Upgraded all components to vue 3 & typescript
This commit is contained in:
		| @@ -1,44 +1,45 @@ | ||||
| <template> | ||||
|   <div class="cast"> | ||||
|     <ol class="persons"> | ||||
|       <CastListItem v-for="person in cast" :person="person" :key="person.id" /> | ||||
|       <CastListItem | ||||
|         v-for="credit in cast" | ||||
|         :creditItem="credit" | ||||
|         :key="credit.id" | ||||
|       /> | ||||
|     </ol> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import CastListItem from "src/components/CastListItem"; | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|   import CastListItem from "src/components/CastListItem.vue"; | ||||
|   import type { MediaTypes, CreditTypes } from "../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   name: "CastList", | ||||
|   components: { CastListItem }, | ||||
|   props: { | ||||
|     cast: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     } | ||||
|   interface Props { | ||||
|     cast: Array<MediaTypes | CreditTypes>; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| .cast { | ||||
|   position: relative; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   .cast { | ||||
|     position: relative; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|  | ||||
|   ol { | ||||
|     overflow-x: scroll; | ||||
|     padding: 0; | ||||
|     list-style-type: none; | ||||
|     margin: 0; | ||||
|     display: flex; | ||||
|     ol { | ||||
|       overflow-x: scroll; | ||||
|       padding: 0; | ||||
|       list-style-type: none; | ||||
|       margin: 0; | ||||
|       display: flex; | ||||
|  | ||||
|     scrollbar-width: none; /* for Firefox */ | ||||
|       scrollbar-width: none; /* for Firefox */ | ||||
|  | ||||
|     &::-webkit-scrollbar { | ||||
|       display: none; /* for Chrome, Safari, and Opera */ | ||||
|       &::-webkit-scrollbar { | ||||
|         display: none; /* for Chrome, Safari, and Opera */ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,121 +1,116 @@ | ||||
| <template> | ||||
|   <li class="card"> | ||||
|     <a @click="openCastItem"> | ||||
|       <img class="persons--image" :src="pictureUrl" /> | ||||
|       <p class="name">{{ person.name || person.title }}</p> | ||||
|       <p class="meta">{{ person.character || person.year }}</p> | ||||
|       <img :src="pictureUrl" /> | ||||
|       <p class="name">{{ creditItem.name || creditItem.title }}</p> | ||||
|       <p class="meta">{{ creditItem.character || creditItem.year }}</p> | ||||
|     </a> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from "vuex"; | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import type { MediaTypes, CreditTypes } from "../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   name: "CastListItem", | ||||
|   props: { | ||||
|     person: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["open"]), | ||||
|     openCastItem() { | ||||
|       let { id, type } = this.person; | ||||
|  | ||||
|       if (type) { | ||||
|         this.open({ id, type }); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     pictureUrl() { | ||||
|       const { profile_path, poster_path, poster } = this.person; | ||||
|       if (profile_path) return "https://image.tmdb.org/t/p/w185" + profile_path; | ||||
|       else if (poster_path) | ||||
|         return "https://image.tmdb.org/t/p/w185" + poster_path; | ||||
|       else if (poster) return "https://image.tmdb.org/t/p/w185" + poster; | ||||
|  | ||||
|       return "/assets/no-image_small.svg"; | ||||
|     } | ||||
|   interface Props { | ||||
|     creditItem: MediaTypes | CreditTypes; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const pictureUrl = computed(() => { | ||||
|     const baseUrl = "https://image.tmdb.org/t/p/w185"; | ||||
|  | ||||
|     if ("profile_path" in props.creditItem && props.creditItem.profile_path) { | ||||
|       return baseUrl + props.creditItem.profile_path; | ||||
|     } else if ("poster" in props.creditItem && props.creditItem.poster) { | ||||
|       return baseUrl + props.creditItem.poster; | ||||
|     } | ||||
|  | ||||
|     return "/assets/no-image_small.svg"; | ||||
|   }); | ||||
|  | ||||
|   function openCastItem() { | ||||
|     store.dispatch("popup/open", { ...props.creditItem }); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| li a p:first-of-type { | ||||
|   padding-top: 10px; | ||||
| } | ||||
|  | ||||
| li.card p { | ||||
|   font-size: 1em; | ||||
|   padding: 0 10px; | ||||
|   margin: 0; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   max-height: calc(10px + ((16px * var(--line-height)) * 3)); | ||||
| } | ||||
|  | ||||
| li.card { | ||||
|   margin: 10px; | ||||
|   margin-right: 4px; | ||||
|   padding-bottom: 10px; | ||||
|   border-radius: 8px; | ||||
|   overflow: hidden; | ||||
|  | ||||
|   min-width: 140px; | ||||
|   width: 140px; | ||||
|   background-color: var(--background-color-secondary); | ||||
|   color: var(--text-color); | ||||
|  | ||||
|   transition: all 0.3s ease; | ||||
|   transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||
|  | ||||
|   &:first-of-type { | ||||
|     margin-left: 0; | ||||
|   li a p:first-of-type { | ||||
|     padding-top: 10px; | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); | ||||
|     transform: scale(1.03); | ||||
|   } | ||||
|  | ||||
|   .name { | ||||
|     font-weight: 500; | ||||
|   } | ||||
|  | ||||
|   .character { | ||||
|     font-size: 0.9em; | ||||
|   } | ||||
|  | ||||
|   .meta { | ||||
|     font-size: 0.9em; | ||||
|     color: var(--text-color-70); | ||||
|     display: -webkit-box; | ||||
|   li.card p { | ||||
|     font-size: 1em; | ||||
|     padding: 0 10px; | ||||
|     margin: 0; | ||||
|     overflow: hidden; | ||||
|     -webkit-line-clamp: 1; | ||||
|     -webkit-box-orient: vertical; | ||||
|     // margin-top: auto; | ||||
|     max-height: calc((0.9em * var(--line-height)) * 1); | ||||
|     text-overflow: ellipsis; | ||||
|     max-height: calc(10px + ((16px * var(--line-height)) * 3)); | ||||
|   } | ||||
|  | ||||
|   a { | ||||
|     display: block; | ||||
|     text-decoration: none; | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|   } | ||||
|   li.card { | ||||
|     margin: 10px; | ||||
|     margin-right: 4px; | ||||
|     padding-bottom: 10px; | ||||
|     border-radius: 8px; | ||||
|     overflow: hidden; | ||||
|     cursor: pointer; | ||||
|  | ||||
|   img { | ||||
|     width: 100%; | ||||
|     height: auto; | ||||
|     max-height: 210px; | ||||
|     background-color: var(--background-color); | ||||
|     object-fit: cover; | ||||
|     min-width: 140px; | ||||
|     width: 140px; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     color: var(--text-color); | ||||
|  | ||||
|     transition: all 0.3s ease; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||
|  | ||||
|     &:first-of-type { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); | ||||
|       transform: scale(1.03); | ||||
|     } | ||||
|  | ||||
|     .name { | ||||
|       font-weight: 500; | ||||
|     } | ||||
|  | ||||
|     .character { | ||||
|       font-size: 0.9em; | ||||
|     } | ||||
|  | ||||
|     .meta { | ||||
|       font-size: 0.9em; | ||||
|       color: var(--text-color-70); | ||||
|       display: -webkit-box; | ||||
|       overflow: hidden; | ||||
|       -webkit-line-clamp: 1; | ||||
|       -webkit-box-orient: vertical; | ||||
|       // margin-top: auto; | ||||
|       max-height: calc((0.9em * var(--line-height)) * 1); | ||||
|     } | ||||
|  | ||||
|     a { | ||||
|       display: block; | ||||
|       text-decoration: none; | ||||
|       height: 100%; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       width: 100%; | ||||
|       height: auto; | ||||
|       max-height: 210px; | ||||
|       background-color: var(--background-color); | ||||
|       object-fit: cover; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| <template> | ||||
|   <header | ||||
|     :class="{ expanded, noselect: true }" | ||||
|     v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }" | ||||
|   > | ||||
|   <header ref="headerElement" :class="{ expanded, noselect: true }"> | ||||
|     <img :src="bannerImage" ref="imageElement" /> | ||||
|     <div class="container"> | ||||
|       <h1 class="title">Request movies or tv shows</h1> | ||||
|       <strong class="subtitle" | ||||
| @@ -10,150 +8,205 @@ | ||||
|       > | ||||
|     </div> | ||||
|  | ||||
|     <div class="expand-icon" @click="expanded = !expanded"> | ||||
|     <div class="expand-icon" @click="expand" @mouseover="upgradeImage"> | ||||
|       <IconExpand v-if="!expanded" /> | ||||
|       <IconShrink v-else /> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import IconExpand from "../icons/IconExpand.vue"; | ||||
| import IconShrink from "../icons/IconShrink.vue"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, onMounted } from "vue"; | ||||
|   import IconExpand from "@/icons/IconExpand.vue"; | ||||
|   import IconShrink from "@/icons/IconShrink.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
| export default { | ||||
|   components: { IconExpand, IconShrink }, | ||||
|   props: { | ||||
|     image: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       images: [ | ||||
|         "pulp-fiction.jpg", | ||||
|         "arrival.jpg", | ||||
|         "dune.jpg", | ||||
|         "mandalorian.jpg" | ||||
|       ], | ||||
|       imageFile: undefined, | ||||
|       expanded: false | ||||
|     }; | ||||
|   }, | ||||
|   beforeMount() { | ||||
|     if (this.image && this.image.length > 0) { | ||||
|       this.imageFile = this.image; | ||||
|     } else { | ||||
|       this.imageFile = `/assets/${ | ||||
|         this.images[Math.floor(Math.random() * this.images.length)] | ||||
|       }`; | ||||
|   const ASSET_URL = "https://request.movie/assets/"; | ||||
|   const images: Array<string> = [ | ||||
|     "pulp-fiction.jpg", | ||||
|     "arrival.jpg", | ||||
|     "disaster-artist.jpg", | ||||
|     "dune.jpg", | ||||
|     "mandalorian.jpg" | ||||
|   ]; | ||||
|  | ||||
|   const bannerImage: Ref<string> = ref(); | ||||
|   const expanded: Ref<boolean> = ref(false); | ||||
|   const headerElement: Ref<HTMLElement> = ref(null); | ||||
|   const imageElement: Ref<HTMLImageElement> = ref(null); | ||||
|   const defaultHeaderHeight: Ref<string> = ref(); | ||||
|   const disableProxy = true; | ||||
|  | ||||
|   bannerImage.value = randomImage(); | ||||
|  | ||||
|   function expand() { | ||||
|     expanded.value = !expanded.value; | ||||
|     let height = defaultHeaderHeight?.value; | ||||
|  | ||||
|     if (expanded.value) { | ||||
|       const aspectRation = | ||||
|         imageElement.value.naturalHeight / imageElement.value.naturalWidth; | ||||
|       height = `${imageElement.value.clientWidth * aspectRation}px`; | ||||
|       defaultHeaderHeight.value = headerElement.value.style.height; | ||||
|     } | ||||
|  | ||||
|     headerElement.value.style.setProperty("--header-height", height); | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   function randomImage(): string { | ||||
|     const image = images[Math.floor(Math.random() * images?.length)]; | ||||
|     return ASSET_URL + image; | ||||
|   } | ||||
|  | ||||
|   // function sliceToHeaderSize(url: string): string { | ||||
|   //   let width = headerElement.value?.getBoundingClientRect()?.width || 1349; | ||||
|   //   let height = headerElement.value?.getBoundingClientRect()?.height || 261; | ||||
|  | ||||
|   //   if (disableProxy) return url; | ||||
|  | ||||
|   //   return buildProxyURL(width, height, url); | ||||
|   // } | ||||
|  | ||||
|   // function upgradeImage() { | ||||
|   //   if (disableProxy || imageUpgraded.value == true) return; | ||||
|  | ||||
|   //   const headerSize = 90; | ||||
|   //   const height = window.innerHeight - headerSize; | ||||
|   //   const width = window.innerWidth - headerSize; | ||||
|  | ||||
|   //   const proxyHost = `http://imgproxy.schleppe:8080/insecure/`; | ||||
|   //   const proxySizeOptions = `q:65/plain/`; | ||||
|  | ||||
|   //   bannerImage.value = `${proxyHost}${proxySizeOptions}${ | ||||
|   //     ASSET_URL + image.value | ||||
|   //   }`; | ||||
|   // } | ||||
|  | ||||
|   // function buildProxyURL(width: number, height: number, asset: string): string { | ||||
|   //   const proxyHost = `http://imgproxy.schleppe:8080/insecure/`; | ||||
|   //   const proxySizeOptions = `resize:fill:${width}:${height}:ce/q:65/plain/`; | ||||
|   //   return `${proxyHost}${proxySizeOptions}${asset}`; | ||||
|   // } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| header { | ||||
|   width: 100%; | ||||
|   height: 25vh; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   background-size: cover; | ||||
|   background-repeat: no-repeat; | ||||
|   background-position: 50% 50%; | ||||
|   position: relative; | ||||
|  | ||||
|   &.expanded { | ||||
|     height: calc(100vh - var(--header-size)); | ||||
|     width: calc(100vw - var(--header-size)); | ||||
|   header { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     position: relative; | ||||
|     transition: height 0.5s ease; | ||||
|     overflow: hidden; | ||||
|     --header-height: 261px; | ||||
|  | ||||
|     @include mobile { | ||||
|       width: 100vw; | ||||
|       height: 100vh; | ||||
|       --header-height: 25vh; | ||||
|     } | ||||
|  | ||||
|     height: var(--header-height); | ||||
|  | ||||
|     > * { | ||||
|       z-index: 1; | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       position: absolute; | ||||
|       z-index: 0; | ||||
|       object-fit: cover; | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     &.expanded { | ||||
|       // height: calc(100vh - var(--header-size)); | ||||
|       // width: calc(100vw - var(--header-size)); | ||||
|  | ||||
|       // @include mobile { | ||||
|       //   width: 100vw; | ||||
|       //   height: 100vh; | ||||
|       // } | ||||
|  | ||||
|       &:before { | ||||
|         background-color: transparent; | ||||
|       } | ||||
|  | ||||
|       .title, | ||||
|       .subtitle { | ||||
|         opacity: 0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .expand-icon { | ||||
|       visibility: hidden; | ||||
|       opacity: 0; | ||||
|       transition: all 0.5s ease-in-out; | ||||
|       height: 1.8rem; | ||||
|       width: 1.8rem; | ||||
|       fill: var(--text-color-50); | ||||
|  | ||||
|       position: absolute; | ||||
|       top: 0.5rem; | ||||
|       right: 1rem; | ||||
|  | ||||
|       &:hover { | ||||
|         cursor: pointer; | ||||
|         fill: var(--text-color-90); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       .expand-icon { | ||||
|         visibility: visible; | ||||
|         opacity: 1; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:before { | ||||
|       background-color: transparent; | ||||
|       content: ""; | ||||
|       z-index: 1; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       background-color: var(--background-70); | ||||
|       transition: inherit; | ||||
|     } | ||||
|  | ||||
|     .title, | ||||
|     .subtitle { | ||||
|       opacity: 0; | ||||
|     .container { | ||||
|       text-align: center; | ||||
|       position: relative; | ||||
|       transition: color 0.5s ease; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .expand-icon { | ||||
|     visibility: hidden; | ||||
|     opacity: 0; | ||||
|     transition: all 0.5s ease-in-out; | ||||
|     height: 1.8rem; | ||||
|     width: 1.8rem; | ||||
|     fill: var(--text-color-50); | ||||
|  | ||||
|     position: absolute; | ||||
|     top: 0.5rem; | ||||
|     right: 1rem; | ||||
|  | ||||
|     &:hover { | ||||
|       cursor: pointer; | ||||
|       fill: var(--text-color-90); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     .expand-icon { | ||||
|       visibility: visible; | ||||
|     .title { | ||||
|       font-weight: 500; | ||||
|       font-size: 22px; | ||||
|       text-transform: uppercase; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color; | ||||
|       margin: 0; | ||||
|       opacity: 1; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 2.5rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .subtitle { | ||||
|       display: block; | ||||
|       font-size: 14px; | ||||
|       font-weight: 300; | ||||
|       color: $text-color-70; | ||||
|       margin: 5px 0; | ||||
|       opacity: 1; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 1.3rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:before { | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background-color: var(--background-70); | ||||
|     transition: inherit; | ||||
|   } | ||||
|  | ||||
|   .container { | ||||
|     text-align: center; | ||||
|     position: relative; | ||||
|     transition: color 0.5s ease; | ||||
|   } | ||||
|  | ||||
|   .title { | ||||
|     font-weight: 500; | ||||
|     font-size: 22px; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     color: $text-color; | ||||
|     margin: 0; | ||||
|     opacity: 1; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 2.5rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .subtitle { | ||||
|     display: block; | ||||
|     font-size: 14px; | ||||
|     font-weight: 300; | ||||
|     color: $text-color-70; | ||||
|     margin: 5px 0; | ||||
|     opacity: 1; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 1.3rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,125 +0,0 @@ | ||||
| <template> | ||||
|   <header> | ||||
|     <h2>{{ prettify }}</h2> | ||||
|     <h3>{{ subtitle }}</h3> | ||||
|  | ||||
|     <router-link | ||||
|       v-if="shortList" | ||||
|       :to="urlify" | ||||
|       class="view-more" | ||||
|       :aria-label="`View all ${title}`" | ||||
|     > | ||||
|       View All | ||||
|     </router-link> | ||||
|  | ||||
|     <div v-else-if="info"> | ||||
|       <div v-if="info instanceof Array" class="flex flex-direction-column"> | ||||
|         <span v-for="item in info" :key="item" class="info">{{ item }}</span> | ||||
|       </div> | ||||
|  | ||||
|       <span v-else class="info">{{ info }}</span> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     title: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     subtitle: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: null | ||||
|     }, | ||||
|     info: { | ||||
|       type: [String, Array], | ||||
|       required: false | ||||
|     }, | ||||
|     link: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     shortList: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     urlify: function () { | ||||
|       return `/list/${this.title.toLowerCase().replace(" ", "_")}`; | ||||
|     }, | ||||
|     prettify: function () { | ||||
|       return this.title.includes("_") | ||||
|         ? this.title.split("_").join(" ") | ||||
|         : this.title; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
|  | ||||
| header { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   padding: 0.5rem 0.75rem; | ||||
|   background-color: $background-color; | ||||
|  | ||||
|   position: sticky; | ||||
|   position: -webkit-sticky; | ||||
|   top: $header-size; | ||||
|   z-index: 1; | ||||
|  | ||||
|   h2 { | ||||
|     font-size: 1.4rem; | ||||
|     font-weight: 300; | ||||
|     text-transform: capitalize; | ||||
|     line-height: 1.4rem; | ||||
|     margin: 0; | ||||
|     color: $text-color; | ||||
|   } | ||||
|  | ||||
|   .view-more { | ||||
|     font-size: 0.9rem; | ||||
|     font-weight: 300; | ||||
|     letter-spacing: 0.5px; | ||||
|     color: $text-color-70; | ||||
|     text-decoration: none; | ||||
|     transition: color 0.5s ease; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     &:after { | ||||
|       content: " →"; | ||||
|     } | ||||
|     &:hover { | ||||
|       color: $text-color; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .info { | ||||
|     font-size: 13px; | ||||
|     font-weight: 300; | ||||
|     letter-spacing: 0.5px; | ||||
|     color: $text-color; | ||||
|     text-decoration: none; | ||||
|     text-align: right; | ||||
|   } | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding-left: 1.25rem; | ||||
|   } | ||||
|  | ||||
|   @include desktop-lg-min { | ||||
|     padding-left: 1.75rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										110
									
								
								src/components/PageHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/components/PageHeader.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| <template> | ||||
|   <header> | ||||
|     <h2>{{ prettify }}</h2> | ||||
|     <h3>{{ subtitle }}</h3> | ||||
|  | ||||
|     <router-link | ||||
|       v-if="shortList" | ||||
|       :to="urlify" | ||||
|       class="view-more" | ||||
|       :aria-label="`View all ${title}`" | ||||
|     > | ||||
|       View All | ||||
|     </router-link> | ||||
|  | ||||
|     <div v-else-if="info"> | ||||
|       <div v-if="info instanceof Array" class="flex flex-direction-column"> | ||||
|         <span v-for="item in info" :key="item" class="info">{{ item }}</span> | ||||
|       </div> | ||||
|  | ||||
|       <span v-else class="info">{{ info }}</span> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, computed } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     title: string; | ||||
|     subtitle?: string; | ||||
|     info?: string | Array<string>; | ||||
|     link?: string; | ||||
|     shortList?: boolean; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const urlify = computed(() => { | ||||
|     return `/list/${props.title.toLowerCase().replace(" ", "_")}`; | ||||
|   }); | ||||
|  | ||||
|   const prettify = computed(() => { | ||||
|     return props.title.includes("_") | ||||
|       ? props.title.split("_").join(" ") | ||||
|       : props.title; | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   header { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 0.75rem; | ||||
|     background-color: $background-color; | ||||
|  | ||||
|     position: sticky; | ||||
|     position: -webkit-sticky; | ||||
|     top: $header-size; | ||||
|     z-index: 1; | ||||
|  | ||||
|     h2 { | ||||
|       font-size: 1.4rem; | ||||
|       font-weight: 300; | ||||
|       text-transform: capitalize; | ||||
|       line-height: 1.4rem; | ||||
|       margin: 0; | ||||
|       color: $text-color; | ||||
|     } | ||||
|  | ||||
|     .view-more { | ||||
|       font-size: 0.9rem; | ||||
|       font-weight: 300; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color-70; | ||||
|       text-decoration: none; | ||||
|       transition: color 0.5s ease; | ||||
|       cursor: pointer; | ||||
|  | ||||
|       &:after { | ||||
|         content: " →"; | ||||
|       } | ||||
|       &:hover { | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .info { | ||||
|       font-size: 13px; | ||||
|       font-weight: 300; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color; | ||||
|       text-decoration: none; | ||||
|       text-align: right; | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       padding-left: 1.25rem; | ||||
|     } | ||||
|  | ||||
|     @include desktop-lg-min { | ||||
|       padding-left: 1.75rem; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -9,117 +9,137 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions, mapGetters } from "vuex"; | ||||
| import Movie from "@/components/popup/Movie"; | ||||
| import Person from "@/components/popup/Person"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, onMounted, onBeforeUnmount } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import Movie from "@/components/popup/Movie.vue"; | ||||
|   import Person from "@/components/popup/Person.vue"; | ||||
|   import { ListTypes } from "../interfaces/IList"; | ||||
|   import type { MediaTypes } from "../interfaces/IList"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
| export default { | ||||
|   components: { Movie, Person }, | ||||
|   computed: { | ||||
|     ...mapGetters("popup", ["isOpen", "id", "type"]) | ||||
|   }, | ||||
|   watch: { | ||||
|     isOpen(value) { | ||||
|       value | ||||
|         ? document.getElementsByTagName("body")[0].classList.add("no-scroll") | ||||
|         : document | ||||
|             .getElementsByTagName("body")[0] | ||||
|             .classList.remove("no-scroll"); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["close", "open"]), | ||||
|     checkEventForEscapeKey(event) { | ||||
|       if (event.keyCode == 27) this.close(); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     let id = null; | ||||
|     let type = null; | ||||
|  | ||||
|     if (params.has("movie")) { | ||||
|       id = Number(params.get("movie")); | ||||
|       type = "movie"; | ||||
|     } else if (params.has("show")) { | ||||
|       id = Number(params.get("show")); | ||||
|       type = "show"; | ||||
|     } else if (params.has("person")) { | ||||
|       id = Number(params.get("person")); | ||||
|       type = "person"; | ||||
|     } | ||||
|  | ||||
|     if (id && type) { | ||||
|       this.open({ id, type }); | ||||
|     } | ||||
|  | ||||
|     window.addEventListener("keyup", this.checkEventForEscapeKey); | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     window.removeEventListener("keyup", this.checkEventForEscapeKey); | ||||
|   interface URLQueryParameters { | ||||
|     id: number; | ||||
|     type: ListTypes; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const isOpen: Ref<boolean> = ref(); | ||||
|   const id: Ref<string> = ref(); | ||||
|   const type: Ref<MediaTypes> = ref(); | ||||
|  | ||||
|   const unsubscribe = store.subscribe((mutation, state) => { | ||||
|     if (!mutation.type.includes("popup")) return; | ||||
|  | ||||
|     isOpen.value = state.popup.open; | ||||
|     id.value = state.popup.id; | ||||
|     type.value = state.popup.type; | ||||
|  | ||||
|     console.log("popup state:", isOpen.value); | ||||
|     isOpen.value | ||||
|       ? document.getElementsByTagName("body")[0].classList.add("no-scroll") | ||||
|       : document.getElementsByTagName("body")[0].classList.remove("no-scroll"); | ||||
|   }); | ||||
|  | ||||
|   function getFromURLQuery(): URLQueryParameters { | ||||
|     let id, type; | ||||
|  | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     params.forEach((value, key) => { | ||||
|       if (!(key in ListTypes)) return; | ||||
|  | ||||
|       id = Number(params.get(key)); | ||||
|       type = key; | ||||
|     }); | ||||
|  | ||||
|     return { id, type }; | ||||
|   } | ||||
|  | ||||
|   function open(id: Number, type: string) { | ||||
|     if (!id || !type) return; | ||||
|     store.dispatch("popup/open", { id, type }); | ||||
|   } | ||||
|  | ||||
|   function close() { | ||||
|     store.dispatch("popup/close"); | ||||
|   } | ||||
|  | ||||
|   function checkEventForEscapeKey(event: KeyboardEvent) { | ||||
|     if (event.keyCode !== 27) return; | ||||
|     close(); | ||||
|   } | ||||
|  | ||||
|   window.addEventListener("keyup", checkEventForEscapeKey); | ||||
|  | ||||
|   onMounted(() => { | ||||
|     const { id, type } = getFromURLQuery(); | ||||
|     open(id, type); | ||||
|   }); | ||||
|  | ||||
|   onBeforeUnmount(() => { | ||||
|     unsubscribe(); | ||||
|     window.removeEventListener("keyup", checkEventForEscapeKey); | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .movie-popup { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   z-index: 20; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   background: rgba($dark, 0.93); | ||||
|   -webkit-overflow-scrolling: touch; | ||||
|   overflow: auto; | ||||
|  | ||||
|   &__box { | ||||
|     max-width: 768px; | ||||
|     position: relative; | ||||
|     z-index: 5; | ||||
|     margin: 8vh auto; | ||||
|  | ||||
|     @include mobile { | ||||
|       margin: 0 0 50px 0; | ||||
|     } | ||||
|   } | ||||
|   &__close { | ||||
|     display: block; | ||||
|     position: absolute; | ||||
|   .movie-popup { | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     border: 0; | ||||
|     background: transparent; | ||||
|     width: 40px; | ||||
|     height: 40px; | ||||
|     transition: background 0.5s ease; | ||||
|     cursor: pointer; | ||||
|     z-index: 5; | ||||
|     left: 0; | ||||
|     z-index: 20; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: rgba($dark, 0.93); | ||||
|     -webkit-overflow-scrolling: touch; | ||||
|     overflow: auto; | ||||
|  | ||||
|     &:before, | ||||
|     &:after { | ||||
|       content: ""; | ||||
|     &__box { | ||||
|       max-width: 768px; | ||||
|       position: relative; | ||||
|       z-index: 5; | ||||
|       margin: 8vh auto; | ||||
|  | ||||
|       @include mobile { | ||||
|         margin: 0 0 50px 0; | ||||
|       } | ||||
|     } | ||||
|     &__close { | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 19px; | ||||
|       left: 10px; | ||||
|       width: 20px; | ||||
|       height: 2px; | ||||
|       background: $white; | ||||
|     } | ||||
|     &:before { | ||||
|       transform: rotate(45deg); | ||||
|     } | ||||
|     &:after { | ||||
|       transform: rotate(-45deg); | ||||
|     } | ||||
|     &:hover { | ||||
|       background: $green; | ||||
|       top: 0; | ||||
|       right: 0; | ||||
|       border: 0; | ||||
|       background: transparent; | ||||
|       width: 40px; | ||||
|       height: 40px; | ||||
|       transition: background 0.5s ease; | ||||
|       cursor: pointer; | ||||
|       z-index: 5; | ||||
|  | ||||
|       &:before, | ||||
|       &:after { | ||||
|         content: ""; | ||||
|         display: block; | ||||
|         position: absolute; | ||||
|         top: 19px; | ||||
|         left: 10px; | ||||
|         width: 20px; | ||||
|         height: 2px; | ||||
|         background: $white; | ||||
|       } | ||||
|       &:before { | ||||
|         transform: rotate(45deg); | ||||
|       } | ||||
|       &:after { | ||||
|         transform: rotate(-45deg); | ||||
|       } | ||||
|       &:hover { | ||||
|         background: $green; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -6,9 +6,9 @@ | ||||
|       :class="{ shortList: shortList }" | ||||
|     > | ||||
|       <results-list-item | ||||
|         v-for="(movie, index) in results" | ||||
|         :key="`${movie.type}-${movie.id}-${index}`" | ||||
|         :movie="movie" | ||||
|         v-for="(result, index) in results" | ||||
|         :key="`${result.type}-${result.id}-${index}`" | ||||
|         :listItem="result" | ||||
|       /> | ||||
|     </ul> | ||||
|  | ||||
| @@ -16,68 +16,58 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ResultsListItem from "@/components/ResultsListItem"; | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|   import ResultsListItem from "@/components/ResultsListItem.vue"; | ||||
|   import type { ListResults } from "../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   components: { ResultsListItem }, | ||||
|   props: { | ||||
|     results: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     }, | ||||
|     shortList: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     }, | ||||
|     loading: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     } | ||||
|   interface Props { | ||||
|     results: Array<ListResults>; | ||||
|     shortList?: Boolean; | ||||
|     loading?: Boolean; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| .no-results { | ||||
|   width: 100%; | ||||
|   display: block; | ||||
|   text-align: center; | ||||
|   margin: 1.5rem; | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .results { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); | ||||
|   grid-auto-rows: auto; | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   list-style: none; | ||||
|  | ||||
|   @include mobile { | ||||
|     grid-template-columns: repeat(2, 1fr); | ||||
|   .no-results { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     text-align: center; | ||||
|     margin: 1.5rem; | ||||
|     font-size: 1.2rem; | ||||
|   } | ||||
|  | ||||
|   &.shortList { | ||||
|     overflow: auto; | ||||
|     grid-auto-flow: column; | ||||
|     max-width: 100vw; | ||||
|   .results { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); | ||||
|     grid-auto-rows: auto; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     list-style: none; | ||||
|  | ||||
|     @include noscrollbar; | ||||
|  | ||||
|     > li { | ||||
|       min-width: 225px; | ||||
|     @include mobile { | ||||
|       grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       max-width: calc(100vw - var(--header-size)); | ||||
|     &.shortList { | ||||
|       overflow: auto; | ||||
|       grid-auto-flow: column; | ||||
|       max-width: 100vw; | ||||
|  | ||||
|       @include noscrollbar; | ||||
|  | ||||
|       > li { | ||||
|         min-width: 225px; | ||||
|       } | ||||
|  | ||||
|       @include tablet-min { | ||||
|         max-width: calc(100vw - var(--header-size)); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,6 +1,10 @@ | ||||
| <template> | ||||
|   <li class="movie-item" ref="list-item"> | ||||
|     <figure ref="poster" class="movie-item__poster" @click="openMoviePopup"> | ||||
|     <figure | ||||
|       ref="posterElement" | ||||
|       class="movie-item__poster" | ||||
|       @click="openMoviePopup" | ||||
|     > | ||||
|       <img | ||||
|         class="movie-item__img" | ||||
|         :alt="posterAltText" | ||||
| @@ -8,173 +12,170 @@ | ||||
|         src="/assets/placeholder.png" | ||||
|       /> | ||||
|  | ||||
|       <div v-if="movie.download" class="progress"> | ||||
|         <progress :value="movie.download.progress" max="100"></progress> | ||||
|         <span>{{ movie.download.state }}: {{ movie.download.progress }}%</span> | ||||
|       <div v-if="listItem.download" class="progress"> | ||||
|         <progress :value="listItem.download.progress" max="100"></progress> | ||||
|         <span | ||||
|           >{{ listItem.download.state }}: | ||||
|           {{ listItem.download.progress }}%</span | ||||
|         > | ||||
|       </div> | ||||
|     </figure> | ||||
|  | ||||
|     <div class="movie-item__info"> | ||||
|       <p v-if="movie.title || movie.name" class="movie-item__title"> | ||||
|         {{ movie.title || movie.name }} | ||||
|       <p v-if="listItem.title || listItem.name" class="movie-item__title"> | ||||
|         {{ listItem.title || listItem.name }} | ||||
|       </p> | ||||
|       <p v-if="movie.year">{{ movie.year }}</p> | ||||
|       <p v-if="movie.type == 'person'"> | ||||
|         Known for: {{ movie.known_for_department }} | ||||
|       <p v-if="listItem.year">{{ listItem.year }}</p> | ||||
|       <p v-if="listItem.type == 'person'"> | ||||
|         Known for: {{ listItem.known_for_department }} | ||||
|       </p> | ||||
|     </div> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from "vuex"; | ||||
| import img from "../directives/v-image"; | ||||
| import { buildImageProxyUrl } from "../utils"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { buildImageProxyUrl } from "../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { MediaTypes } from "../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     movie: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   directives: { | ||||
|     img: img | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       poster: null, | ||||
|       observed: false | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     posterAltText: function () { | ||||
|       const type = this.movie.type || ""; | ||||
|       const title = this.movie.title || this.movie.name; | ||||
|       return this.movie.poster | ||||
|         ? `Poster for ${type} ${title}` | ||||
|         : `Missing image for ${type} ${title}`; | ||||
|     }, | ||||
|     imageWidth() { | ||||
|       if (this.image) | ||||
|         return Math.ceil(this.image.getBoundingClientRect().width); | ||||
|     }, | ||||
|     imageHeight() { | ||||
|       if (this.image) | ||||
|         return Math.ceil(this.image.getBoundingClientRect().height); | ||||
|     } | ||||
|   }, | ||||
|   beforeMount() { | ||||
|     if (this.movie.poster == null) { | ||||
|       this.poster = "/assets/no-image.svg"; | ||||
|       return; | ||||
|     } | ||||
|   interface Props { | ||||
|     listItem: MediaTypes; | ||||
|   } | ||||
|  | ||||
|     this.poster = `https://image.tmdb.org/t/p/w500${this.movie.poster}`; | ||||
|     // this.poster = this.buildProxyURL( | ||||
|     //   this.imageWidth, | ||||
|     //   this.imageHeight, | ||||
|     //   assetUrl | ||||
|     // ); | ||||
|   }, | ||||
|   mounted() { | ||||
|     const poster = this.$refs["poster"]; | ||||
|     this.image = poster.getElementsByTagName("img")[0]; | ||||
|     if (this.image == null) return; | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; | ||||
|   const IMAGE_FALLBACK = "/assets/no-image.svg"; | ||||
|   const poster: Ref<string> = ref(); | ||||
|   const posterElement: Ref<HTMLElement> = ref(null); | ||||
|   const observed: Ref<boolean> = ref(false); | ||||
|  | ||||
|   poster.value = props.listItem?.poster | ||||
|     ? IMAGE_BASE_URL + props.listItem?.poster | ||||
|     : IMAGE_FALLBACK; | ||||
|  | ||||
|   onMounted(observePosterAndSetImageSource); | ||||
|  | ||||
|   function observePosterAndSetImageSource() { | ||||
|     const imageElement = posterElement.value.getElementsByTagName("img")[0]; | ||||
|     if (imageElement == null) return; | ||||
|  | ||||
|     const imageObserver = new IntersectionObserver((entries, imgObserver) => { | ||||
|       entries.forEach(entry => { | ||||
|         if (entry.isIntersecting && this.observed == false) { | ||||
|           const lazyImage = entry.target; | ||||
|         if (entry.isIntersecting && observed.value == false) { | ||||
|           const lazyImage = entry.target as HTMLImageElement; | ||||
|           lazyImage.src = lazyImage.dataset.src; | ||||
|           poster.className = poster.className + " is-loaded"; | ||||
|           this.observed = true; | ||||
|           posterElement.value.classList.add("is-loaded"); | ||||
|           observed.value = true; | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     imageObserver.observe(this.image); | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["open"]), | ||||
|     openMoviePopup() { | ||||
|       this.open({ | ||||
|         id: this.movie.id, | ||||
|         type: this.movie.type | ||||
|       }); | ||||
|     } | ||||
|     imageObserver.observe(imageElement); | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   function openMoviePopup() { | ||||
|     store.dispatch("popup/open", { ...props.listItem }); | ||||
|   } | ||||
|  | ||||
|   const posterAltText = computed(() => { | ||||
|     const type = props.listItem.type || ""; | ||||
|     let title: string = ""; | ||||
|  | ||||
|     if ("name" in props.listItem) title = props.listItem.name; | ||||
|     else if ("title" in props.listItem) title = props.listItem.title; | ||||
|  | ||||
|     return props.listItem.poster | ||||
|       ? `Poster for ${type} ${title}` | ||||
|       : `Missing image for ${type} ${title}`; | ||||
|   }); | ||||
|  | ||||
|   const imageSize = computed(() => { | ||||
|     if (!posterElement.value) return; | ||||
|     const { height, width } = posterElement.value.getBoundingClientRect(); | ||||
|     return { | ||||
|       height: Math.ceil(height), | ||||
|       width: Math.ceil(width) | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   // import img from "../directives/v-image"; | ||||
|   //   directives: { | ||||
|   //     img: img | ||||
|   //   }, | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| .movie-item { | ||||
|   padding: 15px; | ||||
|   width: 100%; | ||||
|   background-color: var(--background-color); | ||||
|   .movie-item { | ||||
|     padding: 15px; | ||||
|     width: 100%; | ||||
|     background-color: var(--background-color); | ||||
|  | ||||
|   &:hover &__info > p { | ||||
|     color: $text-color; | ||||
|   } | ||||
|  | ||||
|   &__poster { | ||||
|     text-decoration: none; | ||||
|     color: $text-color-70; | ||||
|     font-weight: 300; | ||||
|     position: relative; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|     &::before { | ||||
|       content: ""; | ||||
|       position: absolute; | ||||
|       z-index: 1; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       background-color: var(--background-color); | ||||
|       transition: 1s background-color ease; | ||||
|     &:hover &__info > p { | ||||
|       color: $text-color; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       transform: scale(1.03); | ||||
|       box-shadow: 0 0 10px rgba($dark, 0.1); | ||||
|     } | ||||
|  | ||||
|     &.is-loaded::before { | ||||
|       background-color: transparent; | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       width: 100%; | ||||
|       border-radius: 10px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__info { | ||||
|     padding-top: 10px; | ||||
|     font-weight: 300; | ||||
|  | ||||
|     > p { | ||||
|     &__poster { | ||||
|       text-decoration: none; | ||||
|       color: $text-color-70; | ||||
|       margin: 0; | ||||
|       font-size: 14px; | ||||
|       letter-spacing: 0.5px; | ||||
|       transition: color 0.5s ease; | ||||
|       cursor: pointer; | ||||
|       @include mobile-ls-min { | ||||
|         font-size: 12px; | ||||
|       font-weight: 300; | ||||
|       position: relative; | ||||
|       transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|       &::before { | ||||
|         content: ""; | ||||
|         position: absolute; | ||||
|         z-index: 1; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         background-color: var(--background-color); | ||||
|         transition: 1s background-color ease; | ||||
|       } | ||||
|       @include tablet-min { | ||||
|         font-size: 14px; | ||||
|  | ||||
|       &:hover { | ||||
|         transform: scale(1.03); | ||||
|         box-shadow: 0 0 10px rgba($dark, 0.1); | ||||
|       } | ||||
|  | ||||
|       &.is-loaded::before { | ||||
|         background-color: transparent; | ||||
|       } | ||||
|  | ||||
|       img { | ||||
|         width: 100%; | ||||
|         border-radius: 10px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__title { | ||||
|     font-weight: 400; | ||||
|     &__info { | ||||
|       padding-top: 10px; | ||||
|       font-weight: 300; | ||||
|  | ||||
|       > p { | ||||
|         color: $text-color-70; | ||||
|         margin: 0; | ||||
|         font-size: 14px; | ||||
|         letter-spacing: 0.5px; | ||||
|         transition: color 0.5s ease; | ||||
|         cursor: pointer; | ||||
|         @include mobile-ls-min { | ||||
|           font-size: 12px; | ||||
|         } | ||||
|         @include tablet-min { | ||||
|           font-size: 14px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__title { | ||||
|       font-weight: 400; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <div ref="resultSection" class="resultSection"> | ||||
|     <list-header v-bind="{ title, info, shortList }" /> | ||||
|     <page-header v-bind="{ title, info, shortList }" /> | ||||
|  | ||||
|     <div | ||||
|       v-if="!loadedPages.includes(1) && loading == false" | ||||
| @@ -26,177 +26,186 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ListHeader from "@/components/ListHeader"; | ||||
| import ResultsList from "@/components/ResultsList"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import store from "@/store"; | ||||
| import { getTmdbMovieListByName } from "@/api"; | ||||
| import Loader from "@/components/ui/Loader"; | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, ref, computed, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ResultsList from "@/components/ResultsList.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import Loader from "@/components/ui/Loader.vue"; | ||||
|   import { getTmdbMovieListByName } from "../api"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { IList, ListResults } from "../interfaces/IList"; | ||||
|   import type ISection from "../interfaces/ISection"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     apiFunction: { | ||||
|       type: Function, | ||||
|       required: true | ||||
|     }, | ||||
|     title: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     shortList: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     } | ||||
|   }, | ||||
|   components: { ListHeader, ResultsList, SeasonedButton, Loader }, | ||||
|   data() { | ||||
|     return { | ||||
|       results: [], | ||||
|       page: 1, | ||||
|       loadedPages: [], | ||||
|       totalPages: -1, | ||||
|       totalResults: 0, | ||||
|       loading: true, | ||||
|       autoLoad: false, | ||||
|       observer: undefined | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     info() { | ||||
|       if (this.results.length === 0) return [null, null]; | ||||
|       return [this.pageCount, this.resultCount]; | ||||
|     }, | ||||
|     resultCount() { | ||||
|       const loadedResults = this.results.length; | ||||
|       const totalResults = this.totalResults < 10000 ? this.totalResults : "∞"; | ||||
|       return `${loadedResults} of ${totalResults} results`; | ||||
|     }, | ||||
|     pageCount() { | ||||
|       return `Page ${this.page} of ${this.totalPages}`; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     loadMore() { | ||||
|       if (!this.autoLoad) { | ||||
|         this.autoLoad = true; | ||||
|       } | ||||
|  | ||||
|       this.loading = true; | ||||
|       let maxPage = [...this.loadedPages].slice(-1)[0]; | ||||
|  | ||||
|       if (maxPage == NaN) return; | ||||
|       this.page = maxPage + 1; | ||||
|       this.getListResults(); | ||||
|     }, | ||||
|     loadLess() { | ||||
|       this.loading = true; | ||||
|       const minPage = this.loadedPages[0]; | ||||
|       if (minPage === 1) return; | ||||
|  | ||||
|       this.page = minPage - 1; | ||||
|       this.getListResults(true); | ||||
|     }, | ||||
|     updateQueryParams() { | ||||
|       let params = new URLSearchParams(window.location.search); | ||||
|       if (params.has("page")) { | ||||
|         params.set("page", this.page); | ||||
|       } else if (this.page > 1) { | ||||
|         params.append("page", this.page); | ||||
|       } | ||||
|  | ||||
|       window.history.replaceState( | ||||
|         {}, | ||||
|         "search", | ||||
|         `${window.location.protocol}//${window.location.hostname}${ | ||||
|           window.location.port ? `:${window.location.port}` : "" | ||||
|         }${window.location.pathname}${ | ||||
|           params.toString().length ? `?${params}` : "" | ||||
|         }` | ||||
|       ); | ||||
|     }, | ||||
|     getPageFromUrl() { | ||||
|       return new URLSearchParams(window.location.search).get("page"); | ||||
|     }, | ||||
|     getListResults(front = false) { | ||||
|       this.apiFunction(this.page) | ||||
|         .then(results => { | ||||
|           if (!front) this.results = this.results.concat(...results.results); | ||||
|           else this.results = results.results.concat(...this.results); | ||||
|           this.page = results.page; | ||||
|           this.loadedPages.push(this.page); | ||||
|           this.loadedPages = this.loadedPages.sort((a, b) => a - b); | ||||
|           this.totalPages = results.total_pages; | ||||
|           this.totalResults = results.total_results; | ||||
|         }) | ||||
|         .then(this.updateQueryParams) | ||||
|         .finally(() => (this.loading = false)); | ||||
|     }, | ||||
|     setupAutoloadObserver() { | ||||
|       this.observer = new IntersectionObserver(this.handleButtonIntersection, { | ||||
|         root: this.$refs.resultSection.$el, | ||||
|         rootMargin: "0px", | ||||
|         threshold: 0 | ||||
|       }); | ||||
|  | ||||
|       this.observer.observe(this.$refs.loadMoreButton); | ||||
|     }, | ||||
|     handleButtonIntersection(entries) { | ||||
|       entries.map(entry => | ||||
|         entry.isIntersecting && this.autoLoad ? this.loadMore() : null | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.page = this.getPageFromUrl() || this.page; | ||||
|     if (this.results.length === 0) this.getListResults(); | ||||
|  | ||||
|     if (!this.shortList) { | ||||
|       store.dispatch( | ||||
|         "documentTitle/updateTitle", | ||||
|         `${this.$router.history.current.name} ${this.title}` | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     if (!this.shortList) { | ||||
|       this.setupAutoloadObserver(); | ||||
|     } | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.observer = undefined; | ||||
|   interface Props extends ISection { | ||||
|     title: string; | ||||
|     apiFunction: (page: number) => Promise<IList>; | ||||
|     shortList?: boolean; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const results: Ref<ListResults> = ref([]); | ||||
|   const page: Ref<number> = ref(1); | ||||
|   const loadedPages: Ref<number[]> = ref([]); | ||||
|   const totalResults: Ref<number> = ref(0); | ||||
|   const totalPages: Ref<number> = ref(0); | ||||
|   const loading: Ref<boolean> = ref(true); | ||||
|   const autoLoad: Ref<boolean> = ref(false); | ||||
|   const observer: Ref<any> = ref(null); | ||||
|   const resultSection = ref(null); | ||||
|   const loadMoreButton = ref(null); | ||||
|  | ||||
|   page.value = getPageFromUrl() || page.value; | ||||
|   if (results.value?.length === 0) getListResults(); | ||||
|  | ||||
|   const info = computed(() => { | ||||
|     if (results.value.length === 0) return [null, null]; | ||||
|  | ||||
|     const pageCount = pageCountString(page.value, totalPages.value); | ||||
|     const resultCount = resultCountString(results.value, totalResults.value); | ||||
|     return [pageCount, resultCount]; | ||||
|   }); | ||||
|  | ||||
|   onMounted(() => { | ||||
|     if (!props?.shortList) setupAutoloadObserver(); | ||||
|   }); | ||||
|  | ||||
|   function pageCountString(page: Number, totalPages: Number) { | ||||
|     return `Page ${page} of ${totalPages}`; | ||||
|   } | ||||
|  | ||||
|   function resultCountString(results: ListResults, totalResults: number) { | ||||
|     const loadedResults = results.length; | ||||
|     const _totalResults = totalResults < 10000 ? totalResults : "∞"; | ||||
|     return `${loadedResults} of ${_totalResults} results`; | ||||
|   } | ||||
|  | ||||
|   function getPageFromUrl() { | ||||
|     const page = new URLSearchParams(window.location.search).get("page"); | ||||
|     if (!page) return null; | ||||
|  | ||||
|     return Number(page); | ||||
|   } | ||||
|  | ||||
|   function getListResults(front = false) { | ||||
|     props | ||||
|       .apiFunction(page.value) | ||||
|       .then(listResponse => { | ||||
|         if (!front) | ||||
|           results.value = results.value.concat(...listResponse.results); | ||||
|         else results.value = listResponse.results.concat(...results.value); | ||||
|  | ||||
|         page.value = listResponse.page; | ||||
|         loadedPages.value.push(page.value); | ||||
|         loadedPages.value = loadedPages.value.sort((a, b) => a - b); | ||||
|         totalPages.value = listResponse.total_pages; | ||||
|         totalResults.value = listResponse.total_results; | ||||
|       }) | ||||
|       .then(updateQueryParams) | ||||
|       .finally(() => (loading.value = false)); | ||||
|   } | ||||
|  | ||||
|   function loadMore() { | ||||
|     if (!autoLoad.value) { | ||||
|       autoLoad.value = true; | ||||
|     } | ||||
|  | ||||
|     loading.value = true; | ||||
|     let maxPage = [...loadedPages.value].slice(-1)[0]; | ||||
|  | ||||
|     if (maxPage == NaN) return; | ||||
|     page.value = maxPage + 1; | ||||
|     getListResults(); | ||||
|   } | ||||
|  | ||||
|   function loadLess() { | ||||
|     loading.value = true; | ||||
|     const minPage = loadedPages.value[0]; | ||||
|     if (minPage === 1) return; | ||||
|  | ||||
|     page.value = minPage - 1; | ||||
|     getListResults(true); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams() { | ||||
|     let params = new URLSearchParams(window.location.search); | ||||
|     if (params.has("page")) { | ||||
|       params.set("page", page.value?.toString()); | ||||
|     } else if (page.value > 1) { | ||||
|       params.append("page", page.value?.toString()); | ||||
|     } | ||||
|  | ||||
|     window.history.replaceState( | ||||
|       {}, | ||||
|       "search", | ||||
|       `${window.location.protocol}//${window.location.hostname}${ | ||||
|         window.location.port ? `:${window.location.port}` : "" | ||||
|       }${window.location.pathname}${ | ||||
|         params.toString().length ? `?${params}` : "" | ||||
|       }` | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function handleButtonIntersection(entries) { | ||||
|     entries.map(entry => | ||||
|       entry.isIntersecting && autoLoad.value ? loadMore() : null | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function setupAutoloadObserver() { | ||||
|     observer.value = new IntersectionObserver(handleButtonIntersection, { | ||||
|       root: resultSection.value.$el, | ||||
|       rootMargin: "0px", | ||||
|       threshold: 0 | ||||
|     }); | ||||
|  | ||||
|     observer.value.observe(loadMoreButton.value); | ||||
|   } | ||||
|  | ||||
|   //   created() { | ||||
|   //     if (!this.shortList) { | ||||
|   //       store.dispatch( | ||||
|   //         "documentTitle/updateTitle", | ||||
|   //         `${this.$router.history.current.name} ${this.title}` | ||||
|   //       ); | ||||
|   //     } | ||||
|   //   }, | ||||
|   //   beforeDestroy() { | ||||
|   //     this.observer = undefined; | ||||
|   //   } | ||||
|   // }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .resultSection { | ||||
|   background-color: var(--background-color); | ||||
| } | ||||
|  | ||||
| .button-container { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .load-button { | ||||
|   margin: 2rem 0; | ||||
|  | ||||
|   @include mobile { | ||||
|     margin: 1rem 0; | ||||
|   .resultSection { | ||||
|     background-color: var(--background-color); | ||||
|   } | ||||
|  | ||||
|   &:last-of-type { | ||||
|     margin-bottom: 4rem; | ||||
|   .button-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .load-button { | ||||
|     margin: 2rem 0; | ||||
|  | ||||
|     @include mobile { | ||||
|       margin-bottom: 2rem; | ||||
|       margin: 1rem 0; | ||||
|     } | ||||
|  | ||||
|     &:last-of-type { | ||||
|       margin-bottom: 4rem; | ||||
|  | ||||
|       @include mobile { | ||||
|         margin-bottom: 2rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,747 +0,0 @@ | ||||
| <template> | ||||
|   <div v-if="show" class="container"> | ||||
|     <h2 class="torrentHeader-text editable"> | ||||
|       Searching for: | ||||
|       <span :contenteditable="!edit" @input="this.handleInput">{{ | ||||
|         query | ||||
|       }}</span> | ||||
|  | ||||
|       <IconSearch | ||||
|         class="icon" | ||||
|         v-if="editedSearchQuery && editedSearchQuery.length" | ||||
|       /> | ||||
|       <IconEdit v-else class="icon" @click="() => (this.edit = !this.edit)" /> | ||||
|     </h2> | ||||
|  | ||||
|     <div v-if="!loading"> | ||||
|       <div v-if="torrents.length > 0"> | ||||
|         <table> | ||||
|           <thead class="table__header noselect"> | ||||
|             <tr> | ||||
|               <th | ||||
|                 v-for="column in columns" | ||||
|                 :key="column" | ||||
|                 @click="sortTable(column)" | ||||
|                 :class="column === selectedColumn ? 'active' : null" | ||||
|               > | ||||
|                 {{ column }} | ||||
|                 <span v-if="prevCol === column && direction">↑</span> | ||||
|                 <span v-if="prevCol === column && !direction">↓</span> | ||||
|               </th> | ||||
|             </tr> | ||||
|             <!--             <th | ||||
|               @click="sortTable('name')" | ||||
|               :class="selectedSortableClass('name')" | ||||
|             > | ||||
|               <span>Name</span> | ||||
|               <span v-if="prevCol === 'name' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'name' && !direction">↓</span> | ||||
|             </th> | ||||
|             <th | ||||
|               @click="sortTable('seed')" | ||||
|               :class="selectedSortableClass('seed')" | ||||
|             > | ||||
|               <span>Seed</span> | ||||
|               <span v-if="prevCol === 'seed' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'seed' && !direction">↓</span> | ||||
|             </th> | ||||
|             <th | ||||
|               @click="sortTable('size')" | ||||
|               :class="selectedSortableClass('size')" | ||||
|             > | ||||
|               <span>Size</span> | ||||
|               <span v-if="prevCol === 'size' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'size' && !direction">↓</span> | ||||
|             </th> | ||||
|  | ||||
|             <th> | ||||
|               <span>Magnet</span> | ||||
|             </th> --> | ||||
|           </thead> | ||||
|  | ||||
|           <tbody> | ||||
|             <tr | ||||
|               v-for="torrent in torrents" | ||||
|               class="table__content" | ||||
|               :key="torrent.magnet" | ||||
|             > | ||||
|               <td @click="expand($event, torrent.name)">{{ torrent.name }}</td> | ||||
|               <td @click="expand($event, torrent.name)">{{ torrent.seed }}</td> | ||||
|               <td @click="expand($event, torrent.name)">{{ torrent.size }}</td> | ||||
|               <td | ||||
|                 @click="sendTorrent(torrent.magnet, torrent.name, $event)" | ||||
|                 class="download" | ||||
|               > | ||||
|                 <IconMagnet /> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <div style="display: flex; justify-content: center; padding: 1rem"> | ||||
|           <seasonedButton @click="resetTorrentsAndToggleEditSearchQuery" | ||||
|             >Edit search query</seasonedButton | ||||
|           > | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         v-else | ||||
|         style=" | ||||
|           display: flex; | ||||
|           padding-bottom: 2rem; | ||||
|           justify-content: center; | ||||
|           flex-direction: column; | ||||
|           width: 100%; | ||||
|           align-items: center; | ||||
|         " | ||||
|       > | ||||
|         <h2>No results found</h2> | ||||
|         <br /> | ||||
|  | ||||
|         <div class="editQuery" v-if="editSearchQuery"> | ||||
|           <seasonedInput | ||||
|             placeholder="Torrent query" | ||||
|             :value.sync="editedSearchQuery" | ||||
|             @enter="fetchTorrents(editedSearchQuery)" | ||||
|           /> | ||||
|  | ||||
|           <div style="height: 45px; width: 5px"></div> | ||||
|  | ||||
|           <seasonedButton @click="fetchTorrents(editedSearchQuery)" | ||||
|             >Search</seasonedButton | ||||
|           > | ||||
|         </div> | ||||
|  | ||||
|         <seasonedButton | ||||
|           @click="toggleEditSearchQuery" | ||||
|           :active="editSearchQuery ? true : false" | ||||
|           >Edit search query</seasonedButton | ||||
|         > | ||||
|       </div> | ||||
|     </div> | ||||
|     <div v-else class="torrentloader"><i></i></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import store from "@/store"; | ||||
| import { sortableSize } from "@/utils"; | ||||
| import { searchTorrents, addMagnet } from "@/api"; | ||||
|  | ||||
| import IconMagnet from "../icons/IconMagnet"; | ||||
| import IconEdit from "../icons/IconEdit"; | ||||
| import IconSearch from "../icons/IconSearch"; | ||||
|  | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import SeasonedInput from "@/components/ui/SeasonedInput"; | ||||
| import ToggleButton from "@/components/ui/ToggleButton"; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     IconMagnet, | ||||
|     IconEdit, | ||||
|     IconSearch, | ||||
|     SeasonedButton, | ||||
|     SeasonedInput, | ||||
|     ToggleButton | ||||
|   }, | ||||
|   props: { | ||||
|     query: { | ||||
|       type: String, | ||||
|       require: true | ||||
|     }, | ||||
|     tmdb_id: { | ||||
|       type: Number, | ||||
|       require: true | ||||
|     }, | ||||
|     tmdb_type: String, | ||||
|     admin: Boolean, | ||||
|     show: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       edit: true, | ||||
|       loading: false, | ||||
|       torrents: [], | ||||
|       torrentResponse: undefined, | ||||
|       currentPage: 0, | ||||
|       prevCol: "", | ||||
|       direction: false, | ||||
|       release_types: ["all"], | ||||
|       selectedRelaseType: "all", | ||||
|       editSearchQuery: false, | ||||
|       editedSearchQuery: "", | ||||
|  | ||||
|       columns: ["name", "seed", "size", "magnet"], | ||||
|       selectedColumn: null | ||||
|     }; | ||||
|   }, | ||||
|   created() { | ||||
|     this.fetchTorrents().then(_ => this.sortTable("size")); | ||||
|   }, | ||||
|   watch: { | ||||
|     selectedRelaseType: function (newValue) { | ||||
|       this.applyFilter(newValue); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     selectedSortableClass(headerName) { | ||||
|       return headerName === this.prevCol ? "active" : ""; | ||||
|     }, | ||||
|     resetTorrentsAndToggleEditSearchQuery() { | ||||
|       this.torrents = []; | ||||
|       this.toggleEditSearchQuery(); | ||||
|     }, | ||||
|     toggleEditSearchQuery() { | ||||
|       this.editSearchQuery = !this.editSearchQuery; | ||||
|     }, | ||||
|     expand(event, name) { | ||||
|       const existingExpandedElement = | ||||
|         document.getElementsByClassName("expanded")[0]; | ||||
|  | ||||
|       const clickedElement = event.target.parentNode; | ||||
|       const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]; | ||||
|  | ||||
|       if (existingExpandedElement) { | ||||
|         const expandedSibling = | ||||
|           event.target.parentNode.nextSibling.className === "expanded"; | ||||
|  | ||||
|         existingExpandedElement.remove(); | ||||
|         const table = document.getElementsByTagName("table")[0]; | ||||
|         table.style.display = "block"; | ||||
|  | ||||
|         if (expandedSibling) { | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const nameRow = document.createElement("tr"); | ||||
|       const nameCol = document.createElement("td"); | ||||
|       nameRow.className = "expanded"; | ||||
|       nameRow.dataset[scopedStyleDataVariable] = ""; | ||||
|       nameCol.innerText = name; | ||||
|       nameCol.dataset[scopedStyleDataVariable] = ""; | ||||
|  | ||||
|       nameRow.appendChild(nameCol); | ||||
|  | ||||
|       clickedElement.insertAdjacentElement("afterend", nameRow); | ||||
|     }, | ||||
|     sendTorrent(magnet, name, event) { | ||||
|       this.$notifications.info({ | ||||
|         title: "Adding torrent 🦜", | ||||
|         description: this.query, | ||||
|         timeout: 3000 | ||||
|       }); | ||||
|  | ||||
|       event.target.parentNode.classList.add("active"); | ||||
|       addMagnet(magnet, name, this.tmdb_id) | ||||
|         .catch(resp => { | ||||
|           console.log("error:", resp.data); | ||||
|         }) | ||||
|         .then(resp => { | ||||
|           console.log("addTorrent resp: ", resp); | ||||
|           this.$notifications.success({ | ||||
|             title: "Torrent added 🎉", | ||||
|             description: this.query, | ||||
|             timeout: 3000 | ||||
|           }); | ||||
|         }); | ||||
|     }, | ||||
|     sortTable(col, sameDirection = false) { | ||||
|       if (this.prevCol === col && sameDirection === false) { | ||||
|         this.direction = !this.direction; | ||||
|       } | ||||
|  | ||||
|       if (col === "name") this.sortName(); | ||||
|       else if (col === "seed") this.sortSeed(); | ||||
|       else if (col === "size") this.sortSize(); | ||||
|  | ||||
|       this.prevCol = col; | ||||
|     }, | ||||
|     sortName() { | ||||
|       const torrentsCopy = [...this.torrents]; | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1)); | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1)); | ||||
|       } | ||||
|     }, | ||||
|     sortSeed() { | ||||
|       const torrentsCopy = [...this.torrents]; | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort( | ||||
|           (a, b) => parseInt(a.seed) - parseInt(b.seed) | ||||
|         ); | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort( | ||||
|           (a, b) => parseInt(b.seed) - parseInt(a.seed) | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     sortSize() { | ||||
|       const torrentsCopy = [...this.torrents]; | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort( | ||||
|           (a, b) => | ||||
|             parseInt(sortableSize(a.size)) - parseInt(sortableSize(b.size)) | ||||
|         ); | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort( | ||||
|           (a, b) => | ||||
|             parseInt(sortableSize(b.size)) - parseInt(sortableSize(a.size)) | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     findRelaseTypes() { | ||||
|       this.torrents.forEach(item => | ||||
|         this.release_types.push(...item.release_type) | ||||
|       ); | ||||
|       this.release_types = [...new Set(this.release_types)]; | ||||
|     }, | ||||
|     applyFilter(item, index) { | ||||
|       this.selectedRelaseType = item; | ||||
|       const torrents = [...this.torrentResponse]; | ||||
|  | ||||
|       if (item === "all") { | ||||
|         this.torrents = torrents; | ||||
|         this.sortTable(this.prevCol, true); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       this.torrents = torrents.filter(torrent => | ||||
|         torrent.release_type.includes(item) | ||||
|       ); | ||||
|       this.sortTable(this.prevCol, true); | ||||
|     }, | ||||
|     updateResultCountInStore() { | ||||
|       store.dispatch("torrentModule/setResults", this.torrents); | ||||
|       store.dispatch( | ||||
|         "torrentModule/setResultCount", | ||||
|         this.torrentResponse.length | ||||
|       ); | ||||
|     }, | ||||
|     filterDeadTorrents(torrents) { | ||||
|       return torrents.filter(torrent => { | ||||
|         if (isNaN(torrent.seed)) return false; | ||||
|         return parseInt(torrent.seed) > 0; | ||||
|       }); | ||||
|     }, | ||||
|     fetchTorrents(query = undefined) { | ||||
|       this.loading = true; | ||||
|       this.editSearchQuery = false; | ||||
|  | ||||
|       return searchTorrents(query || this.query) | ||||
|         .then(data => { | ||||
|           const { results } = data; | ||||
|           if (results) { | ||||
|             this.torrentResponse = results; | ||||
|             this.torrents = this.filterDeadTorrents(results); | ||||
|           } else { | ||||
|             this.torrents = []; | ||||
|           } | ||||
|         }) | ||||
|         .then(this.updateResultCountInStore) | ||||
|         .then(this.findRelaseTypes) | ||||
|         .catch(e => { | ||||
|           console.log("e:", e); | ||||
|           const error = e.toString(); | ||||
|           this.errorMessage = | ||||
|             error.indexOf("401") != -1 ? "Permission denied" : "Nothing found"; | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.loading = false; | ||||
|         }); | ||||
|     }, | ||||
|     handleInput(event) { | ||||
|       this.editedSearchQuery = event.target.innerText; | ||||
|       console.log("edit text:", this.editedSearchQuery); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| .expanded { | ||||
|   display: flex; | ||||
|   padding: 0.25rem 1rem; | ||||
|   max-width: 100%; | ||||
|   border-left: 1px solid $text-color; | ||||
|   border-right: 1px solid $text-color; | ||||
|   border-bottom: 1px solid $text-color; | ||||
|  | ||||
|   td { | ||||
|     word-break: break-all; | ||||
|     padding: 0.5rem 0.15rem; | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| $checkboxSize: 20px; | ||||
| $ui-border-width: 2px; | ||||
|  | ||||
| .checkbox { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   margin-bottom: $checkboxSize * 0.5; | ||||
|  | ||||
|   input[type="checkbox"] { | ||||
|     display: block; | ||||
|     opacity: 0; | ||||
|     position: absolute; | ||||
|  | ||||
|     + div { | ||||
|       position: relative; | ||||
|       display: inline-block; | ||||
|       padding-left: 1.25rem; | ||||
|       font-size: 20px; | ||||
|       line-height: $checkboxSize + $ui-border-width * 2; | ||||
|       left: $checkboxSize; | ||||
|       cursor: pointer; | ||||
|  | ||||
|       &::before { | ||||
|         content: ""; | ||||
|         display: inline-block; | ||||
|         position: absolute; | ||||
|         left: -$checkboxSize; | ||||
|         border: $ui-border-width solid var(--color-green); | ||||
|         width: $checkboxSize; | ||||
|         height: $checkboxSize; | ||||
|       } | ||||
|  | ||||
|       &::after { | ||||
|         transition: all 0.3s ease; | ||||
|         content: ""; | ||||
|         position: absolute; | ||||
|         display: inline-block; | ||||
|         left: -$checkboxSize + $ui-border-width; | ||||
|         top: $ui-border-width; | ||||
|         width: $checkboxSize + $ui-border-width; | ||||
|         height: $checkboxSize + $ui-border-width; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:checked { | ||||
|       + div::after { | ||||
|         background-color: var(--color-green); | ||||
|         opacity: 1; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:hover:not(checked) { | ||||
|       + div::after { | ||||
|         background-color: var(--color-green); | ||||
|         opacity: 0.4; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus { | ||||
|       + div::before { | ||||
|         outline: 2px solid Highlight; | ||||
|         outline-style: auto; | ||||
|         outline-color: -webkit-focus-ring-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/elements"; | ||||
|  | ||||
| h2 { | ||||
|   font-size: 20px; | ||||
| } | ||||
|  | ||||
| thead { | ||||
|   user-select: none; | ||||
|   -webkit-user-select: none; | ||||
|   color: var(--background-color); | ||||
|   text-transform: uppercase; | ||||
|   cursor: pointer; | ||||
|   background-color: var(--text-color); | ||||
|   letter-spacing: 0.8px; | ||||
|   font-size: 1rem; | ||||
|   border: 1px solid var(--text-color-90); | ||||
|  | ||||
|   th:first-of-type { | ||||
|     border-top-left-radius: 8px; | ||||
|   } | ||||
|  | ||||
|   th:last-of-type { | ||||
|     border-top-right-radius: 8px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| tbody { | ||||
|   tr > td:first-of-type { | ||||
|     white-space: unset; | ||||
|   } | ||||
|  | ||||
|   tr > td:not(td:first-of-type) { | ||||
|     text-align: center; | ||||
|   } | ||||
|  | ||||
|   tr > td:last-of-type { | ||||
|     cursor: pointer; | ||||
|   } | ||||
|  | ||||
|   tr td:first-of-type { | ||||
|     border-left: 1px solid var(--text-color-90); | ||||
|   } | ||||
|  | ||||
|   tr td:last-of-type { | ||||
|     border-right: 1px solid var(--text-color-90); | ||||
|   } | ||||
|  | ||||
|   tr:last-of-type { | ||||
|     td { | ||||
|       border-bottom: 1px solid var(--text-color-90); | ||||
|     } | ||||
|  | ||||
|     td:first-of-type { | ||||
|       border-bottom-left-radius: 8px; | ||||
|     } | ||||
|     td:last-of-type { | ||||
|       border-bottom-right-radius: 8px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   tr:nth-child(even) { | ||||
|     background-color: var(--background-70); | ||||
|   } | ||||
| } | ||||
|  | ||||
| th, | ||||
| td { | ||||
|   padding: 0.35rem 0.25rem; | ||||
|   white-space: nowrap; | ||||
|  | ||||
|   svg { | ||||
|     width: 24px; | ||||
|     fill: var(--text-color); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .toggle { | ||||
|   max-width: unset !important; | ||||
|   margin: 1rem 0; | ||||
| } | ||||
|  | ||||
| .container { | ||||
|   background-color: $background-color; | ||||
|   padding: 0 1rem; | ||||
| } | ||||
|  | ||||
| .torrentHeader { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   padding-bottom: 20px; | ||||
|  | ||||
|   &-text { | ||||
|     font-weight: 400; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 20px; | ||||
|     // color: $green; | ||||
|     text-align: center; | ||||
|     margin: 0; | ||||
|  | ||||
|     .icon { | ||||
|       vertical-align: text-top; | ||||
|       margin-left: 1rem; | ||||
|       fill: var(--text-color); | ||||
|       width: 22px; | ||||
|       height: 22px; | ||||
|       // stroke: white !important; | ||||
|     } | ||||
|  | ||||
|     &.editable { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &-editIcon { | ||||
|     margin-left: 10px; | ||||
|     margin-top: -3px; | ||||
|     width: 22px; | ||||
|     height: 22px; | ||||
|  | ||||
|     &:hover { | ||||
|       fill: $green; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| table { | ||||
|   // border-collapse: collapse; | ||||
|   border-spacing: 0; | ||||
|   margin-top: 1rem; | ||||
|   width: 100%; | ||||
|   // table-layout: fixed; | ||||
| } | ||||
|  | ||||
| // .table__content, | ||||
| // .table__header { | ||||
| //   display: flex; | ||||
| //   padding: 0; | ||||
| //   border-left: 1px solid $text-color; | ||||
| //   border-right: 1px solid $text-color; | ||||
| //   border-bottom: 1px solid $text-color; | ||||
|  | ||||
| //   th, | ||||
| //   td { | ||||
| //     display: flex; | ||||
| //     flex-direction: column; | ||||
| //     flex-basis: 100%; | ||||
|  | ||||
| //     padding: 0.4rem; | ||||
|  | ||||
| //     white-space: nowrap; | ||||
| //     text-overflow: ellipsis; | ||||
| //     overflow: hidden; | ||||
| //     min-width: 75px; | ||||
| //   } | ||||
|  | ||||
| //   th:first-child, | ||||
| //   td:first-child { | ||||
| //     flex: 1; | ||||
| //   } | ||||
|  | ||||
| //   th:not(:first-child), | ||||
| //   td:not(:first-child) { | ||||
| //     flex: 0.2; | ||||
| //   } | ||||
|  | ||||
| //   th:nth-child(2), | ||||
| //   td:nth-child(2) { | ||||
| //     flex: 0.1; | ||||
| //   } | ||||
|  | ||||
| //   @include mobile-only { | ||||
| //     th:first-child, | ||||
| //     td:first-child { | ||||
| //       display: none; | ||||
|  | ||||
| //       &.show { | ||||
| //         display: block; | ||||
| //         align: flex-end; | ||||
| //       } | ||||
| //     } | ||||
|  | ||||
| //     th:not(:first-child), | ||||
| //     td:not(:first-child) { | ||||
| //       flex: 1; | ||||
| //     } | ||||
| //   } | ||||
| // } | ||||
|  | ||||
| .table__content { | ||||
|   td:not(:last-child) { | ||||
|     border-right: 1px solid $text-color; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .table__content:last-child { | ||||
|   margin-bottom: 1rem; | ||||
|  | ||||
|   border-bottom-left-radius: 3px; | ||||
|   border-bottom-right-radius: 3px; | ||||
| } | ||||
|  | ||||
| // .table__header { | ||||
| //   color: $text-color; | ||||
| //   text-transform: uppercase; | ||||
| //   cursor: pointer; | ||||
| //   background-color: $background-color-secondary; | ||||
|  | ||||
| //   border-top: 1px solid $text-color; | ||||
| //   border-top-left-radius: 3px; | ||||
| //   border-top-right-radius: 3px; | ||||
|  | ||||
| //   th { | ||||
| //     display: flex; | ||||
| //     flex-direction: row; | ||||
| //     font-weight: 400; | ||||
| //     letter-spacing: 0.7px; | ||||
| //     // font-size: 1.08rem; | ||||
| //     font-size: 15px; | ||||
|  | ||||
| //     &::before { | ||||
| //       content: ""; | ||||
| //       min-width: 0.2rem; | ||||
| //     } | ||||
|  | ||||
| //     span:first-child { | ||||
| //       margin-right: 0.6rem; | ||||
| //     } | ||||
| //     span:nth-child(2) { | ||||
| //       margin-right: 0.1rem; | ||||
| //     } | ||||
| //   } | ||||
|  | ||||
| //   th:not(:last-child) { | ||||
| //     border-right: 1px solid $text-color; | ||||
| //   } | ||||
| // } | ||||
|  | ||||
| .editQuery { | ||||
|   display: flex; | ||||
|   width: 70%; | ||||
|   justify-content: center; | ||||
|   margin-bottom: 1rem; | ||||
|  | ||||
|   @include mobile-only { | ||||
|     width: 90%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .download { | ||||
|   &__icon { | ||||
|     fill: $text-color-70; | ||||
|     height: 1.2rem; | ||||
|  | ||||
|     &:hover { | ||||
|       fill: $text-color; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.active &__icon { | ||||
|     fill: $green; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .torrentloader { | ||||
|   width: 100%; | ||||
|   padding: 2rem 0; | ||||
|  | ||||
|   i { | ||||
|     animation: load 1s linear infinite; | ||||
|     border: 2px solid $text-color; | ||||
|     border-radius: 50%; | ||||
|     display: block; | ||||
|     height: 30px; | ||||
|     left: 50%; | ||||
|     margin: 0 auto; | ||||
|     width: 30px; | ||||
|  | ||||
|     &:after { | ||||
|       border: 5px solid $green; | ||||
|       border-radius: 50%; | ||||
|       content: ""; | ||||
|       left: 10px; | ||||
|       position: absolute; | ||||
|       top: 16px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @keyframes load { | ||||
|   100% { | ||||
|     transform: rotate(360deg); | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -5,9 +5,9 @@ | ||||
|         v-for="result in searchResults" | ||||
|         :key="`${result.index}-${result.title}-${result.type}`" | ||||
|         @click="openPopup(result)" | ||||
|         :class=" | ||||
|           `result di-${result.index} ${result.index === index ? 'active' : ''}` | ||||
|         " | ||||
|         :class="`result di-${result.index} ${ | ||||
|           result.index === index ? 'active' : '' | ||||
|         }`" | ||||
|       > | ||||
|         <IconMovie v-if="result.type == 'movie'" class="type-icon" /> | ||||
|         <IconShow v-if="result.type == 'show'" class="type-icon" /> | ||||
| @@ -24,239 +24,229 @@ | ||||
|   </transition> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from "vuex"; | ||||
| import IconMovie from "src/icons/IconMovie"; | ||||
| import IconShow from "src/icons/IconShow"; | ||||
| import IconPerson from "src/icons/IconPerson"; | ||||
| import { elasticSearchMoviesAndShows } from "@/api"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, watch, defineProps } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import IconMovie from "@/icons/IconMovie.vue"; | ||||
|   import IconShow from "@/icons/IconShow.vue"; | ||||
|   import IconPerson from "@/icons/IconPerson.vue"; | ||||
|   import { elasticSearchMoviesAndShows } from "../../api"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
| export default { | ||||
|   components: { IconMovie, IconShow, IconPerson }, | ||||
|   props: { | ||||
|     query: { | ||||
|       type: String, | ||||
|       default: null, | ||||
|       required: false | ||||
|     }, | ||||
|     index: { | ||||
|       type: Number, | ||||
|       default: -1, | ||||
|       required: false | ||||
|     }, | ||||
|     results: { | ||||
|       type: Array, | ||||
|       default: [], | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     query(newQuery) { | ||||
|       if (newQuery && newQuery.length > 1) this.fetchAutocompleteResults(); | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchResults: [], | ||||
|       keyboardNavigationIndex: 0, | ||||
|       numberOfResults: 10 | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["open"]), | ||||
|     openPopup(result) { | ||||
|       const { id, type } = result; | ||||
|       this.open({ id, type }); | ||||
|     }, | ||||
|     fetchAutocompleteResults() { | ||||
|       this.keyboardNavigationIndex = 0; | ||||
|       this.searchResults = []; | ||||
|  | ||||
|       elasticSearchMoviesAndShows(this.query, this.numberOfResults).then( | ||||
|         resp => { | ||||
|           const data = resp.hits.hits; | ||||
|  | ||||
|           let results = data.map(item => { | ||||
|             let index = null; | ||||
|             if (item._source.log.file.path.includes("movie")) index = "movie"; | ||||
|             if (item._source.log.file.path.includes("series")) index = "show"; | ||||
|  | ||||
|             if (index === "movie" || index === "show") { | ||||
|               return { | ||||
|                 title: | ||||
|                   item._source.original_name || item._source.original_title, | ||||
|                 id: item._source.id, | ||||
|                 adult: item._source.adult, | ||||
|                 type: index | ||||
|               }; | ||||
|             } | ||||
|           }); | ||||
|  | ||||
|           results = this.removeDuplicates(results); | ||||
|           results = results.map((el, index) => { | ||||
|             return { ...el, index }; | ||||
|           }); | ||||
|  | ||||
|           this.$emit("update:results", results); | ||||
|           this.searchResults = results; | ||||
|         } | ||||
|       ); | ||||
|     }, | ||||
|     removeDuplicates(searchResults) { | ||||
|       let filteredResults = []; | ||||
|       searchResults.map(result => { | ||||
|         if (result === undefined) return; | ||||
|         const numberOfDuplicates = filteredResults.filter( | ||||
|           filterItem => filterItem.id == result.id | ||||
|         ); | ||||
|         if (numberOfDuplicates.length >= 1) { | ||||
|           return null; | ||||
|         } | ||||
|         filteredResults.push(result); | ||||
|       }); | ||||
|  | ||||
|       if (this.adult == false) { | ||||
|         filteredResults = filteredResults.filter( | ||||
|           result => result.adult == false | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return filteredResults; | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     if (this.query) this.fetchAutocompleteResults(); | ||||
|   interface Props { | ||||
|     query?: string; | ||||
|     index?: Number; | ||||
|     results?: Array<any>; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:results", value: Array<any>); | ||||
|   } | ||||
|  | ||||
|   const numberOfResults: number = 10; | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const searchResults: Ref<Array<any>> = ref([]); | ||||
|   const keyboardNavigationIndex: Ref<number> = ref(0); | ||||
|  | ||||
|   // on load functions | ||||
|   fetchAutocompleteResults(); | ||||
|   // end on load functions | ||||
|  | ||||
|   watch( | ||||
|     () => props.query, | ||||
|     newQuery => { | ||||
|       if (newQuery?.length > 0) fetchAutocompleteResults(); | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   function openPopup(result) { | ||||
|     if (!result.id || !result.type) return; | ||||
|  | ||||
|     store.dispatch("popup/open", { ...result }); | ||||
|   } | ||||
|  | ||||
|   function fetchAutocompleteResults() { | ||||
|     keyboardNavigationIndex.value = 0; | ||||
|     searchResults.value = []; | ||||
|  | ||||
|     elasticSearchMoviesAndShows(props.query, numberOfResults) | ||||
|       .then(elasticResponse => parseElasticResponse(elasticResponse)) | ||||
|       .then(_searchResults => { | ||||
|         emit("update:results", _searchResults); | ||||
|         searchResults.value = _searchResults; | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   function parseElasticResponse(elasticResponse: any) { | ||||
|     const data = elasticResponse.hits.hits; | ||||
|  | ||||
|     let results = data.map(item => { | ||||
|       let index = null; | ||||
|       if (item._source.log.file.path.includes("movie")) index = "movie"; | ||||
|       if (item._source.log.file.path.includes("series")) index = "show"; | ||||
|  | ||||
|       if (index === "movie" || index === "show") { | ||||
|         return { | ||||
|           title: item._source.original_name || item._source.original_title, | ||||
|           id: item._source.id, | ||||
|           adult: item._source.adult, | ||||
|           type: index | ||||
|         }; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return removeDuplicates(results).map((el, index) => { | ||||
|       return { ...el, index }; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function removeDuplicates(searchResults) { | ||||
|     let filteredResults = []; | ||||
|     searchResults.map(result => { | ||||
|       if (result === undefined) return; | ||||
|       const numberOfDuplicates = filteredResults.filter( | ||||
|         filterItem => filterItem.id == result.id | ||||
|       ); | ||||
|       if (numberOfDuplicates.length >= 1) { | ||||
|         return null; | ||||
|       } | ||||
|       filteredResults.push(result); | ||||
|     }); | ||||
|  | ||||
|     return filteredResults; | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
| $sizes: 22; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|   $sizes: 22; | ||||
|  | ||||
| @for $i from 0 through $sizes { | ||||
|   .dropdown .di-#{$i} { | ||||
|     visibility: visible; | ||||
|     transform-origin: top center; | ||||
|     animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes scaleZ { | ||||
|   0% { | ||||
|     opacity: 0; | ||||
|     transform: scale(0); | ||||
|   } | ||||
|   80% { | ||||
|     transform: scale(1.07); | ||||
|   } | ||||
|   100% { | ||||
|     opacity: 1; | ||||
|     transform: scale(1); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dropdown { | ||||
|   top: var(--header-size); | ||||
|   position: relative; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   max-width: 720px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   flex-wrap: wrap; | ||||
|   z-index: 5; | ||||
|  | ||||
|   margin-top: -1px; | ||||
|   border-top: none; | ||||
|   padding: 0; | ||||
|  | ||||
|   @include mobile { | ||||
|     position: fixed; | ||||
|     left: 0; | ||||
|     max-width: 100vw; | ||||
|   } | ||||
|  | ||||
|   @include tablet-min { | ||||
|     top: unset; | ||||
|     --gutter: 1.5rem; | ||||
|     max-width: calc(100% - (2 * var(--gutter))); | ||||
|     margin: -1px var(--gutter) 0 var(--gutter); | ||||
|   } | ||||
|  | ||||
|   @include desktop { | ||||
|     max-width: 720px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| li.result { | ||||
|   background-color: var(--background-95); | ||||
|   color: var(--text-color-50); | ||||
|   padding: 0.5rem 2rem; | ||||
|   list-style: none; | ||||
|  | ||||
|   opacity: 0; | ||||
|   height: 56px; | ||||
|   width: 100%; | ||||
|   visibility: hidden; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   padding: 0.5rem 2rem; | ||||
|  | ||||
|   font-size: 1.4rem; | ||||
|   text-transform: capitalize; | ||||
|   list-style: none; | ||||
|   cursor: pointer; | ||||
|   white-space: nowrap; | ||||
|  | ||||
|   transition: color 0.1s ease, fill 0.4s ease; | ||||
|  | ||||
|   span { | ||||
|     overflow-x: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     transition: inherit; | ||||
|   } | ||||
|  | ||||
|   &.active, | ||||
|   &:hover, | ||||
|   &:active { | ||||
|     color: var(--text-color); | ||||
|     border-bottom: 2px solid var(--color-green); | ||||
|  | ||||
|     .type-icon { | ||||
|       fill: var(--text-color); | ||||
|   @for $i from 0 through $sizes { | ||||
|     .dropdown .di-#{$i} { | ||||
|       visibility: visible; | ||||
|       transform-origin: top center; | ||||
|       animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .type-icon { | ||||
|     width: 28px; | ||||
|     height: 28px; | ||||
|     margin-right: 1rem; | ||||
|     transition: inherit; | ||||
|     fill: var(--text-color-50); | ||||
|   @keyframes scaleZ { | ||||
|     0% { | ||||
|       opacity: 0; | ||||
|       transform: scale(0); | ||||
|     } | ||||
|     80% { | ||||
|       transform: scale(1.07); | ||||
|     } | ||||
|     100% { | ||||
|       opacity: 1; | ||||
|       transform: scale(1); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| li.info { | ||||
|   visibility: hidden; | ||||
|   opacity: 0; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   padding: 0 1rem; | ||||
|   color: var(--text-color-50); | ||||
|   background-color: var(--background-95); | ||||
|   color: var(--text-color-50); | ||||
|   font-size: 0.6rem; | ||||
|   height: 16px; | ||||
|   width: 100%; | ||||
| } | ||||
|   .dropdown { | ||||
|     top: var(--header-size); | ||||
|     position: relative; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|     max-width: 720px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-wrap: wrap; | ||||
|     z-index: 5; | ||||
|  | ||||
| .shut-leave-to { | ||||
|   height: 0px; | ||||
|   transition: height 0.4s ease; | ||||
|   flex-wrap: no-wrap; | ||||
|   overflow: hidden; | ||||
| } | ||||
|     margin-top: -1px; | ||||
|     border-top: none; | ||||
|     padding: 0; | ||||
|  | ||||
|     @include mobile { | ||||
|       position: fixed; | ||||
|       left: 0; | ||||
|       max-width: 100vw; | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       top: unset; | ||||
|       --gutter: 1.5rem; | ||||
|       max-width: calc(100% - (2 * var(--gutter))); | ||||
|       margin: -1px var(--gutter) 0 var(--gutter); | ||||
|     } | ||||
|  | ||||
|     @include desktop { | ||||
|       max-width: 720px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   li.result { | ||||
|     background-color: var(--background-95); | ||||
|     color: var(--text-color-50); | ||||
|     padding: 0.5rem 2rem; | ||||
|     list-style: none; | ||||
|  | ||||
|     opacity: 0; | ||||
|     height: 56px; | ||||
|     width: 100%; | ||||
|     visibility: hidden; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 2rem; | ||||
|  | ||||
|     font-size: 1.4rem; | ||||
|     text-transform: capitalize; | ||||
|     list-style: none; | ||||
|     cursor: pointer; | ||||
|     white-space: nowrap; | ||||
|  | ||||
|     transition: color 0.1s ease, fill 0.4s ease; | ||||
|  | ||||
|     span { | ||||
|       overflow-x: hidden; | ||||
|       text-overflow: ellipsis; | ||||
|       transition: inherit; | ||||
|     } | ||||
|  | ||||
|     &.active, | ||||
|     &:hover, | ||||
|     &:active { | ||||
|       color: var(--text-color); | ||||
|       border-bottom: 2px solid var(--color-green); | ||||
|  | ||||
|       .type-icon { | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .type-icon { | ||||
|       width: 28px; | ||||
|       height: 28px; | ||||
|       margin-right: 1rem; | ||||
|       transition: inherit; | ||||
|       fill: var(--text-color-50); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   li.info { | ||||
|     visibility: hidden; | ||||
|     opacity: 0; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     padding: 0 1rem; | ||||
|     color: var(--text-color-50); | ||||
|     background-color: var(--background-95); | ||||
|     color: var(--text-color-50); | ||||
|     font-size: 0.6rem; | ||||
|     height: 16px; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .shut-leave-to { | ||||
|     height: 0px; | ||||
|     transition: height 0.4s ease; | ||||
|     flex-wrap: no-wrap; | ||||
|     overflow: hidden; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -10,10 +10,10 @@ | ||||
|     <SearchInput /> | ||||
|  | ||||
|     <Hamburger class="mobile-only" /> | ||||
|  | ||||
|     <NavigationIcon class="desktop-only" :route="profileRoute" /> | ||||
|  | ||||
|     <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> | ||||
|     <!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> --> | ||||
|     <div class="navigation-icons-grid mobile-only" v-if="isOpen"> | ||||
|       <NavigationIcons> | ||||
|         <NavigationIcon :route="profileRoute" /> | ||||
|       </NavigationIcons> | ||||
| @@ -21,106 +21,104 @@ | ||||
|   </nav> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters, mapActions } from "vuex"; | ||||
| import TmdbLogo from "@/icons/tmdb-logo"; | ||||
| import IconProfile from "@/icons/IconProfile"; | ||||
| import IconProfileLock from "@/icons/IconProfileLock"; | ||||
| import IconSettings from "@/icons/IconSettings"; | ||||
| import IconActivity from "@/icons/IconActivity"; | ||||
| import SearchInput from "@/components/header/SearchInput"; | ||||
| import NavigationIcons from "src/components/header/NavigationIcons"; | ||||
| import NavigationIcon from "src/components/header/NavigationIcon"; | ||||
| import Hamburger from "@/components/ui/Hamburger"; | ||||
| <script setup lang="ts"> | ||||
|   import { computed, defineProps, PropType } from "vue"; | ||||
|   import type { App } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import SearchInput from "@/components/header/SearchInput.vue"; | ||||
|   import Hamburger from "@/components/ui/Hamburger.vue"; | ||||
|   import NavigationIcons from "@/components/header/NavigationIcons.vue"; | ||||
|   import NavigationIcon from "@/components/header/NavigationIcon.vue"; | ||||
|   import TmdbLogo from "@/icons/tmdb-logo.vue"; | ||||
|   import IconProfile from "@/icons/IconProfile.vue"; | ||||
|   import IconProfileLock from "@/icons/IconProfileLock.vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     NavigationIcons, | ||||
|     NavigationIcon, | ||||
|     SearchInput, | ||||
|     TmdbLogo, | ||||
|     IconProfile, | ||||
|     IconProfileLock, | ||||
|     IconSettings, | ||||
|     IconActivity, | ||||
|     Hamburger | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("user", ["loggedIn"]), | ||||
|     ...mapGetters("hamburger", ["isOpen"]), | ||||
|     isHome() { | ||||
|       return this.$route.path === "/"; | ||||
|     }, | ||||
|     profileRoute() { | ||||
|       return { | ||||
|         title: !this.loggedIn ? "Signin" : "Profile", | ||||
|         route: !this.loggedIn ? "/signin" : "/profile", | ||||
|         icon: !this.loggedIn ? IconProfileLock : IconProfile | ||||
|       }; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|   const route = useRoute(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const signinNavigationIcon: INavigationIcon = { | ||||
|     title: "Signin", | ||||
|     route: "/signin", | ||||
|     icon: IconProfileLock | ||||
|   }; | ||||
|  | ||||
|   const profileNavigationIcon: INavigationIcon = { | ||||
|     title: "Profile", | ||||
|     route: "/profile", | ||||
|     icon: IconProfile | ||||
|   }; | ||||
|  | ||||
|   const isHome = computed(() => route.path === "/"); | ||||
|   const isOpen = computed(() => store.getters["hamburger/isOpen"]); | ||||
|   const loggedIn = computed(() => store.getters["user/loggedIn"]); | ||||
|  | ||||
|   const profileRoute = computed(() => | ||||
|     !loggedIn.value ? signinNavigationIcon : profileNavigationIcon | ||||
|   ); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .spacer { | ||||
|   @include mobile-only { | ||||
|   .spacer { | ||||
|     @include mobile-only { | ||||
|       width: 100%; | ||||
|       height: $header-size; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   nav { | ||||
|     display: grid; | ||||
|     grid-template-columns: var(--header-size) 1fr var(--header-size); | ||||
|  | ||||
|     > * { | ||||
|       z-index: 10; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .nav__logo { | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   .logo { | ||||
|     padding: 1rem; | ||||
|     fill: var(--color-green); | ||||
|     width: var(--header-size); | ||||
|     height: var(--header-size); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background: $background-nav-logo; | ||||
|     transition: transform 0.3s ease; | ||||
|  | ||||
|     &:hover { | ||||
|       transform: scale(1.08); | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       padding: 0.5rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .navigation-icons-grid { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     position: fixed; | ||||
|     top: var(--header-size); | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: $header-size; | ||||
|   } | ||||
| } | ||||
|     background-color: $background-95; | ||||
|     visibility: hidden; | ||||
|     opacity: 0; | ||||
|     transition: opacity 0.4s ease; | ||||
|  | ||||
| nav { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--header-size) 1fr var(--header-size); | ||||
|  | ||||
|   > * { | ||||
|     z-index: 10; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .nav__logo { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .logo { | ||||
|   padding: 1rem; | ||||
|   fill: var(--color-green); | ||||
|   width: var(--header-size); | ||||
|   height: var(--header-size); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   background: $background-nav-logo; | ||||
|   transition: transform 0.3s ease; | ||||
|  | ||||
|   &:hover { | ||||
|     transform: scale(1.08); | ||||
|   } | ||||
|  | ||||
|   @include mobile { | ||||
|     padding: 0.5rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .navigation-icons-grid { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   position: fixed; | ||||
|   top: var(--header-size); | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   background-color: $background-95; | ||||
|   visibility: hidden; | ||||
|   opacity: 0; | ||||
|   transition: opacity 0.4s ease; | ||||
|  | ||||
|   &.open { | ||||
|     opacity: 1; | ||||
|     visibility: visible; | ||||
|  | ||||
|     &.open { | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <router-link | ||||
|     :to="{ path: route.route }" | ||||
|     :key="route.title" | ||||
|     v-if="route.requiresAuth == undefined || (route.requiresAuth && loggedIn)" | ||||
|     :to="{ path: route?.route }" | ||||
|     :key="route?.title" | ||||
|     v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)" | ||||
|   > | ||||
|     <li class="navigation-link" :class="{ active: route.route == active }"> | ||||
|       <component class="navigation-icon" :is="route.icon"></component> | ||||
| @@ -11,71 +11,66 @@ | ||||
|   </router-link> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters, mapActions } from "vuex"; | ||||
| <script setup lang="ts"> | ||||
|   import { useStore } from "vuex"; | ||||
|   import { computed, defineProps } from "vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
| export default { | ||||
|   name: "NavigationIcon", | ||||
|   props: { | ||||
|     active: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     route: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("user", ["loggedIn"]) | ||||
|   interface Props { | ||||
|     route: INavigationIcon; | ||||
|     active?: string; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   defineProps<Props>(); | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const loggedIn = computed(() => store.getters["user/loggedIn"]); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .navigation-link { | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   min-height: var(--header-size); | ||||
|   list-style: none; | ||||
|   padding: 1rem 0.15rem; | ||||
|   text-align: center; | ||||
|   background-color: var(--background-color-secondary); | ||||
|   transition: transform 0.3s ease, color 0.3s ease, stoke 0.3s ease, | ||||
|     fill 0.3s ease, background-color 0.5s ease; | ||||
|   .navigation-link { | ||||
|     display: grid; | ||||
|     place-items: center; | ||||
|     min-height: var(--header-size); | ||||
|     list-style: none; | ||||
|     padding: 1rem 0.15rem; | ||||
|     text-align: center; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     transition: transform 0.3s ease, color 0.3s ease, stoke 0.3s ease, | ||||
|       fill 0.3s ease, background-color 0.5s ease; | ||||
|  | ||||
|   &:hover { | ||||
|     transform: scale(1.05); | ||||
|   } | ||||
|     &:hover { | ||||
|       transform: scale(1.05); | ||||
|     } | ||||
|  | ||||
|   &:hover, | ||||
|   &.active { | ||||
|     background-color: var(--background-color); | ||||
|     &:hover, | ||||
|     &.active { | ||||
|       background-color: var(--background-color); | ||||
|  | ||||
|     span, | ||||
|     .navigation-icon { | ||||
|       color: var(--text-color); | ||||
|       fill: var(--text-color); | ||||
|       span, | ||||
|       .navigation-icon { | ||||
|         color: var(--text-color); | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     span { | ||||
|       text-transform: uppercase; | ||||
|       font-size: 11px; | ||||
|       margin-top: 0.25rem; | ||||
|       color: var(--text-color-70); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   span { | ||||
|     text-transform: uppercase; | ||||
|     font-size: 11px; | ||||
|     margin-top: 0.25rem; | ||||
|     color: var(--text-color-70); | ||||
|   a { | ||||
|     text-decoration: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| a { | ||||
|   text-decoration: none; | ||||
| } | ||||
|  | ||||
| .navigation-icon { | ||||
|   width: 28px; | ||||
|   fill: var(--text-color-70); | ||||
|   transition: inherit; | ||||
| } | ||||
|   .navigation-icon { | ||||
|     width: 28px; | ||||
|     fill: var(--text-color-70); | ||||
|     transition: inherit; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -10,94 +10,83 @@ | ||||
|   </ul> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import NavigationIcon from "@/components/header/NavigationIcon"; | ||||
| import IconInbox from "@/icons/IconInbox"; | ||||
| import IconNowPlaying from "@/icons/IconNowPlaying"; | ||||
| import IconPopular from "@/icons/IconPopular"; | ||||
| import IconUpcoming from "@/icons/IconUpcoming"; | ||||
| import IconSettings from "@/icons/IconSettings"; | ||||
| import IconActivity from "@/icons/IconActivity"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, watch } from "vue"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import NavigationIcon from "@/components/header/NavigationIcon.vue"; | ||||
|   import IconInbox from "@/icons/IconInbox.vue"; | ||||
|   import IconNowPlaying from "@/icons/IconNowPlaying.vue"; | ||||
|   import IconPopular from "@/icons/IconPopular.vue"; | ||||
|   import IconUpcoming from "@/icons/IconUpcoming.vue"; | ||||
|   import IconSettings from "@/icons/IconSettings.vue"; | ||||
|   import IconActivity from "@/icons/IconActivity.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
| export default { | ||||
|   name: "NavigationIcons", | ||||
|   components: { | ||||
|     NavigationIcon, | ||||
|     IconInbox, | ||||
|     IconPopular, | ||||
|     IconNowPlaying, | ||||
|     IconUpcoming, | ||||
|     IconSettings, | ||||
|     IconActivity | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       routes: [ | ||||
|         { | ||||
|           title: "Requests", | ||||
|           route: "/list/requests", | ||||
|           icon: IconInbox | ||||
|         }, | ||||
|         { | ||||
|           title: "Now Playing", | ||||
|           route: "/list/now_playing", | ||||
|           icon: IconNowPlaying | ||||
|         }, | ||||
|         { | ||||
|           title: "Popular", | ||||
|           route: "/list/popular", | ||||
|           icon: IconPopular | ||||
|         }, | ||||
|         { | ||||
|           title: "Upcoming", | ||||
|           route: "/list/upcoming", | ||||
|           icon: IconUpcoming | ||||
|         }, | ||||
|         { | ||||
|           title: "Activity", | ||||
|           route: "/activity", | ||||
|           requiresAuth: true, | ||||
|           icon: IconActivity | ||||
|         }, | ||||
|         { | ||||
|           title: "Settings", | ||||
|           route: "/profile?settings=true", | ||||
|           requiresAuth: true, | ||||
|           icon: IconSettings | ||||
|         } | ||||
|       ], | ||||
|       activeRoute: null | ||||
|     }; | ||||
|   }, | ||||
|   watch: { | ||||
|     $route() { | ||||
|       this.activeRoute = window.location.pathname; | ||||
|   const route = useRoute(); | ||||
|   const activeRoute = ref(window?.location?.pathname); | ||||
|   const routes: INavigationIcon[] = [ | ||||
|     { | ||||
|       title: "Requests", | ||||
|       route: "/list/requests", | ||||
|       icon: IconInbox | ||||
|     }, | ||||
|     { | ||||
|       title: "Now Playing", | ||||
|       route: "/list/now_playing", | ||||
|       icon: IconNowPlaying | ||||
|     }, | ||||
|     { | ||||
|       title: "Popular", | ||||
|       route: "/list/popular", | ||||
|       icon: IconPopular | ||||
|     }, | ||||
|     { | ||||
|       title: "Upcoming", | ||||
|       route: "/list/upcoming", | ||||
|       icon: IconUpcoming | ||||
|     }, | ||||
|     { | ||||
|       title: "Activity", | ||||
|       route: "/activity", | ||||
|       requiresAuth: true, | ||||
|       icon: IconActivity | ||||
|     }, | ||||
|     { | ||||
|       title: "Torrents", | ||||
|       route: "/torrents", | ||||
|       requiresAuth: true, | ||||
|       icon: IconBinoculars | ||||
|     }, | ||||
|     { | ||||
|       title: "Settings", | ||||
|       route: "/profile?settings=true", | ||||
|       requiresAuth: true, | ||||
|       icon: IconSettings | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.activeRoute = window.location.pathname; | ||||
|   } | ||||
| }; | ||||
|   ]; | ||||
|  | ||||
|   watch(route, () => (activeRoute.value = window?.location?.pathname)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .navigation-icons { | ||||
|   display: grid; | ||||
|   grid-column: 1fr; | ||||
|   padding-left: 0; | ||||
|   margin: 0; | ||||
|   background-color: var(--background-color-secondary); | ||||
|   z-index: 15; | ||||
|   width: 100%; | ||||
|   .navigation-icons { | ||||
|     display: grid; | ||||
|     grid-column: 1fr; | ||||
|     padding-left: 0; | ||||
|     margin: 0; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     z-index: 15; | ||||
|     width: 100%; | ||||
|  | ||||
|   @include desktop { | ||||
|     grid-template-rows: var(--header-size); | ||||
|     @include desktop { | ||||
|       grid-template-rows: var(--header-size); | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       grid-template-columns: 1fr 1fr; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @include mobile { | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="search" :class="{ active: focusingInput }"> | ||||
|     <div class="search" :class="{ active: inputIsActive }"> | ||||
|       <IconSearch class="search-icon" tabindex="-1" /> | ||||
|  | ||||
|       <input | ||||
|         ref="input" | ||||
|         ref="inputElement" | ||||
|         type="text" | ||||
|         placeholder="Search for movie or show" | ||||
|         aria-label="Search input for finding a movie or show" | ||||
| @@ -13,21 +13,21 @@ | ||||
|         tabindex="0" | ||||
|         v-model="query" | ||||
|         @input="handleInput" | ||||
|         @click="focusingInput = true" | ||||
|         @click="focus" | ||||
|         @keydown.escape="handleEscape" | ||||
|         @keyup.enter="handleSubmit" | ||||
|         @keydown.up="navigateUp" | ||||
|         @keydown.down="navigateDown" | ||||
|         @focus="focusingInput = true" | ||||
|         @blur="focusingInput = false" | ||||
|         @focus="focus" | ||||
|         @blur="blur" | ||||
|       /> | ||||
|  | ||||
|       <IconClose | ||||
|         tabindex="0" | ||||
|         aria-label="button" | ||||
|         v-if="query && query.length" | ||||
|         @click="resetQuery" | ||||
|         @keydown.enter.stop="resetQuery" | ||||
|         @click="clearInput" | ||||
|         @keydown.enter.stop="clearInput" | ||||
|         class="close-icon" | ||||
|       /> | ||||
|     </div> | ||||
| @@ -36,246 +36,258 @@ | ||||
|       v-if="showAutocompleteResults" | ||||
|       :query="query" | ||||
|       :index="dropdownIndex" | ||||
|       :results.sync="dropdownResults" | ||||
|       v-model:results="dropdownResults" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions, mapGetters } from "vuex"; | ||||
| import SeasonedButton from "@/components/ui/SeasonedButton"; | ||||
| import AutocompleteDropdown from "@/components/header/AutocompleteDropdown"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import AutocompleteDropdown from "@/components/header/AutocompleteDropdown.vue"; | ||||
|   import IconSearch from "@/icons/IconSearch.vue"; | ||||
|   import IconClose from "@/icons/IconClose.vue"; | ||||
|   import config from "../../config"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { ListTypes } from "../../interfaces/IList"; | ||||
|  | ||||
| import IconSearch from "src/icons/IconSearch"; | ||||
| import IconClose from "src/icons/IconClose"; | ||||
| import config from "@/config"; | ||||
|   interface ISearchResult { | ||||
|     title: string; | ||||
|     id: number; | ||||
|     adult: boolean; | ||||
|     type: ListTypes; | ||||
|   } | ||||
|  | ||||
| export default { | ||||
|   name: "SearchInput", | ||||
|   components: { | ||||
|     SeasonedButton, | ||||
|     AutocompleteDropdown, | ||||
|     IconClose, | ||||
|     IconSearch | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       query: null, | ||||
|       disabled: false, | ||||
|       dropdownIndex: -1, | ||||
|       dropdownResults: [], | ||||
|       focusingInput: false, | ||||
|       showAutocomplete: false | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("popup", ["isOpen"]), | ||||
|     showAutocompleteResults() { | ||||
|       return ( | ||||
|         !this.disabled && | ||||
|         this.focusingInput && | ||||
|         this.query && | ||||
|         this.query.length > 0 | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     if (params && params.has("query")) { | ||||
|       this.query = decodeURIComponent(params.get("query")); | ||||
|     } | ||||
|   const store = useStore(); | ||||
|   const router = useRouter(); | ||||
|   const route = useRoute(); | ||||
|  | ||||
|     const elasticUrl = config.ELASTIC_URL; | ||||
|     if (elasticUrl === undefined || elasticUrl === false || elasticUrl === "") { | ||||
|       this.disabled = true; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions("popup", ["open"]), | ||||
|     navigateDown() { | ||||
|       if (this.dropdownIndex < this.dropdownResults.length - 1) { | ||||
|         this.dropdownIndex++; | ||||
|       } | ||||
|     }, | ||||
|     navigateUp() { | ||||
|       if (this.dropdownIndex > -1) this.dropdownIndex--; | ||||
|   const query: Ref<string> = ref(null); | ||||
|   const disabled: Ref<boolean> = ref(false); | ||||
|   const dropdownIndex: Ref<number> = ref(-1); | ||||
|   const dropdownResults: Ref<ISearchResult[]> = ref([]); | ||||
|   const inputIsActive: Ref<boolean> = ref(false); | ||||
|   const showAutocomplete: Ref<boolean> = ref(false); | ||||
|   const inputElement: Ref<any> = ref(null); | ||||
|  | ||||
|       const input = this.$refs.input; | ||||
|       const textLength = input.value.length; | ||||
|   const isOpen = computed(() => store.getters["popup/isOpen"]); | ||||
|   const showAutocompleteResults = computed(() => { | ||||
|     return ( | ||||
|       !disabled.value && | ||||
|       inputIsActive.value && | ||||
|       query.value && | ||||
|       query.value.length > 0 | ||||
|     ); | ||||
|   }); | ||||
|  | ||||
|       setTimeout(() => { | ||||
|         input.focus(); | ||||
|         input.setSelectionRange(textLength, textLength + 1); | ||||
|       }, 1); | ||||
|     }, | ||||
|     search() { | ||||
|       const encodedQuery = encodeURI(this.query.replace('/ /g, "+"')); | ||||
|   const params = new URLSearchParams(window.location.search); | ||||
|   if (params && params.has("query")) { | ||||
|     query.value = decodeURIComponent(params.get("query")); | ||||
|   } | ||||
|  | ||||
|       this.$router.push({ | ||||
|         name: "search", | ||||
|         query: { | ||||
|           ...this.$route.query, | ||||
|           query: encodedQuery | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     resetQuery(event) { | ||||
|       this.query = ""; | ||||
|       this.$refs.input.focus(); | ||||
|     }, | ||||
|     handleInput(e) { | ||||
|       this.$emit("input", this.query); | ||||
|       this.dropdownIndex = -1; | ||||
|     }, | ||||
|     handleSubmit() { | ||||
|       if (!this.query || this.query.length == 0) return; | ||||
|   const elasticUrl = config.ELASTIC_URL; | ||||
|   if (elasticUrl === undefined || elasticUrl === "") { | ||||
|     disabled.value = true; | ||||
|   } | ||||
|  | ||||
|       if (this.dropdownIndex >= 0) { | ||||
|         const resultItem = this.dropdownResults[this.dropdownIndex]; | ||||
|  | ||||
|         console.log("resultItem:", resultItem); | ||||
|         this.open({ | ||||
|           id: resultItem.id, | ||||
|           type: resultItem.type | ||||
|         }); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       this.search(); | ||||
|       this.$refs.input.blur(); | ||||
|       this.dropdownIndex = -1; | ||||
|     }, | ||||
|     handleEscape() { | ||||
|       if (!this.isOpen) { | ||||
|         this.$refs.input.blur(); | ||||
|         this.dropdownIndex = -1; | ||||
|       } | ||||
|   function navigateDown() { | ||||
|     if (dropdownIndex.value < dropdownResults.value.length - 1) { | ||||
|       dropdownIndex.value++; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   function navigateUp() { | ||||
|     if (dropdownIndex.value > -1) dropdownIndex.value--; | ||||
|  | ||||
|     const textLength = inputElement.value.value.length; | ||||
|  | ||||
|     setTimeout(() => { | ||||
|       inputElement.value.focus(); | ||||
|       inputElement.value.setSelectionRange(textLength, textLength + 1); | ||||
|     }, 1); | ||||
|   } | ||||
|  | ||||
|   function search() { | ||||
|     const encodedQuery = encodeURI(query.value.replace("/ /g", "+")); | ||||
|  | ||||
|     router.push({ | ||||
|       name: "search", | ||||
|       query: { | ||||
|         ...route.query, | ||||
|         query: encodedQuery | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function handleInput(e) { | ||||
|     dropdownIndex.value = -1; | ||||
|   } | ||||
|  | ||||
|   function handleSubmit() { | ||||
|     if (!query.value || query.value.length == 0) return; | ||||
|  | ||||
|     if (dropdownIndex.value >= 0) { | ||||
|       const resultItem = dropdownResults.value[dropdownIndex.value]; | ||||
|  | ||||
|       store.dispatch("popup/open", { | ||||
|         id: resultItem?.id, | ||||
|         type: resultItem?.type | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     search(); | ||||
|     reset(); | ||||
|   } | ||||
|  | ||||
|   function focus() { | ||||
|     inputIsActive.value = true; | ||||
|   } | ||||
|  | ||||
|   function blur(event: MouseEvent = null) { | ||||
|     return setTimeout(reset, 150); | ||||
|   } | ||||
|  | ||||
|   function reset() { | ||||
|     inputElement.value.blur(); | ||||
|     dropdownIndex.value = -1; | ||||
|     inputIsActive.value = false; | ||||
|   } | ||||
|  | ||||
|   function clearInput(event: MouseEvent) { | ||||
|     query.value = ""; | ||||
|     inputElement.value.focus(); | ||||
|   } | ||||
|  | ||||
|   function handleEscape() { | ||||
|     if (!isOpen.value) reset(); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| .close-icon { | ||||
|   position: absolute; | ||||
|   top: calc(50% - 12px); | ||||
|   right: 0; | ||||
|   cursor: pointer; | ||||
|   fill: var(--text-color); | ||||
|   height: 24px; | ||||
|   width: 24px; | ||||
|   .close-icon { | ||||
|     position: absolute; | ||||
|     top: calc(50% - 12px); | ||||
|     right: 0; | ||||
|     cursor: pointer; | ||||
|     fill: var(--text-color); | ||||
|     height: 24px; | ||||
|     width: 24px; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     right: 6px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .filter { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   margin: 1rem 2rem; | ||||
|  | ||||
|   h2 { | ||||
|     margin-top: 0.5rem; | ||||
|     margin-bottom: 0.5rem; | ||||
|     font-weight: 400; | ||||
|   } | ||||
|  | ||||
|   &-items { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|  | ||||
|     > :not(:first-child) { | ||||
|       margin-left: 1rem; | ||||
|     @include tablet-min { | ||||
|       right: 6px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| hr { | ||||
|   display: block; | ||||
|   height: 1px; | ||||
|   border: 0; | ||||
|   border-bottom: 1px solid $text-color-50; | ||||
|   margin-top: 10px; | ||||
|   margin-bottom: 10px; | ||||
|   width: 90%; | ||||
| } | ||||
|  | ||||
| .search.active { | ||||
|   input { | ||||
|     border-color: var(--color-green); | ||||
|   } | ||||
|  | ||||
|   .search-icon { | ||||
|     fill: var(--color-green); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .search { | ||||
|   height: $header-size; | ||||
|   display: flex; | ||||
|   position: fixed; | ||||
|   flex-wrap: wrap; | ||||
|   z-index: 5; | ||||
|   border: 0; | ||||
|   background-color: $background-color-secondary; | ||||
|  | ||||
|   // TODO check if this is for mobile | ||||
|   width: calc(100% - 110px); | ||||
|   top: 0; | ||||
|   right: 55px; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     position: relative; | ||||
|   .filter { | ||||
|     width: 100%; | ||||
|     right: 0px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     margin: 1rem 2rem; | ||||
|  | ||||
|     h2 { | ||||
|       margin-top: 0.5rem; | ||||
|       margin-bottom: 0.5rem; | ||||
|       font-weight: 400; | ||||
|     } | ||||
|  | ||||
|     &-items { | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       align-items: center; | ||||
|  | ||||
|       > :not(:first-child) { | ||||
|         margin-left: 1rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   input { | ||||
|   hr { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 13px 28px 13px 45px; | ||||
|     outline: none; | ||||
|     margin: 0; | ||||
|     height: 1px; | ||||
|     border: 0; | ||||
|     background-color: $background-color-secondary; | ||||
|     font-weight: 300; | ||||
|     font-size: 18px; | ||||
|     color: $text-color; | ||||
|     border-bottom: 1px solid transparent; | ||||
|     border-bottom: 1px solid $text-color-50; | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 10px; | ||||
|     width: 90%; | ||||
|   } | ||||
|  | ||||
|     &:focus { | ||||
|       // border-bottom: 1px solid var(--color-green); | ||||
|   .search.active { | ||||
|     input { | ||||
|       border-color: var(--color-green); | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 24px; | ||||
|       padding: 13px 40px 13px 60px; | ||||
|     .search-icon { | ||||
|       fill: var(--color-green); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &-icon { | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
|     fill: var(--text-color-50); | ||||
|     pointer-events: none; | ||||
|     position: absolute; | ||||
|     left: 15px; | ||||
|     top: calc(50% - 10px); | ||||
|   .search { | ||||
|     height: $header-size; | ||||
|     display: flex; | ||||
|     position: fixed; | ||||
|     flex-wrap: wrap; | ||||
|     z-index: 5; | ||||
|     border: 0; | ||||
|     background-color: $background-color-secondary; | ||||
|  | ||||
|     // TODO check if this is for mobile | ||||
|     width: calc(100% - 110px); | ||||
|     top: 0; | ||||
|     right: 55px; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       top: calc(50% - 12px); | ||||
|       left: 22px; | ||||
|       position: relative; | ||||
|       width: 100%; | ||||
|       right: 0px; | ||||
|     } | ||||
|  | ||||
|     input { | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|       padding: 13px 28px 13px 45px; | ||||
|       outline: none; | ||||
|       margin: 0; | ||||
|       border: 0; | ||||
|       background-color: $background-color-secondary; | ||||
|       font-weight: 300; | ||||
|       font-size: 18px; | ||||
|       color: $text-color; | ||||
|       border-bottom: 1px solid transparent; | ||||
|  | ||||
|       &:focus { | ||||
|         // border-bottom: 1px solid var(--color-green); | ||||
|         border-color: var(--color-green); | ||||
|       } | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 24px; | ||||
|         padding: 13px 40px 13px 60px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &-icon { | ||||
|       width: 20px; | ||||
|       height: 20px; | ||||
|       fill: var(--text-color-50); | ||||
|       pointer-events: none; | ||||
|       position: absolute; | ||||
|       left: 15px; | ||||
|       top: calc(50% - 10px); | ||||
|  | ||||
|       @include tablet-min { | ||||
|         width: 24px; | ||||
|         height: 24px; | ||||
|         top: calc(50% - 12px); | ||||
|         left: 22px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,82 +1,85 @@ | ||||
| <template> | ||||
|   <li | ||||
|     class="sidebar-list-element" | ||||
|     @click="event => $emit('click', event)" | ||||
|     @click="emit('click')" | ||||
|     :class="{ active, disabled }" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     active: { | ||||
|       type: Boolean, | ||||
|       default: false | ||||
|     }, | ||||
|     disabled: { | ||||
|       type: Boolean, | ||||
|       default: false | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     active?: Boolean; | ||||
|     disabled?: Boolean; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "click"); | ||||
|   } | ||||
|  | ||||
|   const emit = defineEmits<Emit>(); | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| li.sidebar-list-element { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   text-decoration: none; | ||||
|   text-transform: uppercase; | ||||
|   color: var(--text-color-50); | ||||
|   font-size: 12px; | ||||
|   padding: 10px 0; | ||||
|   border-bottom: 1px solid var(--text-color-5); | ||||
|   cursor: pointer; | ||||
|   li.sidebar-list-element { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     text-decoration: none; | ||||
|     text-transform: uppercase; | ||||
|     color: var(--text-color-50); | ||||
|     font-size: 12px; | ||||
|     padding: 10px 0; | ||||
|     border-bottom: 1px solid var(--text-color-5); | ||||
|     cursor: pointer; | ||||
|     user-select: none; | ||||
|     -webkit-user-select: none; | ||||
|  | ||||
|   &:first-of-type { | ||||
|     padding-top: 0; | ||||
|   } | ||||
|  | ||||
|   div > svg, | ||||
|   svg { | ||||
|     width: 26px; | ||||
|     height: 26px; | ||||
|     margin-right: 1rem; | ||||
|     transition: all 0.3s ease; | ||||
|     fill: var(--text-color-70); | ||||
|   } | ||||
|  | ||||
|   &:hover, | ||||
|   &.active { | ||||
|     color: var(--text-color); | ||||
|     &:first-of-type { | ||||
|       padding-top: 0; | ||||
|     } | ||||
|  | ||||
|     div > svg, | ||||
|     svg { | ||||
|       fill: var(--text-color); | ||||
|       transform: scale(1.1, 1.1); | ||||
|       width: 26px; | ||||
|       height: 26px; | ||||
|       margin-right: 1rem; | ||||
|       transition: all 0.3s ease; | ||||
|       fill: var(--text-color-70); | ||||
|     } | ||||
|  | ||||
|     &:hover, | ||||
|     &.active { | ||||
|       color: var(--text-color); | ||||
|  | ||||
|       div > svg, | ||||
|       svg { | ||||
|         fill: var(--text-color); | ||||
|         transform: scale(1.1, 1.1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.active > div > svg, | ||||
|     &.active > svg { | ||||
|       fill: var(--color-green); | ||||
|     } | ||||
|  | ||||
|     &.disabled { | ||||
|       cursor: default; | ||||
|     } | ||||
|  | ||||
|     .pending { | ||||
|       color: #f8bd2d; | ||||
|     } | ||||
|  | ||||
|     .meta { | ||||
|       margin-left: auto; | ||||
|       text-align: right; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.active > div > svg, | ||||
|   &.active > svg { | ||||
|     fill: var(--color-green); | ||||
|   } | ||||
|  | ||||
|   &.disabled { | ||||
|     cursor: default; | ||||
|   } | ||||
|  | ||||
|   .pending { | ||||
|     color: #f8bd2d; | ||||
|   } | ||||
|  | ||||
|   .meta { | ||||
|     margin-left: auto; | ||||
|     text-align: right; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| <template> | ||||
|   <div | ||||
|     id="description" | ||||
|     ref="descriptionElement" | ||||
|     class="movie-description noselect" | ||||
|     @click="overflow ? (truncated = !truncated) : null" | ||||
|   > | ||||
|     <span ref="description" :class="{ truncated }">{{ description }}</span> | ||||
|     <span :class="{ truncated }">{{ description }}</span> | ||||
|  | ||||
|     <button v-if="description && overflow" class="truncate-toggle"> | ||||
|       <IconArrowDown :class="{ rotate: !truncated }" /> | ||||
| @@ -12,113 +12,114 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import IconArrowDown from "../../icons/IconArrowDown"; | ||||
| export default { | ||||
|   components: { IconArrowDown }, | ||||
|   props: { | ||||
|     description: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       truncated: true, | ||||
|       overflow: false | ||||
|     }; | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.checkDescriptionOverflowing(); | ||||
|   }, | ||||
|   methods: { | ||||
|     checkDescriptionOverflowing() { | ||||
|       const descriptionEl = document.getElementById("description"); | ||||
|       if (!descriptionEl) return; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, onMounted } from "vue"; | ||||
|   import IconArrowDown from "../../icons/IconArrowDown.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|       const { height, width } = descriptionEl.getBoundingClientRect(); | ||||
|       const { fontSize, lineHeight } = getComputedStyle(descriptionEl); | ||||
|  | ||||
|       const elementWithoutOverflow = document.createElement("div"); | ||||
|       elementWithoutOverflow.setAttribute( | ||||
|         "style", | ||||
|         `max-width: ${Math.ceil( | ||||
|           width + 10 | ||||
|         )}px; display: block; font-size: ${fontSize}; line-height: ${lineHeight};` | ||||
|       ); | ||||
|       // Don't know why need to add 10px to width, but works out perfectly | ||||
|  | ||||
|       elementWithoutOverflow.classList.add("dummy-non-overflow"); | ||||
|       elementWithoutOverflow.innerText = this.description; | ||||
|  | ||||
|       document.body.appendChild(elementWithoutOverflow); | ||||
|       const elemWithoutOverflowHeight = | ||||
|         elementWithoutOverflow.getBoundingClientRect()["height"]; | ||||
|  | ||||
|       this.overflow = elemWithoutOverflowHeight > height; | ||||
|       this.removeElements(document.querySelectorAll(".dummy-non-overflow")); | ||||
|     }, | ||||
|     removeElements: elems => elems.forEach(el => el.remove()) | ||||
|   interface Props { | ||||
|     description: string; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const truncated: Ref<boolean> = ref(true); | ||||
|   const overflow: Ref<boolean> = ref(false); | ||||
|   const descriptionElement: Ref<HTMLElement> = ref(null); | ||||
|  | ||||
|   onMounted(checkDescriptionOverflowing); | ||||
|  | ||||
|   // The description element overflows text after 4 rows with css | ||||
|   // line-clamp this takes the same text and adds to a temporary | ||||
|   // element without css overflow. If the temp element is | ||||
|   // higher then description element, we display expand button | ||||
|   function checkDescriptionOverflowing() { | ||||
|     const element = descriptionElement?.value; | ||||
|     if (!element) return; | ||||
|  | ||||
|     const { height, width } = element.getBoundingClientRect(); | ||||
|     const { fontSize, lineHeight } = getComputedStyle(element); | ||||
|  | ||||
|     const descriptionComparisonElement = document.createElement("div"); | ||||
|     descriptionComparisonElement.setAttribute( | ||||
|       "style", | ||||
|       `max-width: ${Math.ceil( | ||||
|         width + 10 | ||||
|       )}px; display: block; font-size: ${fontSize}; line-height: ${lineHeight};` | ||||
|     ); | ||||
|     // Don't know why need to add 10px to width, but works out perfectly | ||||
|  | ||||
|     descriptionComparisonElement.classList.add("dummy-non-overflow"); | ||||
|     descriptionComparisonElement.innerText = props.description; | ||||
|  | ||||
|     document.body.appendChild(descriptionComparisonElement); | ||||
|     const elemWithoutOverflowHeight = | ||||
|       descriptionComparisonElement.getBoundingClientRect()["height"]; | ||||
|  | ||||
|     overflow.value = elemWithoutOverflowHeight > height; | ||||
|     removeElements(document.querySelectorAll(".dummy-non-overflow")); | ||||
|   } | ||||
|  | ||||
|   function removeElements(elems: NodeListOf<Element>) { | ||||
|     elems.forEach(el => el.remove()); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .movie-description { | ||||
|   font-weight: 300; | ||||
|   font-size: 13px; | ||||
|   line-height: 1.8; | ||||
|   margin-bottom: 20px; | ||||
|   transition: all 1s ease; | ||||
|   .movie-description { | ||||
|     font-weight: 300; | ||||
|     font-size: 13px; | ||||
|     line-height: 1.8; | ||||
|     margin-bottom: 20px; | ||||
|     transition: all 1s ease; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     margin-bottom: 30px; | ||||
|     font-size: 14px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| span.truncated { | ||||
|   display: -webkit-box; | ||||
|   overflow: hidden; | ||||
|   -webkit-line-clamp: 4; | ||||
|   -webkit-box-orient: vertical; | ||||
| } | ||||
|  | ||||
| .truncate-toggle { | ||||
|   border: none; | ||||
|   background: none; | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   text-align: center; | ||||
|   color: var(--text-color); | ||||
|   margin-top: 1rem; | ||||
|   cursor: pointer; | ||||
|  | ||||
|   svg { | ||||
|     transition: 0.4s ease all; | ||||
|     height: 22px; | ||||
|     width: 22px; | ||||
|     fill: var(--text-color); | ||||
|  | ||||
|     &.rotate { | ||||
|       transform: rotateX(180deg); | ||||
|     @include tablet-min { | ||||
|       margin-bottom: 30px; | ||||
|       font-size: 14px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &::before, | ||||
|   &::after { | ||||
|     content: ""; | ||||
|     flex: 1; | ||||
|     border-bottom: 1px solid var(--text-color-50); | ||||
|   span.truncated { | ||||
|     display: -webkit-box; | ||||
|     overflow: hidden; | ||||
|     -webkit-line-clamp: 4; | ||||
|     -webkit-box-orient: vertical; | ||||
|   } | ||||
|   &::before { | ||||
|     margin-right: 1rem; | ||||
|  | ||||
|   .truncate-toggle { | ||||
|     border: none; | ||||
|     background: none; | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     text-align: center; | ||||
|     color: var(--text-color); | ||||
|     margin-top: 1rem; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     svg { | ||||
|       transition: 0.4s ease all; | ||||
|       height: 22px; | ||||
|       width: 22px; | ||||
|       fill: var(--text-color); | ||||
|  | ||||
|       &.rotate { | ||||
|         transform: rotateX(180deg); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &::before, | ||||
|     &::after { | ||||
|       content: ""; | ||||
|       flex: 1; | ||||
|       border-bottom: 1px solid var(--text-color-50); | ||||
|     } | ||||
|     &::before { | ||||
|       margin-right: 1rem; | ||||
|     } | ||||
|     &::after { | ||||
|       margin-left: 1rem; | ||||
|     } | ||||
|   } | ||||
|   &::after { | ||||
|     margin-left: 1rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -7,52 +7,48 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     title: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     detail: { | ||||
|       required: false, | ||||
|       default: null | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     title: string; | ||||
|     detail?: string | number; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .movie-detail { | ||||
|   margin-bottom: 20px; | ||||
|   .movie-detail { | ||||
|     margin-bottom: 20px; | ||||
|  | ||||
|   &:last-of-type { | ||||
|     margin-bottom: 0px; | ||||
|   } | ||||
|     &:last-of-type { | ||||
|       margin-bottom: 0px; | ||||
|     } | ||||
|  | ||||
|   @include tablet-min { | ||||
|     margin-bottom: 30px; | ||||
|   } | ||||
|     @include tablet-min { | ||||
|       margin-bottom: 30px; | ||||
|     } | ||||
|  | ||||
|   h2.title { | ||||
|     margin: 0; | ||||
|     font-weight: 400; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 1.2rem; | ||||
|     color: var(--color-green); | ||||
|     h2.title { | ||||
|       margin: 0; | ||||
|       font-weight: 400; | ||||
|       text-transform: uppercase; | ||||
|       font-size: 1.2rem; | ||||
|       color: var(--color-green); | ||||
|  | ||||
|     @include mobile { | ||||
|       font-size: 1.1rem; | ||||
|       @include mobile { | ||||
|         font-size: 1.1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     span.info { | ||||
|       font-weight: 300; | ||||
|       font-size: 1rem; | ||||
|       letter-spacing: 0.8px; | ||||
|       margin-top: 5px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   span.info { | ||||
|     font-weight: 300; | ||||
|     font-size: 1rem; | ||||
|     letter-spacing: 0.8px; | ||||
|     margin-top: 5px; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|   <section class="movie"> | ||||
|     <!-- HEADER w/ POSTER --> | ||||
|     <header | ||||
|       ref="header" | ||||
|       ref="backdropElement" | ||||
|       :class="compact ? 'compact' : ''" | ||||
|       @click="compact = !compact" | ||||
|     > | ||||
| @@ -10,13 +10,13 @@ | ||||
|         <img | ||||
|           class="movie-item__img is-loaded" | ||||
|           ref="poster-image" | ||||
|           src="/assets/placeholder.png" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
|  | ||||
|       <div v-if="movie" class="movie__title"> | ||||
|         <h1>{{ movie.title || movie.name }}</h1> | ||||
|         <i>{{ movie.tagline }}</i> | ||||
|       <div v-if="media" class="movie__title"> | ||||
|         <h1>{{ media.title || media.name }}</h1> | ||||
|         <i>{{ media.tagline }}</i> | ||||
|       </div> | ||||
|       <loading-placeholder v-else :count="2" /> | ||||
|     </header> | ||||
| @@ -25,11 +25,15 @@ | ||||
|     <div class="movie__main"> | ||||
|       <div class="movie__wrap movie__wrap--main"> | ||||
|         <!-- SIDEBAR ACTIONS --> | ||||
|         <div class="movie__actions" v-if="movie"> | ||||
|           <action-button :active="matched" :disabled="true"> | ||||
|             <IconThumbsUp v-if="matched" /> | ||||
|         <div class="movie__actions" v-if="media"> | ||||
|           <action-button :active="media?.exists_in_plex" :disabled="true"> | ||||
|             <IconThumbsUp v-if="media?.exists_in_plex" /> | ||||
|             <IconThumbsDown v-else /> | ||||
|             {{ !matched ? "Not yet available" : "Already available 🎉" }} | ||||
|             {{ | ||||
|               !media?.exists_in_plex | ||||
|                 ? "Not yet available" | ||||
|                 : "Already available 🎉" | ||||
|             }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button @click="sendRequest" :active="requested"> | ||||
| @@ -37,16 +41,19 @@ | ||||
|               <div v-if="!requested" key="request"><IconRequest /></div> | ||||
|               <div v-else key="requested"><IconRequested /></div> | ||||
|             </transition> | ||||
|             {{ !requested ? `Request ${this.type}?` : "Already requested" }} | ||||
|             {{ !requested ? `Request ${type}?` : "Already requested" }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button v-if="plexId && matched" @click="openInPlex"> | ||||
|           <action-button | ||||
|             v-if="plexId && media?.exists_in_plex" | ||||
|             @click="openInPlex" | ||||
|           > | ||||
|             <IconPlay /> | ||||
|             Open and watch in plex now! | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button | ||||
|             v-if="credits && credits.cast && credits.cast.length" | ||||
|             v-if="cast?.length" | ||||
|             :active="showCast" | ||||
|             @click="() => (showCast = !showCast)" | ||||
|           > | ||||
| @@ -94,451 +101,419 @@ | ||||
|           </div> | ||||
|  | ||||
|           <Description | ||||
|             v-if="!loading && movie && movie.overview" | ||||
|             :description="movie.overview" | ||||
|             v-if="!loading && media && media.overview" | ||||
|             :description="media.overview" | ||||
|           /> | ||||
|  | ||||
|           <div class="movie__details" v-if="movie"> | ||||
|           <div class="movie__details" v-if="media"> | ||||
|             <Detail | ||||
|               v-if="movie.year" | ||||
|               v-if="media.year" | ||||
|               title="Release date" | ||||
|               :detail="movie.year" | ||||
|               :detail="media.year" | ||||
|             /> | ||||
|             <Detail v-if="movie.rating" title="Rating" :detail="movie.rating" /> | ||||
|             <Detail v-if="media.rating" title="Rating" :detail="media.rating" /> | ||||
|             <Detail | ||||
|               v-if="movie.type == 'show'" | ||||
|               v-if="media.type == ListTypes.Show" | ||||
|               title="Seasons" | ||||
|               :detail="movie.seasons" | ||||
|               :detail="media.seasons" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="movie.genres && movie.genres.length" | ||||
|               v-if="media.genres && media.genres.length" | ||||
|               title="Genres" | ||||
|               :detail="movie.genres.join(', ')" | ||||
|               :detail="media.genres.join(', ')" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if=" | ||||
|                 movie.production_status && | ||||
|                 movie.production_status !== 'Released' | ||||
|                 media.production_status && | ||||
|                 media.production_status !== 'Released' | ||||
|               " | ||||
|               title="Production status" | ||||
|               :detail="movie.production_status" | ||||
|               :detail="media.production_status" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="movie.runtime" | ||||
|               v-if="media.runtime" | ||||
|               title="Runtime" | ||||
|               :detail="humanMinutes(movie.runtime)" | ||||
|               :detail="humanMinutes(media.runtime)" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- TODO: change this classname, this is general  --> | ||||
|  | ||||
|         <div | ||||
|           class="movie__admin" | ||||
|           v-if="showCast && credits && credits.cast && credits.cast.length" | ||||
|         > | ||||
|         <div class="movie__admin" v-if="showCast && cast?.length"> | ||||
|           <Detail title="cast"> | ||||
|             <CastList :cast="credits.cast" /> | ||||
|             <CastList :cast="cast" /> | ||||
|           </Detail> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <!-- TORRENT LIST --> | ||||
|       <TorrentList | ||||
|         v-if="movie && admin" | ||||
|         :show="showTorrents" | ||||
|         :query="title" | ||||
|         v-if="media && admin && showTorrents" | ||||
|         class="torrents" | ||||
|         :query="media?.title" | ||||
|         :tmdb_id="id" | ||||
|         :admin="admin" | ||||
|       ></TorrentList> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters } from "vuex"; | ||||
| import img from "@/directives/v-image"; | ||||
| import IconProfile from "@/icons/IconProfile"; | ||||
| import IconThumbsUp from "@/icons/IconThumbsUp"; | ||||
| import IconThumbsDown from "@/icons/IconThumbsDown"; | ||||
| import IconInfo from "@/icons/IconInfo"; | ||||
| import IconRequest from "@/icons/IconRequest"; | ||||
| import IconRequested from "@/icons/IconRequested"; | ||||
| import IconBinoculars from "@/icons/IconBinoculars"; | ||||
| import IconPlay from "@/icons/IconPlay"; | ||||
| import TorrentList from "@/components/TorrentList"; | ||||
| import CastList from "@/components/CastList"; | ||||
| import Detail from "@/components/popup/Detail"; | ||||
| import ActionButton from "@/components/popup/ActionButton"; | ||||
| import Description from "@/components/popup/Description"; | ||||
| import store from "@/store"; | ||||
| import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|  | ||||
| import { | ||||
|   getMovie, | ||||
|   getShow, | ||||
|   getPerson, | ||||
|   getCredits, | ||||
|   request, | ||||
|   getRequestStatus, | ||||
|   watchLink | ||||
| } from "@/api"; | ||||
|   // import img from "@/directives/v-image"; | ||||
|   import IconProfile from "@/icons/IconProfile.vue"; | ||||
|   import IconThumbsUp from "@/icons/IconThumbsUp.vue"; | ||||
|   import IconThumbsDown from "@/icons/IconThumbsDown.vue"; | ||||
|   import IconInfo from "@/icons/IconInfo.vue"; | ||||
|   import IconRequest from "@/icons/IconRequest.vue"; | ||||
|   import IconRequested from "@/icons/IconRequested.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import IconPlay from "@/icons/IconPlay.vue"; | ||||
|   import TorrentList from "@/components/torrent/TruncatedTorrentResults.vue"; | ||||
|   import CastList from "@/components/CastList.vue"; | ||||
|   import Detail from "@/components/popup/Detail.vue"; | ||||
|   import ActionButton from "@/components/popup/ActionButton.vue"; | ||||
|   import Description from "@/components/popup/Description.vue"; | ||||
|   import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import type { | ||||
|     IMovie, | ||||
|     IShow, | ||||
|     IMediaCredits, | ||||
|     ICast | ||||
|   } from "../../interfaces/IList"; | ||||
|   import { ListTypes } from "../../interfaces/IList"; | ||||
|  | ||||
| export default { | ||||
|   // props: ['id', 'type'], | ||||
|   props: { | ||||
|     id: { | ||||
|       required: true, | ||||
|       type: Number | ||||
|     }, | ||||
|     type: { | ||||
|       required: false, | ||||
|       type: String | ||||
|     } | ||||
|   }, | ||||
|   components: { | ||||
|     Description, | ||||
|     Detail, | ||||
|     ActionButton, | ||||
|     IconProfile, | ||||
|     IconThumbsUp, | ||||
|     IconThumbsDown, | ||||
|     IconRequest, | ||||
|     IconRequested, | ||||
|     IconInfo, | ||||
|     IconBinoculars, | ||||
|     IconPlay, | ||||
|     TorrentList, | ||||
|     CastList, | ||||
|     LoadingPlaceholder | ||||
|   }, | ||||
|   directives: { img: img }, // TODO decide to remove or use | ||||
|   data() { | ||||
|     return { | ||||
|       ASSET_URL: "https://image.tmdb.org/t/p/", | ||||
|       ASSET_SIZES: ["w500", "w780", "original"], | ||||
|       movie: undefined, | ||||
|       title: undefined, | ||||
|       poster: undefined, | ||||
|       backdrop: undefined, | ||||
|       matched: false, | ||||
|       requested: false, | ||||
|       showTorrents: false, | ||||
|       showCast: false, | ||||
|       credits: [], | ||||
|       compact: false, | ||||
|       loading: true | ||||
|     }; | ||||
|   }, | ||||
|   watch: { | ||||
|     id: function (val) { | ||||
|       this.fetchByType(); | ||||
|     }, | ||||
|     backdrop: function (backdrop) { | ||||
|       if (backdrop != null) { | ||||
|         const style = { | ||||
|           backgroundImage: | ||||
|             "url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")" | ||||
|         }; | ||||
|   import { humanMinutes } from "../../utils"; | ||||
|   import { | ||||
|     getMovie, | ||||
|     getShow, | ||||
|     getPerson, | ||||
|     getMovieCredits, | ||||
|     getShowCredits, | ||||
|     request, | ||||
|     getRequestStatus, | ||||
|     watchLink | ||||
|   } from "../../api"; | ||||
|  | ||||
|         Object.assign(this.$refs.header.style, style); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters("user", ["loggedIn", "admin", "plexId"]), | ||||
|     numberOfTorrentResults: () => { | ||||
|       let numTorrents = store.getters["torrentModule/resultCount"]; | ||||
|       return numTorrents !== null ? numTorrents + " results" : null; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     async fetchByType() { | ||||
|       try { | ||||
|         let response; | ||||
|         if (this.type === "movie") { | ||||
|           response = await getMovie(this.id, true, false); | ||||
|         } else if (this.type === "show") { | ||||
|           response = await getShow(this.id, false, false); | ||||
|         } else { | ||||
|           this.$router.push({ name: "404" }); | ||||
|         } | ||||
|  | ||||
|         this.parseResponse(response); | ||||
|       } catch (error) { | ||||
|         this.$router.push({ name: "404" }); | ||||
|       } | ||||
|  | ||||
|       // async get credits | ||||
|       getCredits(this.type, this.id).then(credits => (this.credits = credits)); | ||||
|     }, | ||||
|     parseResponse(movie) { | ||||
|       this.loading = false; | ||||
|       this.movie = { ...movie }; | ||||
|       this.title = movie.title; | ||||
|       this.poster = movie.poster; | ||||
|       this.backdrop = movie.backdrop; | ||||
|       this.matched = movie.exists_in_plex || false; | ||||
|       this.checkIfRequested(movie).then(status => (this.requested = status)); | ||||
|  | ||||
|       store.dispatch("documentTitle/updateTitle", movie.title); | ||||
|       this.setPosterSrc(); | ||||
|     }, | ||||
|     async checkIfRequested(movie) { | ||||
|       return await getRequestStatus(movie.id, movie.type); | ||||
|     }, | ||||
|     setPosterSrc() { | ||||
|       const poster = this.$refs["poster-image"]; | ||||
|       if (this.poster == null) { | ||||
|         poster.src = "/assets/no-image.svg"; | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`; | ||||
|     }, | ||||
|     humanMinutes(minutes) { | ||||
|       if (minutes instanceof Array) { | ||||
|         minutes = minutes[0]; | ||||
|       } | ||||
|  | ||||
|       const hours = Math.floor(minutes / 60); | ||||
|       const minutesLeft = minutes - hours * 60; | ||||
|  | ||||
|       if (minutesLeft == 0) { | ||||
|         return hours > 1 ? `${hours} hours` : `${hours} hour`; | ||||
|       } else if (hours == 0) { | ||||
|         return `${minutesLeft} min`; | ||||
|       } | ||||
|  | ||||
|       return `${hours}h ${minutesLeft}m`; | ||||
|     }, | ||||
|     sendRequest() { | ||||
|       request(this.id, this.type).then(resp => { | ||||
|         if (resp.success) { | ||||
|           this.requested = true; | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     openInPlex() { | ||||
|       watchLink(this.title, this.movie.year).then( | ||||
|         watchLink => (window.location = watchLink) | ||||
|       ); | ||||
|     }, | ||||
|     openTmdb() { | ||||
|       const tmdbType = this.type === "show" ? "tv" : this.type; | ||||
|       window.location.href = | ||||
|         "https://www.themoviedb.org/" + tmdbType + "/" + this.id; | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     store.dispatch("torrentModule/setResultCount", null); | ||||
|     this.prevDocumentTitle = store.getters["documentTitle/title"]; | ||||
|     this.fetchByType(); | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle); | ||||
|   interface Props { | ||||
|     id: number; | ||||
|     type: ListTypes.Movie | ListTypes.Show; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const ASSET_URL = "https://image.tmdb.org/t/p/"; | ||||
|   const ASSET_SIZES = ["w500", "w780", "original"]; | ||||
|  | ||||
|   const media: Ref<IMovie | IShow> = ref(); | ||||
|   const requested: Ref<boolean> = ref(); | ||||
|   const showTorrents: Ref<boolean> = ref(); | ||||
|   const showCast: Ref<boolean> = ref(); | ||||
|   const cast: Ref<ICast[]> = ref([]); | ||||
|   const compact: Ref<boolean> = ref(); | ||||
|   const loading: Ref<boolean> = ref(); | ||||
|   const backdropElement: Ref<HTMLElement> = ref(); | ||||
|  | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const loggedIn = computed(() => store.getters["user/loggedIn"]); | ||||
|   const admin = computed(() => store.getters["user/admin"]); | ||||
|   const plexId = computed(() => store.getters["user/plexId"]); | ||||
|   const poster = computed(() => computePoster()); | ||||
|  | ||||
|   const numberOfTorrentResults = computed(() => { | ||||
|     const count = store.getters["torrentModule/resultCount"]; | ||||
|     return count ? `${count} results` : null; | ||||
|   }); | ||||
|  | ||||
|   // On created functions | ||||
|   fetchMedia(); | ||||
|   setBackdrop(); | ||||
|   store.dispatch("torrentModule/setResultCount", null); | ||||
|   // End on create functions | ||||
|  | ||||
|   function fetchMedia() { | ||||
|     if (!props.id || !props.type) { | ||||
|       console.error("Unable to fetch media, requires id & type"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     let apiFunction: Function; | ||||
|     let parameters: object; | ||||
|  | ||||
|     if (props.type === ListTypes.Movie) { | ||||
|       apiFunction = getMovie; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } else if (props.type === ListTypes.Show) { | ||||
|       apiFunction = getShow; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } | ||||
|  | ||||
|     apiFunction(props.id, { ...parameters }) | ||||
|       .then(setAndReturnMedia) | ||||
|       .then(media => getCredits(props.type)) | ||||
|       .then(credits => (cast.value = credits?.cast)) | ||||
|       .then(() => getRequestStatus(props.id, props.type)) | ||||
|       .then(requestStatus => (requested.value = requestStatus || false)); | ||||
|   } | ||||
|  | ||||
|   function getCredits( | ||||
|     type: ListTypes.Movie | ListTypes.Show | ||||
|   ): Promise<IMediaCredits> { | ||||
|     if (type === ListTypes.Movie) { | ||||
|       return getMovieCredits(props.id); | ||||
|     } else if (type === ListTypes.Show) { | ||||
|       return getShowCredits(props.id); | ||||
|     } | ||||
|  | ||||
|     return Promise.reject(); | ||||
|   } | ||||
|  | ||||
|   function setAndReturnMedia(_media: IMovie | IShow) { | ||||
|     media.value = _media; | ||||
|     return _media; | ||||
|   } | ||||
|  | ||||
|   const computePoster = () => { | ||||
|     if (!media.value) return "/assets/placeholder.png"; | ||||
|     else if (!media.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`; | ||||
|   }; | ||||
|  | ||||
|   function setBackdrop() { | ||||
|     if (!media.value?.backdrop || !backdropElement.value?.style) return ""; | ||||
|  | ||||
|     const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`; | ||||
|     backdropElement.value.style.backgroundImage = `url(${backdropURL})`; | ||||
|   } | ||||
|  | ||||
|   function sendRequest() { | ||||
|     request(props.id, props.type).then( | ||||
|       resp => (requested.value = resp?.success || false) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function openInPlex() { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   function openTmdb() { | ||||
|     const tmdbType = props.type === ListTypes.Show ? "tv" : props.type; | ||||
|     const tmdbURL = "https://www.themoviedb.org/" + tmdbType + "/" + props.id; | ||||
|     window.location.href = tmdbURL; | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/loading-placeholder"; | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
|   @import "src/scss/loading-placeholder"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| header { | ||||
|   $duration: 0.2s; | ||||
|   transform: scaleY(1); | ||||
|   transition: height $duration ease; | ||||
|   transform-origin: top; | ||||
|   position: relative; | ||||
|   background-size: cover; | ||||
|   background-repeat: no-repeat; | ||||
|   background-position: 50% 50%; | ||||
|   background-color: $background-color; | ||||
|   display: grid; | ||||
|   grid-template-columns: 1fr 1fr; | ||||
|   height: 350px; | ||||
|   header { | ||||
|     $duration: 0.2s; | ||||
|     transform: scaleY(1); | ||||
|     transition: height $duration ease; | ||||
|     transform-origin: top; | ||||
|     position: relative; | ||||
|     background-size: cover; | ||||
|     background-repeat: no-repeat; | ||||
|     background-position: 50% 50%; | ||||
|     background-color: $background-color; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     height: 350px; | ||||
|  | ||||
|   @include mobile { | ||||
|     grid-template-columns: 1fr; | ||||
|     height: 250px; | ||||
|     place-items: center; | ||||
|   } | ||||
|  | ||||
|   * { | ||||
|     z-index: 2; | ||||
|   } | ||||
|  | ||||
|   &::before { | ||||
|     content: ""; | ||||
|     display: block; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     z-index: 1; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: $background-dark-85; | ||||
|   } | ||||
|  | ||||
|   @include mobile { | ||||
|     &.compact { | ||||
|       height: 100px; | ||||
|     @include mobile { | ||||
|       grid-template-columns: 1fr; | ||||
|       height: 250px; | ||||
|       place-items: center; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .movie__poster { | ||||
|   display: none; | ||||
|     * { | ||||
|       z-index: 2; | ||||
|     } | ||||
|  | ||||
|   @include desktop { | ||||
|     background: var(--background-color); | ||||
|     height: auto; | ||||
|     display: block; | ||||
|     width: calc(100% - 80px); | ||||
|     margin: 40px; | ||||
|  | ||||
|     > img { | ||||
|     &::before { | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       z-index: 1; | ||||
|       width: 100%; | ||||
|       border-radius: 10px; | ||||
|       height: 100%; | ||||
|       background: $background-dark-85; | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       &.compact { | ||||
|         height: 100px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .movie { | ||||
|   &__wrap { | ||||
|     &--header { | ||||
|       align-items: center; | ||||
|       height: 100%; | ||||
|   .movie__poster { | ||||
|     display: none; | ||||
|  | ||||
|     @include desktop { | ||||
|       background: var(--background-color); | ||||
|       height: auto; | ||||
|       display: block; | ||||
|       width: calc(100% - 80px); | ||||
|       margin: 40px; | ||||
|  | ||||
|       > img { | ||||
|         width: 100%; | ||||
|         border-radius: 10px; | ||||
|       } | ||||
|     } | ||||
|     &--main { | ||||
|   } | ||||
|  | ||||
|   .movie { | ||||
|     &__wrap { | ||||
|       &--header { | ||||
|         align-items: center; | ||||
|         height: 100%; | ||||
|       } | ||||
|       &--main { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|         flex-direction: column; | ||||
|         @include tablet-min { | ||||
|           flex-direction: row; | ||||
|         } | ||||
|  | ||||
|         background-color: $background-color; | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__img { | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|       opacity: 0; | ||||
|       transform: scale(0.97) translateZ(0); | ||||
|       transition: opacity 0.5s ease, transform 0.5s ease; | ||||
|  | ||||
|       &.is-loaded { | ||||
|         opacity: 1; | ||||
|         transform: scale(1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__title { | ||||
|       position: relative; | ||||
|       padding: 20px; | ||||
|       text-align: center; | ||||
|       width: 100%; | ||||
|       height: fit-content; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         text-align: left; | ||||
|         padding: 140px 30px 0 40px; | ||||
|       } | ||||
|       h1 { | ||||
|         color: var(--color-green); | ||||
|         font-weight: 500; | ||||
|         line-height: 1.4; | ||||
|         font-size: 24px; | ||||
|         margin-bottom: 0; | ||||
|  | ||||
|         @include tablet-min { | ||||
|           font-size: 30px; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       i { | ||||
|         display: block; | ||||
|         color: rgba(255, 255, 255, 0.8); | ||||
|         margin-top: 1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__actions { | ||||
|       text-align: center; | ||||
|       width: 100%; | ||||
|       order: 2; | ||||
|       padding: 20px; | ||||
|       border-top: 1px solid $text-color-5; | ||||
|       @include tablet-min { | ||||
|         order: 1; | ||||
|         width: 45%; | ||||
|         padding: 185px 0 40px 40px; | ||||
|         border-top: 0; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 1; | ||||
|       @include tablet-min { | ||||
|         order: 2; | ||||
|         padding: 40px; | ||||
|         width: 55%; | ||||
|         margin-left: 45%; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|     &__details { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       flex-direction: column; | ||||
|       @include tablet-min { | ||||
|         flex-direction: row; | ||||
|       } | ||||
|  | ||||
|       background-color: $background-color; | ||||
|       color: $text-color; | ||||
|     } | ||||
|   } | ||||
|       > * { | ||||
|         margin-right: 30px; | ||||
|  | ||||
|   &__img { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     opacity: 0; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|     transition: opacity 0.5s ease, transform 0.5s ease; | ||||
|  | ||||
|     &.is-loaded { | ||||
|       opacity: 1; | ||||
|       transform: scale(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__title { | ||||
|     position: relative; | ||||
|     padding: 20px; | ||||
|     text-align: center; | ||||
|     width: 100%; | ||||
|     height: fit-content; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       text-align: left; | ||||
|       padding: 140px 30px 0 40px; | ||||
|     } | ||||
|     h1 { | ||||
|       color: var(--color-green); | ||||
|       font-weight: 500; | ||||
|       line-height: 1.4; | ||||
|       font-size: 24px; | ||||
|       margin-bottom: 0; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 30px; | ||||
|         @include mobile { | ||||
|           margin-right: 20px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     i { | ||||
|       display: block; | ||||
|       color: rgba(255, 255, 255, 0.8); | ||||
|       margin-top: 1rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__actions { | ||||
|     text-align: center; | ||||
|     width: 100%; | ||||
|     order: 2; | ||||
|     padding: 20px; | ||||
|     border-top: 1px solid $text-color-5; | ||||
|     @include tablet-min { | ||||
|       order: 1; | ||||
|       width: 45%; | ||||
|       padding: 185px 0 40px 40px; | ||||
|       border-top: 0; | ||||
|     } | ||||
|   } | ||||
|   &__info { | ||||
|     width: 100%; | ||||
|     padding: 20px; | ||||
|     order: 1; | ||||
|     @include tablet-min { | ||||
|     &__admin { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 2; | ||||
|       padding: 40px; | ||||
|       width: 55%; | ||||
|       margin-left: 45%; | ||||
|       @include tablet-min { | ||||
|         order: 3; | ||||
|         padding: 40px; | ||||
|         padding-top: 0px; | ||||
|         width: 100%; | ||||
|       } | ||||
|       &-title { | ||||
|         margin: 0; | ||||
|         font-weight: 400; | ||||
|         text-transform: uppercase; | ||||
|         text-align: center; | ||||
|         font-size: 14px; | ||||
|         color: $green; | ||||
|         padding-bottom: 20px; | ||||
|         @include tablet-min { | ||||
|           font-size: 16px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   &__info { | ||||
|     margin-left: 0; | ||||
|   } | ||||
|   &__details { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|  | ||||
|     > * { | ||||
|       margin-right: 30px; | ||||
|     .torrents { | ||||
|       background-color: var(--background-color); | ||||
|       padding: 0 1rem; | ||||
|  | ||||
|       @include mobile { | ||||
|         margin-right: 20px; | ||||
|         padding: 0 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   &__admin { | ||||
|     width: 100%; | ||||
|     padding: 20px; | ||||
|     order: 2; | ||||
|     @include tablet-min { | ||||
|       order: 3; | ||||
|       padding: 40px; | ||||
|       padding-top: 0px; | ||||
|       width: 100%; | ||||
|     } | ||||
|     &-title { | ||||
|       margin: 0; | ||||
|       font-weight: 400; | ||||
|       text-transform: uppercase; | ||||
|       text-align: center; | ||||
|       font-size: 14px; | ||||
|       color: $green; | ||||
|       padding-bottom: 20px; | ||||
|       @include tablet-min { | ||||
|         font-size: 16px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .fade-enter-active, | ||||
| .fade-leave-active { | ||||
|   transition: opacity 0.4s; | ||||
| } | ||||
| .fade-enter, | ||||
| .fade-leave-to { | ||||
|   opacity: 0; | ||||
| } | ||||
|   .fade-enter-active, | ||||
|   .fade-leave-active { | ||||
|     transition: opacity 0.4s; | ||||
|   } | ||||
|   .fade-enter, | ||||
|   .fade-leave-to { | ||||
|     opacity: 0; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
|         <img | ||||
|           class="person-item__img is-loaded" | ||||
|           ref="poster-image" | ||||
|           src="/assets/placeholder.png" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
|     </header> | ||||
| @@ -51,250 +51,219 @@ | ||||
|  | ||||
|       <Detail | ||||
|         title="movies" | ||||
|         :detail="`Credited in ${movieCredits.length} movies`" | ||||
|         v-if="credits" | ||||
|         :detail="`Credited in ${creditedMovies.length} movies`" | ||||
|         v-if="creditedShows.length" | ||||
|       > | ||||
|         <CastList :cast="movieCredits" /> | ||||
|         <CastList :cast="creditedMovies" /> | ||||
|       </Detail> | ||||
|  | ||||
|       <Detail | ||||
|         title="shows" | ||||
|         :detail="`Credited in ${showCredits.length} shows`" | ||||
|         v-if="credits" | ||||
|         :detail="`Credited in ${creditedShows.length} shows`" | ||||
|         v-if="creditedShows.length" | ||||
|       > | ||||
|         <CastList :cast="showCredits" /> | ||||
|         <CastList :cast="creditedShows" /> | ||||
|       </Detail> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import img from "@/directives/v-image"; | ||||
| import CastList from "@/components/CastList"; | ||||
| import Detail from "@/components/popup/Detail"; | ||||
| import Description from "@/components/popup/Description"; | ||||
| import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps } from "vue"; | ||||
|   import img from "@/directives/v-image.vue"; | ||||
|   import CastList from "@/components/CastList.vue"; | ||||
|   import Detail from "@/components/popup/Detail.vue"; | ||||
|   import Description from "@/components/popup/Description.vue"; | ||||
|   import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; | ||||
|   import { getPerson, getPersonCredits } from "../../api"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import type { | ||||
|     IPerson, | ||||
|     IPersonCredits, | ||||
|     ICast, | ||||
|     IMovie, | ||||
|     IShow | ||||
|   } from "../../interfaces/IList"; | ||||
|   import { ListTypes } from "../../interfaces/IList"; | ||||
|  | ||||
| import { getPerson, getPersonCredits } from "@/api"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     id: { | ||||
|       required: true, | ||||
|       type: Number | ||||
|     }, | ||||
|     type: { | ||||
|       required: false, | ||||
|       type: String, | ||||
|       default: "person" | ||||
|     } | ||||
|   }, | ||||
|   components: { | ||||
|     Detail, | ||||
|     Description, | ||||
|     CastList, | ||||
|     LoadingPlaceholder | ||||
|   }, | ||||
|   directives: { img: img }, // TODO decide to remove or use | ||||
|   data() { | ||||
|     return { | ||||
|       ASSET_URL: "https://image.tmdb.org/t/p/", | ||||
|       ASSET_SIZES: ["w500", "w780", "original"], | ||||
|       person: undefined, | ||||
|       loading: true, | ||||
|       credits: undefined | ||||
|     }; | ||||
|   }, | ||||
|   watch: { | ||||
|     backdrop: function (backdrop) { | ||||
|       if (backdrop != null) { | ||||
|         const style = { | ||||
|           backgroundImage: | ||||
|             "url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")" | ||||
|         }; | ||||
|  | ||||
|         Object.assign(this.$refs.header.style, style); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     age: function () { | ||||
|       if (!this.person || !this.person.birthday) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const today = new Date().getFullYear(); | ||||
|       const birthYear = new Date(this.person.birthday).getFullYear(); | ||||
|       return `${today - birthYear} years old`; | ||||
|     }, | ||||
|     movieCredits: function () { | ||||
|       const { cast } = this.credits; | ||||
|       if (!cast) return; | ||||
|  | ||||
|       return cast | ||||
|         .filter(l => l.type === "movie") | ||||
|         .filter((item, pos, self) => self.indexOf(item) == pos) | ||||
|         .sort((a, b) => a.popularity < b.popularity); | ||||
|     }, | ||||
|     showCredits: function () { | ||||
|       const { cast } = this.credits; | ||||
|       if (!cast) return; | ||||
|  | ||||
|       const alreadyExists = (item, pos, self) => { | ||||
|         const names = self.map(item => item.title); | ||||
|         return names.indexOf(item.title) == pos; | ||||
|       }; | ||||
|  | ||||
|       return cast | ||||
|         .filter(item => item.type === "show") | ||||
|         .filter(alreadyExists) | ||||
|         .sort((a, b) => a.popularity < b.popularity); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     parseResponse(person) { | ||||
|       this.loading = false; | ||||
|       this.person = { ...person }; | ||||
|       this.title = person.title; | ||||
|       this.poster = person.poster; | ||||
|       if (person.credits) this.credits = person.credits; | ||||
|  | ||||
|       this.setPosterSrc(); | ||||
|     }, | ||||
|     setPosterSrc() { | ||||
|       const poster = this.$refs["poster-image"]; | ||||
|       if (this.poster == null) { | ||||
|         poster.src = "/assets/no-image.svg"; | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`; | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     getPerson(this.id, false) | ||||
|       .then(this.parseResponse) | ||||
|       .catch(error => { | ||||
|         console.error(error); | ||||
|         this.$router.push({ name: "404" }); | ||||
|       }); | ||||
|  | ||||
|     getPersonCredits(this.id) | ||||
|       .then(credits => (this.credits = credits)) | ||||
|       .catch(error => { | ||||
|         console.error(error); | ||||
|       }); | ||||
|   interface Props { | ||||
|     id: number; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const ASSET_URL = "https://image.tmdb.org/t/p/"; | ||||
|   const ASSET_SIZES = ["w500", "w780", "original"]; | ||||
|  | ||||
|   const person: Ref<IPerson> = ref(); | ||||
|   const credits: Ref<IPersonCredits> = ref(); | ||||
|   const loading: Ref<boolean> = ref(false); | ||||
|   const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|   const creditedShows: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|  | ||||
|   const poster: ComputedRef<string> = computed(() => computePoster()); | ||||
|   const age: ComputedRef<string> = computed(() => { | ||||
|     if (!person.value?.birthday) return; | ||||
|  | ||||
|     const today = new Date().getFullYear(); | ||||
|     const birthYear = new Date(person.value.birthday).getFullYear(); | ||||
|     return `${today - birthYear} years old`; | ||||
|   }); | ||||
|  | ||||
|   // On create functions | ||||
|   fetchPerson(); | ||||
|   // | ||||
|  | ||||
|   function fetchPerson() { | ||||
|     if (!props.id) { | ||||
|       console.error("Unable to fetch person, missing id!"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     getPerson(props.id) | ||||
|       .then(_person => (person.value = _person)) | ||||
|       .then(() => getPersonCredits(person.value?.id)) | ||||
|       .then(_credits => (credits.value = _credits)) | ||||
|       .then(() => personCreditedFrom(credits.value?.cast)); | ||||
|   } | ||||
|  | ||||
|   function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number { | ||||
|     return a.popularity < b.popularity ? 1 : -1; | ||||
|   } | ||||
|  | ||||
|   function alreadyExists(item: IMovie | IShow, pos: number, self: any[]) { | ||||
|     const names = self.map(item => item.title); | ||||
|     return names.indexOf(item.title) === pos; | ||||
|   } | ||||
|  | ||||
|   function personCreditedFrom(cast: Array<IMovie | IShow>): void { | ||||
|     creditedMovies.value = cast | ||||
|       .filter(credit => credit.type === ListTypes.Movie) | ||||
|       .filter(alreadyExists) | ||||
|       .sort(sortPopularity); | ||||
|  | ||||
|     creditedShows.value = cast | ||||
|       .filter(credit => credit.type === ListTypes.Show) | ||||
|       .filter(alreadyExists) | ||||
|       .sort(sortPopularity); | ||||
|   } | ||||
|  | ||||
|   const computePoster = () => { | ||||
|     if (!person.value) return "/assets/placeholder.png"; | ||||
|     else if (!person.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`; | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/loading-placeholder"; | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
| @import "src/scss/main"; | ||||
|   @import "src/scss/loading-placeholder"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| section.person { | ||||
|   overflow: hidden; | ||||
|   position: relative; | ||||
|   padding: 40px; | ||||
|   background-color: var(--background-color); | ||||
|   section.person { | ||||
|     overflow: hidden; | ||||
|     position: relative; | ||||
|     padding: 40px; | ||||
|     background-color: var(--background-color); | ||||
|  | ||||
|   @include mobile { | ||||
|     padding: 50px 20px 10px; | ||||
|     @include mobile { | ||||
|       padding: 50px 20px 10px; | ||||
|     } | ||||
|  | ||||
|     &:before { | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: -130px; | ||||
|       left: -100px; | ||||
|       z-index: 1; | ||||
|       width: 1000px; | ||||
|       height: 500px; | ||||
|       transform: rotate(21deg); | ||||
|       background-color: #062541; | ||||
|  | ||||
|       @include mobile { | ||||
|         // top: -52vw; | ||||
|         top: -215px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:before { | ||||
|     content: ""; | ||||
|   header { | ||||
|     $duration: 0.2s; | ||||
|     transition: height $duration ease; | ||||
|     position: relative; | ||||
|     background-color: transparent; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     height: 350px; | ||||
|     z-index: 2; | ||||
|  | ||||
|     @include mobile { | ||||
|       height: 180px; | ||||
|     } | ||||
|  | ||||
|     .info { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       padding: 30px; | ||||
|       padding-left: 0; | ||||
|       text-align: left; | ||||
|  | ||||
|       @include mobile { | ||||
|         padding: 0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     h1 { | ||||
|       color: $green; | ||||
|       width: 100%; | ||||
|       font-weight: 500; | ||||
|       line-height: 1.4; | ||||
|       font-size: 30px; | ||||
|       margin-top: 0; | ||||
|  | ||||
|       @include mobile { | ||||
|         font-size: 24px; | ||||
|         margin: 10px 0; | ||||
|         // padding: 30px 30px 30px 40px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .known-for { | ||||
|       color: rgba(255, 255, 255, 0.8); | ||||
|       font-size: 1.2rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .person__poster { | ||||
|     display: block; | ||||
|     position: absolute; | ||||
|     top: -130px; | ||||
|     left: -100px; | ||||
|     z-index: 1; | ||||
|     width: 1000px; | ||||
|     height: 500px; | ||||
|     transform: rotate(21deg); | ||||
|     background-color: #062541; | ||||
|  | ||||
|     @include mobile { | ||||
|       // top: -52vw; | ||||
|       top: -215px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| header { | ||||
|   $duration: 0.2s; | ||||
|   transition: height $duration ease; | ||||
|   position: relative; | ||||
|   background-color: transparent; | ||||
|   display: grid; | ||||
|   grid-template-columns: 1fr 1fr; | ||||
|   height: 350px; | ||||
|   z-index: 2; | ||||
|  | ||||
|   @include mobile { | ||||
|     height: 180px; | ||||
|   } | ||||
|  | ||||
|   .info { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     padding: 30px; | ||||
|     padding-left: 0; | ||||
|     text-align: left; | ||||
|  | ||||
|     @include mobile { | ||||
|       padding: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|     color: $green; | ||||
|     width: 100%; | ||||
|     font-weight: 500; | ||||
|     line-height: 1.4; | ||||
|     font-size: 30px; | ||||
|     margin-top: 0; | ||||
|  | ||||
|     @include mobile { | ||||
|       font-size: 24px; | ||||
|       margin: 10px 0; | ||||
|       // padding: 30px 30px 30px 40px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .known-for { | ||||
|     color: rgba(255, 255, 255, 0.8); | ||||
|     font-size: 1.2rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .person__poster { | ||||
|   display: block; | ||||
|   border-radius: 10px; | ||||
|   background-color: grey; | ||||
|   animation: pulse 1s infinite ease-in-out; | ||||
|  | ||||
|   @keyframes pulse { | ||||
|     0% { | ||||
|       background-color: rgba(165, 165, 165, 0.1); | ||||
|     } | ||||
|     50% { | ||||
|       background-color: rgba(165, 165, 165, 0.3); | ||||
|     } | ||||
|     100% { | ||||
|       background-color: rgba(165, 165, 165, 0.1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   > img { | ||||
|     margin: auto; | ||||
|     width: fit-content; | ||||
|     border-radius: 10px; | ||||
|     width: 100%; | ||||
|     background-color: grey; | ||||
|     animation: pulse 1s infinite ease-in-out; | ||||
|  | ||||
|     @include mobile { | ||||
|       max-width: 225px; | ||||
|     @keyframes pulse { | ||||
|       0% { | ||||
|         background-color: rgba(165, 165, 165, 0.1); | ||||
|       } | ||||
|       50% { | ||||
|         background-color: rgba(165, 165, 165, 0.3); | ||||
|       } | ||||
|       100% { | ||||
|         background-color: rgba(165, 165, 165, 0.1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     > img { | ||||
|       border-radius: 10px; | ||||
|       width: 100%; | ||||
|  | ||||
|       @include mobile { | ||||
|         max-width: 225px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										98
									
								
								src/components/profile/ChangePassword.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/components/profile/ChangePassword.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <h3 class="settings__header">Change password</h3> | ||||
|     <form class="form"> | ||||
|       <seasoned-input | ||||
|         placeholder="old password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="oldPassword" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         placeholder="new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="newPassword" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         placeholder="repeat new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="newPasswordRepeat" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-button @click="changePassword">change password</seasoned-button> | ||||
|     </form> | ||||
|     <seasoned-messages v-model:messages="messages" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref } from "vue"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type IErrorMessage from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   interface ResetPasswordPayload { | ||||
|     old_password: string; | ||||
|     new_password: string; | ||||
|   } | ||||
|  | ||||
|   const oldPassword: Ref<string> = ref(""); | ||||
|   const newPassword: Ref<string> = ref(""); | ||||
|   const newPasswordRepeat: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|  | ||||
|   function clearMessages() { | ||||
|     messages.value = []; | ||||
|   } | ||||
|  | ||||
|   function addWarningMessage(message: string, title?: string) { | ||||
|     messages.value.push({ message, title, type: "warning" }); | ||||
|   } | ||||
|  | ||||
|   function validate() { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!oldPassword.value || oldPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing old password!", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       if (!newPassword.value || newPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing new password!", "Validation error"); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       if (newPassword.value !== newPasswordRepeat.value) { | ||||
|         addWarningMessage( | ||||
|           "Password and password repeat do not match!", | ||||
|           "Validation error" | ||||
|         ); | ||||
|         return reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // TODO seasoned-api /user/password-reset | ||||
|   async function changePassword() { | ||||
|     try { | ||||
|       validate(); | ||||
|     } catch (error) { | ||||
|       console.log("not valid!"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const body: ResetPasswordPayload = { | ||||
|       old_password: oldPassword.value, | ||||
|       new_password: newPassword.value | ||||
|     }; | ||||
|     const options = {}; | ||||
|     // fetch() | ||||
|   } | ||||
| </script> | ||||
							
								
								
									
										120
									
								
								src/components/profile/LinkPlexAccount.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/components/profile/LinkPlexAccount.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <h3 class="settings__header">Plex account</h3> | ||||
|  | ||||
|     <div v-if="!plexId"> | ||||
|       <span class="info" | ||||
|         >Sign in to your plex account to get information about recently added | ||||
|         movies and to see your watch history</span | ||||
|       > | ||||
|  | ||||
|       <form class="form"> | ||||
|         <seasoned-input | ||||
|           placeholder="plex username" | ||||
|           type="email" | ||||
|           v-model="username" | ||||
|         /> | ||||
|         <seasoned-input | ||||
|           placeholder="plex password" | ||||
|           type="password" | ||||
|           v-model="password" | ||||
|           @enter="authenticatePlex" | ||||
|         > | ||||
|         </seasoned-input> | ||||
|  | ||||
|         <seasoned-button @click="authenticatePlex" | ||||
|           >link plex account</seasoned-button | ||||
|         > | ||||
|       </form> | ||||
|     </div> | ||||
|  | ||||
|     <div v-else> | ||||
|       <span class="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 v-model:messages="messages" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineEmits } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import seasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import { linkPlexAccount, unlinkPlexAccount } from "../../api"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import type IErrorMessage from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "reload"); | ||||
|   } | ||||
|  | ||||
|   const username: Ref<string> = ref(""); | ||||
|   const password: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const loggedIn: ComputedRef<boolean> = computed( | ||||
|     () => store.getters["user/loggedIn"] | ||||
|   ); | ||||
|   const plexId: ComputedRef<boolean> = computed( | ||||
|     () => store.getters["user/plexId"] | ||||
|   ); | ||||
|   const settings: ComputedRef<boolean> = computed( | ||||
|     () => store.getters["user/settings"] | ||||
|   ); | ||||
|  | ||||
|   async function authenticatePlex() { | ||||
|     let username = this.plexUsername; | ||||
|     let password = this.plexPassword; | ||||
|  | ||||
|     const { success, message } = await linkPlexAccount( | ||||
|       username.value, | ||||
|       password.value | ||||
|     ); | ||||
|  | ||||
|     if (success) { | ||||
|       username.value = ""; | ||||
|       password.value = ""; | ||||
|       emit("reload"); | ||||
|     } | ||||
|  | ||||
|     messages.value.push({ | ||||
|       type: success ? "success" : "error", | ||||
|       title: success ? "Authenticated with plex" : "Something went wrong", | ||||
|       message: message | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async function unauthenticatePlex() { | ||||
|     const response = await unlinkPlexAccount(); | ||||
|  | ||||
|     if (response?.success) { | ||||
|       emit("reload"); | ||||
|     } | ||||
|  | ||||
|     messages.value.push({ | ||||
|       type: response.success ? "success" : "error", | ||||
|       title: response.success | ||||
|         ? "Unlinked plex account " | ||||
|         : "Something went wrong", | ||||
|       message: response.message | ||||
|     }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   .info { | ||||
|     display: block; | ||||
|     margin-bottom: 25px; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										6
									
								
								src/components/torrent/ActiveTorrents.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/components/torrent/ActiveTorrents.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <template> | ||||
|   <code | ||||
|     >Monitor active torrents requested. Requires authentication with owners plex | ||||
|     library!</code | ||||
|   > | ||||
| </template> | ||||
							
								
								
									
										157
									
								
								src/components/torrent/TorrentSearchResults.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/components/torrent/TorrentSearchResults.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| <template> | ||||
|   <div class="container" v-if="query?.length"> | ||||
|     <h2 class="torrent-header-text"> | ||||
|       Searching for: <span class="query">{{ query }}</span> | ||||
|     </h2> | ||||
|  | ||||
|     <loader v-if="loading" type="section" /> | ||||
|     <div v-else> | ||||
|       <div v-if="torrents.length > 0" class="torrent-table"> | ||||
|         <torrent-table :torrents="torrents" @magnet="addTorrent" /> | ||||
|  | ||||
|         <slot /> | ||||
|       </div> | ||||
|  | ||||
|       <div v-else class="no-results"> | ||||
|         <h2>No results found</h2> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, inject, defineProps } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { sortableSize } from "../../utils"; | ||||
|   import { searchTorrents, addMagnet } from "../../api"; | ||||
|  | ||||
|   import Loader from "@/components/ui/Loader.vue"; | ||||
|   import TorrentTable from "@/components/torrent/TorrentTable.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdb_id?: number; | ||||
|   } | ||||
|  | ||||
|   const loading: Ref<boolean> = ref(true); | ||||
|   const torrents: Ref<ITorrent[]> = ref([]); | ||||
|   const release_types: Ref<string[]> = ref(["all"]); | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|   const notifications: { | ||||
|     info; | ||||
|     success; | ||||
|     error; | ||||
|   } = inject("notifications"); | ||||
|  | ||||
|   fetchTorrents(); | ||||
|  | ||||
|   function fetchTorrents() { | ||||
|     loading.value = true; | ||||
|  | ||||
|     searchTorrents(props.query) | ||||
|       .then(torrentResponse => (torrents.value = torrentResponse?.results)) | ||||
|       .then(() => updateResultCountDisplay()) | ||||
|       .finally(() => (loading.value = false)); | ||||
|   } | ||||
|  | ||||
|   function updateResultCountDisplay() { | ||||
|     store.dispatch("torrentModule/setResults", torrents.value); | ||||
|     store.dispatch( | ||||
|       "torrentModule/setResultCount", | ||||
|       torrents.value?.length || -1 | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function addTorrent(torrent: ITorrent) { | ||||
|     const { name, magnet } = torrent; | ||||
|  | ||||
|     notifications.info({ | ||||
|       title: "Adding torrent 🧲", | ||||
|       description: props.query, | ||||
|       timeout: 3000 | ||||
|     }); | ||||
|  | ||||
|     addMagnet(magnet, name, props.tmdb_id) | ||||
|       .then(resp => { | ||||
|         notifications.success({ | ||||
|           title: "Torrent added 🎉", | ||||
|           description: props.query, | ||||
|           timeout: 3000 | ||||
|         }); | ||||
|       }) | ||||
|       .catch(resp => { | ||||
|         console.log("Error while adding torrent:", resp?.data); | ||||
|         notifications.error({ | ||||
|           title: "Failed to add torrent 🙅♀️", | ||||
|           description: "Check console for more info", | ||||
|           timeout: 3000 | ||||
|         }); | ||||
|       }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/elements"; | ||||
|  | ||||
|   h2 { | ||||
|     font-size: 20px; | ||||
|   } | ||||
|  | ||||
|   .toggle { | ||||
|     max-width: unset !important; | ||||
|     margin: 1rem 0; | ||||
|   } | ||||
|  | ||||
|   .container { | ||||
|     background-color: $background-color; | ||||
|   } | ||||
|  | ||||
|   .no-results { | ||||
|     display: flex; | ||||
|     padding-bottom: 2rem; | ||||
|     justify-content: center; | ||||
|     flex-direction: column; | ||||
|     width: 100%; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .torrent-header-text { | ||||
|     font-weight: 300; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 20px; | ||||
|     color: var(--text-color); | ||||
|     text-align: center; | ||||
|     margin: 0; | ||||
|  | ||||
|     .query { | ||||
|       font-weight: 500; | ||||
|       white-space: pre; | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       text-align: left; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .download { | ||||
|     &__icon { | ||||
|       fill: $text-color-70; | ||||
|       height: 1.2rem; | ||||
|  | ||||
|       &:hover { | ||||
|         fill: $text-color; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.active &__icon { | ||||
|       fill: $green; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										250
									
								
								src/components/torrent/TorrentTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								src/components/torrent/TorrentTable.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | ||||
| <template> | ||||
|   <table> | ||||
|     <thead class="table__header noselect"> | ||||
|       <tr> | ||||
|         <th | ||||
|           v-for="column in columns" | ||||
|           :key="column" | ||||
|           @click="sortTable(column)" | ||||
|           :class="column === selectedColumn ? 'active' : null" | ||||
|         > | ||||
|           {{ column }} | ||||
|           <span v-if="prevCol === column && direction">↑</span> | ||||
|           <span v-if="prevCol === column && !direction">↓</span> | ||||
|         </th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|  | ||||
|     <tbody> | ||||
|       <tr | ||||
|         v-for="torrent in torrents" | ||||
|         class="table__content" | ||||
|         :key="torrent.magnet" | ||||
|       > | ||||
|         <td @click="expand($event, torrent.name)">{{ torrent.name }}</td> | ||||
|         <td @click="expand($event, torrent.name)">{{ torrent.seed }}</td> | ||||
|         <td @click="expand($event, torrent.name)">{{ torrent.size }}</td> | ||||
|         <td @click="() => emit('magnet', torrent)" class="download"> | ||||
|           <IconMagnet /> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import IconMagnet from "@/icons/IconMagnet.vue"; | ||||
|   import { sortableSize } from "../../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
|     torrents: Array<ITorrent>; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "magnet", torrent: ITorrent): void; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const columns: string[] = ["name", "seed", "size", "add"]; | ||||
|  | ||||
|   const torrents: Ref<ITorrent[]> = ref(props.torrents); | ||||
|   const direction: Ref<boolean> = ref(false); | ||||
|   const selectedColumn: Ref<string> = ref(columns[0]); | ||||
|   const prevCol: Ref<string> = ref(""); | ||||
|  | ||||
|   function expand(event: MouseEvent, text: string) { | ||||
|     const elementClicked = event.target as HTMLElement; | ||||
|     const tableRow = elementClicked.parentElement; | ||||
|     const scopedStyleDataVariable = Object.keys(tableRow.dataset)[0]; | ||||
|  | ||||
|     const existingExpandedElement = | ||||
|       document.getElementsByClassName("expanded")[0]; | ||||
|     const clickedSameTwice = | ||||
|       existingExpandedElement?.previousSibling?.isEqualNode(tableRow); | ||||
|  | ||||
|     if (existingExpandedElement) { | ||||
|       existingExpandedElement.remove(); | ||||
|  | ||||
|       // Clicked the same element twice, remove and return | ||||
|       // not recreate and collapse | ||||
|       if (clickedSameTwice) return; | ||||
|     } | ||||
|  | ||||
|     const expandedRow = document.createElement("tr"); | ||||
|     const expandedCol = document.createElement("td"); | ||||
|     expandedRow.dataset[scopedStyleDataVariable] = ""; | ||||
|     expandedCol.dataset[scopedStyleDataVariable] = ""; | ||||
|     expandedRow.className = "expanded"; | ||||
|     expandedCol.innerText = text; | ||||
|     expandedCol.colSpan = 4; | ||||
|  | ||||
|     expandedRow.appendChild(expandedCol); | ||||
|     tableRow.insertAdjacentElement("afterend", expandedRow); | ||||
|   } | ||||
|  | ||||
|   function sortTable(col, sameDirection = false) { | ||||
|     if (prevCol.value === col && sameDirection === false) { | ||||
|       direction.value = !direction.value; | ||||
|     } | ||||
|  | ||||
|     if (col === "name") sortName(); | ||||
|     else if (col === "seed") sortSeed(); | ||||
|     else if (col === "size") sortSize(); | ||||
|  | ||||
|     prevCol.value = col; | ||||
|   } | ||||
|  | ||||
|   function sortName() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1)); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function sortSeed() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(a.seed) - parseInt(b.seed) | ||||
|       ); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(b.seed) - parseInt(a.seed) | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function sortSize() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => sortableSize(a.size) - sortableSize(b.size) | ||||
|       ); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => sortableSize(b.size) - sortableSize(a.size) | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/elements"; | ||||
|  | ||||
|   table { | ||||
|     border-spacing: 0; | ||||
|     margin-top: 0.5rem; | ||||
|     width: 100%; | ||||
|     // border-collapse: collapse; | ||||
|     border-radius: 0.5rem; | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   th, | ||||
|   td { | ||||
|     border: 0.5px solid var(--background-color-40); | ||||
|     @include mobile { | ||||
|       white-space: nowrap; | ||||
|       padding: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   thead { | ||||
|     position: relative; | ||||
|     user-select: none; | ||||
|     -webkit-user-select: none; | ||||
|     color: var(--table-header-text-color); | ||||
|     text-transform: uppercase; | ||||
|     cursor: pointer; | ||||
|     background-color: var(--table-background-color); | ||||
|     // background-color: black; | ||||
|     // color: var(--color-green); | ||||
|     letter-spacing: 0.8px; | ||||
|     font-size: 1rem; | ||||
|  | ||||
|     th:last-of-type { | ||||
|       padding-right: 0.4rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   tbody { | ||||
|     // first column | ||||
|     tr td:first-of-type { | ||||
|       position: relative; | ||||
|       padding: 0 0.3rem; | ||||
|       cursor: default; | ||||
|       word-break: break-all; | ||||
|       border-left: 1px solid var(--table-background-color); | ||||
|  | ||||
|       @include mobile { | ||||
|         max-width: 40vw; | ||||
|         overflow-x: hidden; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // all columns except first | ||||
|     tr td:not(td:first-of-type) { | ||||
|       text-align: center; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
|  | ||||
|     // last column | ||||
|     tr td:last-of-type { | ||||
|       vertical-align: middle; | ||||
|       cursor: pointer; | ||||
|       border-right: 1px solid var(--table-background-color); | ||||
|  | ||||
|       svg { | ||||
|         width: 21px; | ||||
|         display: block; | ||||
|         margin: auto; | ||||
|         padding: 0.3rem 0; | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // alternate background color per row | ||||
|     tr:nth-child(even) { | ||||
|       background-color: var(--background-70); | ||||
|     } | ||||
|  | ||||
|     // last element rounded corner border | ||||
|     tr:last-of-type { | ||||
|       td { | ||||
|         border-bottom: 1px solid var(--table-background-color); | ||||
|       } | ||||
|  | ||||
|       td:first-of-type { | ||||
|         border-bottom-left-radius: 0.5rem; | ||||
|       } | ||||
|  | ||||
|       td:last-of-type { | ||||
|         border-bottom-right-radius: 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .expanded { | ||||
|     padding: 0.25rem 1rem; | ||||
|     max-width: 100%; | ||||
|     border-left: 1px solid $text-color; | ||||
|     border-right: 1px solid $text-color; | ||||
|     border-bottom: 1px solid $text-color; | ||||
|  | ||||
|     td { | ||||
|       white-space: normal; | ||||
|       word-break: break-all; | ||||
|       padding: 0.5rem 0.15rem; | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										86
									
								
								src/components/torrent/TruncatedTorrentResults.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/components/torrent/TruncatedTorrentResults.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <torrent-search-results | ||||
|       :query="query" | ||||
|       :tmdb_id="tmdb_id" | ||||
|       :class="{ truncated: truncated }" | ||||
|       ><div | ||||
|         v-if="truncated" | ||||
|         class="load-more" | ||||
|         role="button" | ||||
|         @click="truncated = false" | ||||
|       > | ||||
|         <icon-arrow-down /> | ||||
|       </div> | ||||
|     </torrent-search-results> | ||||
|  | ||||
|     <div class="edit-query-btn-container"> | ||||
|       <seasonedButton @click="openInTorrentPage" | ||||
|         >View on torrent page</seasonedButton | ||||
|       > | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, inject, defineProps } from "vue"; | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import IconArrowDown from "@/icons/IconArrowDown.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdb_id?: number; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|   const truncated: Ref<boolean> = ref(true); | ||||
|  | ||||
|   function openInTorrentPage() { | ||||
|     if (!props.query?.length) { | ||||
|       router.push("/torrents"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     router.push({ path: "/torrents", query: { query: props.query } }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   :global(.truncated .torrent-table) { | ||||
|     position: relative; | ||||
|     max-height: 500px; | ||||
|     overflow-y: hidden; | ||||
|   } | ||||
|  | ||||
|   .load-more { | ||||
|     position: absolute; | ||||
|     display: flex; | ||||
|     align-items: flex-end; | ||||
|     justify-content: center; | ||||
|     bottom: 0rem; | ||||
|     width: 100%; | ||||
|     height: 3rem; | ||||
|     cursor: pointer; | ||||
|     background: linear-gradient( | ||||
|       to top, | ||||
|       var(--background-color) 20%, | ||||
|       var(--background-0) 100% | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   svg { | ||||
|     height: 30px; | ||||
|     fill: var(--text-color); | ||||
|   } | ||||
|  | ||||
|   .edit-query-btn-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     padding: 1rem; | ||||
|   } | ||||
| </style> | ||||
| @@ -4,49 +4,43 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   data() { | ||||
|     return { | ||||
|       darkmode: this.systemDarkModeEnabled() | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleDarkmode() { | ||||
|       this.darkmode = !this.darkmode; | ||||
|       document.body.className = this.darkmode ? "dark" : "light"; | ||||
|     }, | ||||
|     systemDarkModeEnabled() { | ||||
|       const computedStyle = window.getComputedStyle(document.body); | ||||
|       if (computedStyle["colorScheme"] != null) { | ||||
|         return computedStyle.colorScheme.includes("dark"); | ||||
|       } | ||||
|       return false; | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     darkmodeToggleIcon() { | ||||
|       return this.darkmode ? "🌝" : "🌚"; | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|  | ||||
|   let darkmode = ref(systemDarkModeEnabled()); | ||||
|   const darkmodeToggleIcon = computed(() => { | ||||
|     return darkmode.value ? "🌝" : "🌚"; | ||||
|   }); | ||||
|  | ||||
|   function toggleDarkmode() { | ||||
|     darkmode.value = !darkmode.value; | ||||
|     document.body.className = darkmode.value ? "dark" : "light"; | ||||
|   } | ||||
|  | ||||
|   function systemDarkModeEnabled() { | ||||
|     const computedStyle = window.getComputedStyle(document.body); | ||||
|     if (computedStyle["colorScheme"] != null) { | ||||
|       return computedStyle.colorScheme.includes("dark"); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .darkToggle { | ||||
|   height: 25px; | ||||
|   width: 25px; | ||||
|   cursor: pointer; | ||||
|   position: fixed; | ||||
|   margin-bottom: 1.5rem; | ||||
|   margin-right: 2px; | ||||
|   bottom: 0; | ||||
|   right: 0; | ||||
|   z-index: 10; | ||||
|   .darkToggle { | ||||
|     height: 25px; | ||||
|     width: 25px; | ||||
|     cursor: pointer; | ||||
|     position: fixed; | ||||
|     margin-bottom: 1.5rem; | ||||
|     margin-right: 2px; | ||||
|     bottom: 0; | ||||
|     right: 0; | ||||
|     z-index: 10; | ||||
|  | ||||
|   -webkit-user-select: none; | ||||
|   -moz-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
| } | ||||
|     -webkit-user-select: none; | ||||
|     -moz-user-select: none; | ||||
|     -ms-user-select: none; | ||||
|     user-select: none; | ||||
|   } | ||||
| </style> | ||||
|   | ||||
| @@ -10,73 +10,76 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapGetters, mapActions } from "vuex"; | ||||
| <script setup lang="ts"> | ||||
|   import { computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|  | ||||
| export default { | ||||
|   computed: { ...mapGetters("hamburger", ["isOpen"]) }, | ||||
|   methods: { ...mapActions("hamburger", ["toggle"]) } | ||||
| }; | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const isOpen = computed(() => store.getters["hamburger/isOpen"]); | ||||
|   const toggle = () => { | ||||
|     store.dispatch("hamburger/toggle"); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .nav__hamburger { | ||||
|   display: block; | ||||
|   position: relative; | ||||
|   width: var(--header-size); | ||||
|   height: var(--header-size); | ||||
|   cursor: pointer; | ||||
|   border-left: 1px solid var(--background-color); | ||||
|   background-color: var(--background-color-secondary); | ||||
|   .nav__hamburger { | ||||
|     display: block; | ||||
|     position: relative; | ||||
|     width: var(--header-size); | ||||
|     height: var(--header-size); | ||||
|     cursor: pointer; | ||||
|     border-left: 1px solid var(--background-color); | ||||
|     background-color: var(--background-color-secondary); | ||||
|  | ||||
|   @include tablet-min { | ||||
|     display: none; | ||||
|   } | ||||
|     @include tablet-min { | ||||
|       display: none; | ||||
|     } | ||||
|  | ||||
|   .bar { | ||||
|     position: absolute; | ||||
|     width: 23px; | ||||
|     height: 1px; | ||||
|     background-color: var(--text-color-70); | ||||
|     transition: all 300ms ease; | ||||
|     &:nth-child(1) { | ||||
|       left: 16px; | ||||
|       top: 17px; | ||||
|     } | ||||
|     &:nth-child(2) { | ||||
|       left: 16px; | ||||
|       top: 25px; | ||||
|       &:after { | ||||
|         content: ""; | ||||
|         position: absolute; | ||||
|         left: 0px; | ||||
|         top: 0px; | ||||
|         width: 23px; | ||||
|         height: 1px; | ||||
|         transition: all 300ms ease; | ||||
|       } | ||||
|     } | ||||
|     &:nth-child(3) { | ||||
|       right: 15px; | ||||
|       top: 33px; | ||||
|     } | ||||
|   } | ||||
|   &.open { | ||||
|     .bar { | ||||
|       &:nth-child(1), | ||||
|       &:nth-child(3) { | ||||
|         width: 0; | ||||
|       position: absolute; | ||||
|       width: 23px; | ||||
|       height: 1px; | ||||
|       background-color: var(--text-color-70); | ||||
|       transition: all 300ms ease; | ||||
|       &:nth-child(1) { | ||||
|         left: 16px; | ||||
|         top: 17px; | ||||
|       } | ||||
|       &:nth-child(2) { | ||||
|         transform: rotate(-45deg); | ||||
|         left: 16px; | ||||
|         top: 25px; | ||||
|         &:after { | ||||
|           content: ""; | ||||
|           position: absolute; | ||||
|           left: 0px; | ||||
|           top: 0px; | ||||
|           width: 23px; | ||||
|           height: 1px; | ||||
|           transition: all 300ms ease; | ||||
|         } | ||||
|       } | ||||
|       &:nth-child(2):after { | ||||
|         transform: rotate(-90deg); | ||||
|         background-color: var(--text-color-70); | ||||
|       &:nth-child(3) { | ||||
|         right: 15px; | ||||
|         top: 33px; | ||||
|       } | ||||
|     } | ||||
|     &.open { | ||||
|       .bar { | ||||
|         &:nth-child(1), | ||||
|         &:nth-child(3) { | ||||
|           width: 0; | ||||
|         } | ||||
|         &:nth-child(2) { | ||||
|           transform: rotate(-45deg); | ||||
|         } | ||||
|         &:nth-child(2):after { | ||||
|           transform: rotate(-90deg); | ||||
|           background-color: var(--text-color-70); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,48 +1,72 @@ | ||||
| <template> | ||||
|   <div class="loader"> | ||||
|   <div :class="`loader type-${type}`"> | ||||
|     <i class="loader--icon"> | ||||
|       <i class="loader--icon-spinner" /> | ||||
|     </i> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|   <!-- | ||||
|   TODO: fetch and display movie facts after 1.5 seconds while loading? | ||||
|    | ||||
|  | ||||
| --></template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   enum LoaderHeightType { | ||||
|     Page = "page", | ||||
|     Section = "section" | ||||
|   } | ||||
|  | ||||
|   interface Props { | ||||
|     type?: LoaderHeightType; | ||||
|   } | ||||
|  | ||||
|   const { type = LoaderHeightType.Page } = defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
| .loader { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
|   height: 30vh; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   .loader { | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|     height: 30vh; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|  | ||||
|   &--icon { | ||||
|     border: 2px solid $text-color-70; | ||||
|     border-radius: 50%; | ||||
|     display: block; | ||||
|     height: 40px; | ||||
|     position: absolute; | ||||
|     width: 40px; | ||||
|     &.type-section { | ||||
|       height: 15vh; | ||||
|     } | ||||
|  | ||||
|     &-spinner { | ||||
|     &--icon { | ||||
|       border: 2px solid $text-color-70; | ||||
|       border-radius: 50%; | ||||
|       display: block; | ||||
|       animation: load 1s linear infinite; | ||||
|       height: 35px; | ||||
|       width: 35px; | ||||
|       &:after { | ||||
|         border: 7px solid $green-90; | ||||
|         border-radius: 50%; | ||||
|         content: ""; | ||||
|         left: 8px; | ||||
|         position: absolute; | ||||
|         top: 22px; | ||||
|       height: 40px; | ||||
|       position: absolute; | ||||
|       width: 40px; | ||||
|  | ||||
|       &-spinner { | ||||
|         display: block; | ||||
|         animation: load 1s linear infinite; | ||||
|         height: 35px; | ||||
|         width: 35px; | ||||
|         &:after { | ||||
|           border: 7px solid $green-90; | ||||
|           border-radius: 50%; | ||||
|           content: ""; | ||||
|           left: 8px; | ||||
|           position: absolute; | ||||
|           top: 22px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     @keyframes load { | ||||
|       100% { | ||||
|         transform: rotate(360deg); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   @keyframes load { | ||||
|     100% { | ||||
|       transform: rotate(360deg); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -9,26 +9,18 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     count: { | ||||
|       type: Number, | ||||
|       default: 1, | ||||
|       require: false | ||||
|     }, | ||||
|     lineClass: { | ||||
|       type: String, | ||||
|       default: "" | ||||
|     }, | ||||
|     top: { | ||||
|       type: Number, | ||||
|       default: 0 | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     count?: Number; | ||||
|     lineClass?: String; | ||||
|     top?: Number; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   const { count = 1, lineClass = "", top = 0 } = defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/loading-placeholder"; | ||||
|   @import "src/scss/loading-placeholder"; | ||||
| </style> | ||||
|   | ||||
| @@ -8,77 +8,71 @@ | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   name: "seasonedButton", | ||||
|   props: { | ||||
|     active: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|       required: false | ||||
|     }, | ||||
|     fullWidth: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     emit() { | ||||
|       this.$emit("click"); | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     active?: Boolean; | ||||
|     fullWidth?: Boolean; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "click"); | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| button { | ||||
|   display: inline-block; | ||||
|   border: 1px solid $text-color; | ||||
|   font-size: 11px; | ||||
|   font-weight: 300; | ||||
|   line-height: 1.5; | ||||
|   letter-spacing: 0.5px; | ||||
|   text-transform: uppercase; | ||||
|   min-height: 45px; | ||||
|   padding: 5px 10px 4px 10px; | ||||
|   margin: 0; | ||||
|   margin-right: 0.3rem; | ||||
|   color: $text-color; | ||||
|   background: $background-color-secondary; | ||||
|   cursor: pointer; | ||||
|   outline: none; | ||||
|   transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease; | ||||
|   button { | ||||
|     display: inline-block; | ||||
|     border: 1px solid $text-color; | ||||
|     font-size: 11px; | ||||
|     font-weight: 300; | ||||
|     line-height: 1.5; | ||||
|     letter-spacing: 0.5px; | ||||
|     text-transform: uppercase; | ||||
|     min-height: 45px; | ||||
|     padding: 5px 10px 4px 10px; | ||||
|     margin: 0; | ||||
|     margin-right: 0.3rem; | ||||
|     color: $text-color; | ||||
|     background: $background-color-secondary; | ||||
|     cursor: pointer; | ||||
|     outline: none; | ||||
|     transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease; | ||||
|  | ||||
|   @include desktop { | ||||
|     font-size: 0.8rem; | ||||
|     padding: 6px 20px 5px 20px; | ||||
|   } | ||||
|  | ||||
|   &.fullwidth { | ||||
|     font-size: 14px; | ||||
|     width: 40%; | ||||
|  | ||||
|     @include mobile { | ||||
|       width: 60%; | ||||
|     @include desktop { | ||||
|       font-size: 0.8rem; | ||||
|       padding: 6px 20px 5px 20px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:focus, | ||||
|   &:active, | ||||
|   &.active { | ||||
|     background: $text-color; | ||||
|     color: $background-color; | ||||
|   } | ||||
|     &.fullwidth { | ||||
|       font-size: 14px; | ||||
|       width: 40%; | ||||
|  | ||||
|   @media (hover: hover) { | ||||
|     &:hover { | ||||
|       @include mobile { | ||||
|         width: 60%; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus, | ||||
|     &:active, | ||||
|     &.active { | ||||
|       background: $text-color; | ||||
|       color: $background-color; | ||||
|     } | ||||
|  | ||||
|     @media (hover: hover) { | ||||
|       &:hover { | ||||
|         background: $text-color; | ||||
|         color: $background-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,134 +1,139 @@ | ||||
| <template> | ||||
|   <div class="group" :class="{ completed: value, focus }"> | ||||
|   <div class="group" :class="{ completed: modelValue, focus }"> | ||||
|     <component :is="inputIcon" v-if="inputIcon" /> | ||||
|  | ||||
|     <input | ||||
|       class="input" | ||||
|       :type="tempType || type" | ||||
|       @input="handleInput" | ||||
|       v-model="inputValue" | ||||
|       :type="toggledType || type" | ||||
|       :placeholder="placeholder" | ||||
|       @keyup.enter="event => $emit('enter', event)" | ||||
|       :value="modelValue" | ||||
|       @input="handleInput" | ||||
|       @keyup.enter="event => emit('enter', event)" | ||||
|       @focus="focus = true" | ||||
|       @blur="focus = false" | ||||
|     /> | ||||
|  | ||||
|     <i | ||||
|       v-if="value && type === 'password'" | ||||
|       v-if="modelValue && type === 'password'" | ||||
|       @click="toggleShowPassword" | ||||
|       @keydown.enter="toggleShowPassword" | ||||
|       class="show noselect" | ||||
|       tabindex="0" | ||||
|       >{{ tempType == "password" ? "show" : "hide" }}</i | ||||
|       >{{ toggledType == "password" ? "show" : "hide" }}</i | ||||
|     > | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import IconKey from "../../icons/IconKey"; | ||||
| import IconEmail from "../../icons/IconEmail"; | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, defineEmits } from "vue"; | ||||
|   import IconKey from "@/icons/IconKey.vue"; | ||||
|   import IconEmail from "@/icons/IconEmail.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
| export default { | ||||
|   components: { IconKey, IconEmail }, | ||||
|   props: { | ||||
|     placeholder: { type: String }, | ||||
|     type: { type: String, default: "text" }, | ||||
|     value: { type: String, default: undefined } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       inputValue: this.value || undefined, | ||||
|       tempType: this.type, | ||||
|       focus: false | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     inputIcon() { | ||||
|       if (this.type === "password") return IconKey; | ||||
|       if (this.type === "email") return IconEmail; | ||||
|       return false; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     handleInput(event) { | ||||
|       if (this.value !== undefined) { | ||||
|         this.$emit("update:value", this.inputValue); | ||||
|       } else { | ||||
|         this.$emit("change", this.inputValue, event); | ||||
|       } | ||||
|     }, | ||||
|     toggleShowPassword() { | ||||
|       if (this.tempType === "text") { | ||||
|         this.tempType = "password"; | ||||
|       } else { | ||||
|         this.tempType = "text"; | ||||
|       } | ||||
|   interface Props { | ||||
|     modelValue: string; | ||||
|     placeholder: string; | ||||
|     type?: string; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "change"); | ||||
|     (e: "enter"); | ||||
|     (e: "update:modelValue", value: string); | ||||
|   } | ||||
|  | ||||
|   const { placeholder, type = "text", modelValue } = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const toggledType: Ref<string> = ref(type); | ||||
|   const focus: Ref<boolean> = ref(false); | ||||
|  | ||||
|   const inputIcon = computed(() => { | ||||
|     if (type === "password") return IconKey; | ||||
|     if (type === "email") return IconEmail; | ||||
|     if (type === "torrents") return IconBinoculars; | ||||
|     return false; | ||||
|   }); | ||||
|  | ||||
|   function handleInput(event: KeyboardEvent) { | ||||
|     const target = event?.target as HTMLInputElement; | ||||
|     if (!target) return; | ||||
|  | ||||
|     emit("update:modelValue", target?.value); | ||||
|   } | ||||
|  | ||||
|   // Could we move this to component that injects ?? | ||||
|   function toggleShowPassword() { | ||||
|     if (toggledType.value === "text") { | ||||
|       toggledType.value = "password"; | ||||
|     } else { | ||||
|       toggledType.value = "text"; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .group { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
|   position: relative; | ||||
|   max-width: 35rem; | ||||
|   border: 1px solid var(--text-color-50); | ||||
|   background-color: var(--background-color-secondary); | ||||
|   .group { | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|     position: relative; | ||||
|     max-width: 35rem; | ||||
|     border: 1px solid var(--text-color-50); | ||||
|     background-color: var(--background-color-secondary); | ||||
|  | ||||
|   &.completed, | ||||
|   &.focus, | ||||
|   &:hover, | ||||
|   &:focus { | ||||
|     border-color: var(--text-color); | ||||
|     &.completed, | ||||
|     &.focus, | ||||
|     &:hover, | ||||
|     &:focus { | ||||
|       border-color: var(--text-color); | ||||
|  | ||||
|       svg { | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     svg { | ||||
|       fill: var(--text-color); | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       fill: var(--text-color-50); | ||||
|       pointer-events: none; | ||||
|       margin-top: 10px; | ||||
|       margin-left: 10px; | ||||
|       z-index: 8; | ||||
|     } | ||||
|  | ||||
|     input { | ||||
|       width: 100%; | ||||
|       padding: 10px; | ||||
|       outline: none; | ||||
|       background-color: var(--background-color-secondary); | ||||
|       color: var(--text-color); | ||||
|       font-weight: 100; | ||||
|       font-size: 1.2rem; | ||||
|       margin: 0; | ||||
|       z-index: 3; | ||||
|       border: none; | ||||
|  | ||||
|       border-radius: 0; | ||||
|       -webkit-appearance: none; | ||||
|     } | ||||
|  | ||||
|     .show { | ||||
|       position: absolute; | ||||
|       display: grid; | ||||
|       place-items: center; | ||||
|       right: 20px; | ||||
|       z-index: 11; | ||||
|       margin: auto 0; | ||||
|       height: 100%; | ||||
|       font-size: 0.9rem; | ||||
|       cursor: pointer; | ||||
|       color: var(--text-color-50); | ||||
|       -webkit-user-select: none; | ||||
|       user-select: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   svg { | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     fill: var(--text-color-50); | ||||
|     pointer-events: none; | ||||
|     margin-top: 10px; | ||||
|     margin-left: 10px; | ||||
|     z-index: 8; | ||||
|   } | ||||
|  | ||||
|   input { | ||||
|     width: 100%; | ||||
|     padding: 10px; | ||||
|     outline: none; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     color: var(--text-color); | ||||
|     font-weight: 100; | ||||
|     font-size: 1.2rem; | ||||
|     margin: 0; | ||||
|     z-index: 3; | ||||
|     border: none; | ||||
|  | ||||
|     border-radius: 0; | ||||
|     -webkit-appearance: none; | ||||
|   } | ||||
|  | ||||
|   .show { | ||||
|     position: absolute; | ||||
|     display: grid; | ||||
|     place-items: center; | ||||
|     right: 20px; | ||||
|     z-index: 11; | ||||
|     margin: auto 0; | ||||
|     height: 100%; | ||||
|     font-size: 0.9rem; | ||||
|     cursor: pointer; | ||||
|     color: var(--text-color-50); | ||||
|     -webkit-user-select: none; | ||||
|     user-select: none; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| <template> | ||||
|   <transition-group name="fade"> | ||||
|     <div | ||||
|       class="message" | ||||
|       v-for="(message, index) in reversedMessages" | ||||
|       class="card" | ||||
|       v-for="(message, index) in messages" | ||||
|       :key="`${index}-${message.title}-${message.type}}`" | ||||
|       :class="message.type || 'warning'" | ||||
|     > | ||||
|       <span class="pinstripe"></span> | ||||
|       <div> | ||||
|       <div class="content"> | ||||
|         <h2 class="title"> | ||||
|           {{ message.title || defaultTitles[message.type] }} | ||||
|         </h2> | ||||
| @@ -16,150 +16,149 @@ | ||||
|         }}</span> | ||||
|       </div> | ||||
|  | ||||
|       <button class="dismiss" @click="clicked(message)">X</button> | ||||
|       <button class="dismiss" @click="dismiss(index)">X</button> | ||||
|     </div> | ||||
|   </transition-group> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     messages: { | ||||
|       required: true, | ||||
|       type: Array | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       defaultTitles: { | ||||
|         error: "Unexpected error", | ||||
|         warning: "Something went wrong", | ||||
|         undefined: "Something went wrong" | ||||
|       }, | ||||
|       localMessages: [...this.messages] | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     reversedMessages() { | ||||
|       return [...this.messages].reverse(); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     clicked(e) { | ||||
|       const removedMessage = [...this.messages].filter(mes => mes !== e); | ||||
|       this.$emit("update:messages", removedMessage); | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface IErrorMessage { | ||||
|     title: string; | ||||
|     message: string; | ||||
|     type: "error" | "success" | "warning"; | ||||
|   } | ||||
|  | ||||
|   interface Props { | ||||
|     messages: IErrorMessage[]; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:messages", messages: IErrorMessage[]); | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const defaultTitles = { | ||||
|     error: "Unexpected error", | ||||
|     warning: "Something went wrong", | ||||
|     success: "", | ||||
|     undefined: "Something went wrong" | ||||
|   }; | ||||
|  | ||||
|   function dismiss(index: number) { | ||||
|     props.messages.splice(index, 1); | ||||
|     emit("update:messages", [...props.messages]); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
| @import "src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .fade-enter-active { | ||||
|   transition: opacity 0.4s; | ||||
| } | ||||
| .fade-leave-active { | ||||
|   transition: opacity 0.1s; | ||||
| } | ||||
| .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | ||||
|   opacity: 0; | ||||
| } | ||||
|   .fade-active { | ||||
|     transition: opacity 0.4s; | ||||
|   } | ||||
|   .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | ||||
|     opacity: 0; | ||||
|   } | ||||
|  | ||||
| .message { | ||||
|   width: 100%; | ||||
|   max-width: 35rem; | ||||
|  | ||||
|   display: flex; | ||||
|   margin-top: 1rem; | ||||
|   margin-bottom: 1rem; | ||||
|   color: $text-color-70; | ||||
|  | ||||
|   > div { | ||||
|     margin: 10px 24px; | ||||
|   .card { | ||||
|     width: 100%; | ||||
|   } | ||||
|     max-width: 35rem; | ||||
|  | ||||
|   .title { | ||||
|     font-weight: 300; | ||||
|     letter-spacing: 0.25px; | ||||
|     margin: 0; | ||||
|     font-size: 1.3rem; | ||||
|     color: $text-color; | ||||
|     transition: color 0.5s ease; | ||||
|   } | ||||
|   .message { | ||||
|     font-weight: 300; | ||||
|     display: flex; | ||||
|     margin-top: 0.8rem; | ||||
|     color: $text-color-70; | ||||
|     transition: color 0.5s ease; | ||||
|     margin: 0.2rem 0 0.5rem; | ||||
|   } | ||||
|  | ||||
|   @include mobile-only { | ||||
|     > div { | ||||
|       margin: 6px 6px; | ||||
|       line-height: 1.3rem; | ||||
|     .content { | ||||
|       margin: 0.4rem 1.2rem; | ||||
|       width: 100%; | ||||
|  | ||||
|       .title { | ||||
|         font-weight: 300; | ||||
|         letter-spacing: 0.25px; | ||||
|         margin: 0; | ||||
|         font-size: 1.3rem; | ||||
|         color: $text-color; | ||||
|         transition: color 0.5s ease; | ||||
|       } | ||||
|  | ||||
|       .message { | ||||
|         font-weight: 400; | ||||
|         font-size: 1.2rem; | ||||
|         color: $text-color-70; | ||||
|         transition: color 0.5s ease; | ||||
|         margin-bottom: 0.2rem; | ||||
|       } | ||||
|  | ||||
|       @include mobile-only { | ||||
|         margin: 6px 6px; | ||||
|         line-height: 1.3rem; | ||||
|  | ||||
|         h2 { | ||||
|           font-size: 1.1rem; | ||||
|         } | ||||
|         span { | ||||
|           font-size: 0.9rem; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     h2 { | ||||
|       font-size: 1.1rem; | ||||
|     } | ||||
|     span { | ||||
|       font-size: 0.9rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .pinstripe { | ||||
|     width: 0.5rem; | ||||
|     background-color: $color-error-highlight; | ||||
|   } | ||||
|  | ||||
|   .dismiss { | ||||
|     position: relative; | ||||
|     -webkit-appearance: none; | ||||
|     -moz-appearance: none; | ||||
|     background-color: transparent; | ||||
|     border: unset; | ||||
|     font-size: 18px; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     top: 0; | ||||
|     float: right; | ||||
|     height: 1.5rem; | ||||
|     width: 1.5rem; | ||||
|     padding: 0; | ||||
|     margin-top: 0.5rem; | ||||
|     margin-right: 0.5rem; | ||||
|     color: $text-color-70; | ||||
|     transition: color 0.5s ease; | ||||
|  | ||||
|     &:hover { | ||||
|       color: $text-color; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.success { | ||||
|     background-color: $color-success; | ||||
|  | ||||
|     .pinstripe { | ||||
|       background-color: $color-success-highlight; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.error { | ||||
|     background-color: $color-error; | ||||
|  | ||||
|     .pinstripe { | ||||
|       width: 0.5rem; | ||||
|       background-color: $color-error-highlight; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.warning { | ||||
|     background-color: $color-warning; | ||||
|     .dismiss { | ||||
|       position: relative; | ||||
|       -webkit-appearance: none; | ||||
|       -moz-appearance: none; | ||||
|       background-color: transparent; | ||||
|       border: unset; | ||||
|       font-size: 18px; | ||||
|       cursor: pointer; | ||||
|  | ||||
|     .pinstripe { | ||||
|       background-color: $color-warning-highlight; | ||||
|       top: 0; | ||||
|       float: right; | ||||
|       height: 1.5rem; | ||||
|       width: 1.5rem; | ||||
|       padding: 0; | ||||
|       margin-top: 0.5rem; | ||||
|       margin-right: 0.5rem; | ||||
|       color: $text-color-70; | ||||
|       transition: color 0.5s ease; | ||||
|  | ||||
|       &:hover { | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.success { | ||||
|       background-color: $color-success; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-success-highlight; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.error { | ||||
|       background-color: $color-error; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-error-highlight; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.warning { | ||||
|       background-color: $color-warning; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-warning-highlight; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -4,83 +4,74 @@ | ||||
|       v-for="option in options" | ||||
|       :key="option" | ||||
|       class="toggle-button" | ||||
|       @click="toggle(option)" | ||||
|       :class="toggleValue === option ? 'selected' : null" | ||||
|       @click="toggleTo(option)" | ||||
|       :class="selected === option ? 'selected' : null" | ||||
|     > | ||||
|       {{ option }} | ||||
|     </button> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     options: { | ||||
|       Array, | ||||
|       required: true | ||||
|     }, | ||||
|     selected: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: undefined | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       toggleValue: this.selected || this.options[0] | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     toggle(toggleValue) { | ||||
|       this.toggleValue = toggleValue; | ||||
|       if (this.selected !== undefined) { | ||||
|         this.$emit("update:selected", toggleValue); | ||||
|         this.$emit("change", toggleValue); | ||||
|       } else { | ||||
|         this.$emit("change", toggleValue); | ||||
|       } | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     options: string[]; | ||||
|     selected?: string; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:selected", selected: string); | ||||
|     (e: "change"); | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   function toggleTo(option: string) { | ||||
|     emit("update:selected", option); | ||||
|     emit("change"); | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "src/scss/variables"; | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
| $background: $background-ui; | ||||
| $background-selected: $background-color-secondary; | ||||
|   $background: $background-ui; | ||||
|   $background-selected: $background-color-secondary; | ||||
|  | ||||
| .toggle-container { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   overflow-x: scroll; | ||||
|   flex-direction: row; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   background-color: $background; | ||||
|   border: 2px solid $background; | ||||
|   border-radius: 8px; | ||||
|   border-left: 4px solid $background; | ||||
|   border-right: 4px solid $background; | ||||
|  | ||||
|   .toggle-button { | ||||
|     font-size: 1rem; | ||||
|     line-height: 1rem; | ||||
|     font-weight: normal; | ||||
|     padding: 0.5rem; | ||||
|     border: 0; | ||||
|     color: $text-color; | ||||
|   .toggle-container { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     overflow-x: scroll; | ||||
|     flex-direction: row; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     background-color: $background; | ||||
|     text-transform: capitalize; | ||||
|     cursor: pointer; | ||||
|     display: block; | ||||
|     flex: 1 0 auto; | ||||
|     border: 2px solid $background; | ||||
|     border-radius: 8px; | ||||
|     border-left: 4px solid $background; | ||||
|     border-right: 4px solid $background; | ||||
|  | ||||
|     &.selected { | ||||
|     .toggle-button { | ||||
|       font-size: 1rem; | ||||
|       line-height: 1rem; | ||||
|       font-weight: normal; | ||||
|       padding: 0.5rem; | ||||
|       border: 0; | ||||
|       color: $text-color; | ||||
|       background-color: $background-selected; | ||||
|       border-radius: 8px; | ||||
|       background-color: $background; | ||||
|       text-transform: capitalize; | ||||
|       cursor: pointer; | ||||
|       display: block; | ||||
|       flex: 1 0 auto; | ||||
|  | ||||
|       &.selected { | ||||
|         color: $text-color; | ||||
|         background-color: $background-selected; | ||||
|         border-radius: 8px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user