Resolved ALL eslint issues for project
This commit is contained in:
		| @@ -1,17 +1,14 @@ | ||||
| <template> | ||||
|   <div id="app"> | ||||
|     <!-- Header and hamburger navigation --> | ||||
|     <NavigationHeader class="header"></NavigationHeader> | ||||
|     <NavigationHeader class="header" /> | ||||
|  | ||||
|     <div class="navigation-icons-gutter desktop-only"> | ||||
|       <NavigationIcons /> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Display the component assigned to the given route (default: home) --> | ||||
|     <router-view | ||||
|       class="content" | ||||
|       :key="router.currentRoute.value.path" | ||||
|     ></router-view> | ||||
|     <router-view :key="router.currentRoute.value.path" class="content" /> | ||||
|  | ||||
|     <!-- Popup that will show above existing rendered content --> | ||||
|     <popup /> | ||||
|   | ||||
							
								
								
									
										135
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										135
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,34 +1,25 @@ | ||||
| import config from "./config"; | ||||
| import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList"; | ||||
|  | ||||
| let { SEASONED_URL, ELASTIC_URL, ELASTIC_INDEX } = config; | ||||
| if (!SEASONED_URL) { | ||||
|   SEASONED_URL = window.location.origin; | ||||
| } | ||||
| const { ELASTIC_URL, ELASTIC_INDEX } = config; | ||||
|  | ||||
| // TODO | ||||
| //  - Move autorization token and errors here? | ||||
|  | ||||
| const checkStatusAndReturnJson = response => { | ||||
|   if (!response.ok) { | ||||
|     throw response; | ||||
|   } | ||||
|   return response.json(); | ||||
| }; | ||||
| let { SEASONED_URL } = config; | ||||
| if (!SEASONED_URL) SEASONED_URL = window.location.origin; | ||||
|  | ||||
| // - - - TMDB - - - | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movie by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getMovie = ( | ||||
|   id, | ||||
|   checkExistance = false, | ||||
|   credits = false, | ||||
|   release_dates = false | ||||
|   { | ||||
|     checkExistance, | ||||
|     credits, | ||||
|     releaseDates | ||||
|   }: { checkExistance: boolean; credits: boolean; releaseDates?: boolean } | ||||
| ) => { | ||||
|   const url = new URL("/api/v2/movie", SEASONED_URL); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
| @@ -38,14 +29,14 @@ const getMovie = ( | ||||
|   if (credits) { | ||||
|     url.searchParams.append("credits", "true"); | ||||
|   } | ||||
|   if (release_dates) { | ||||
|   if (releaseDates) { | ||||
|     url.searchParams.append("release_dates", "true"); | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting movie: ${id}`); | ||||
|       console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -56,7 +47,14 @@ const getMovie = ( | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getShow = (id, checkExistance = false, credits = false) => { | ||||
| const getShow = ( | ||||
|   id, | ||||
|   { | ||||
|     checkExistance, | ||||
|     credits, | ||||
|     releaseDates | ||||
|   }: { checkExistance: boolean; credits: boolean; releaseDates?: boolean } | ||||
| ) => { | ||||
|   const url = new URL("/api/v2/show", SEASONED_URL); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
|   if (checkExistance) { | ||||
| @@ -65,19 +63,18 @@ const getShow = (id, checkExistance = false, credits = false) => { | ||||
|   if (credits) { | ||||
|     url.searchParams.append("credits", "true"); | ||||
|   } | ||||
|   if (releaseDates) { | ||||
|     url.searchParams.append("release_dates", "true"); | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting show: ${id}`); | ||||
|       console.error(`api error getting show: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| function delay(ms) { | ||||
|   return new Promise(resolve => setTimeout(resolve, ms)); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb person by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
| @@ -94,7 +91,7 @@ const getPerson = (id, credits = false) => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting person: ${id}`); | ||||
|       console.error(`api error getting person: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -111,7 +108,7 @@ const getMovieCredits = (id: number): Promise<IMediaCredits> => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting movie: ${id}`); | ||||
|       console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -128,7 +125,7 @@ const getShowCredits = (id: number): Promise<IMediaCredits> => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting show: ${id}`); | ||||
|       console.error(`api error getting show: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -145,7 +142,7 @@ const getPersonCredits = (id: number): Promise<IPersonCredits> => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting person: ${id}`); | ||||
|       console.error(`api error getting person: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -156,15 +153,12 @@ const getPersonCredits = (id: number): Promise<IPersonCredits> => { | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Tmdb list response | ||||
|  */ | ||||
| const getTmdbMovieListByName = ( | ||||
|   name: string, | ||||
|   page: number = 1 | ||||
| ): Promise<IList> => { | ||||
|   const url = new URL("/api/v2/movie/" + name, SEASONED_URL); | ||||
| const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => { | ||||
|   const url = new URL(`/api/v2/movie/${name}`, SEASONED_URL); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console | ||||
| }; | ||||
|  | ||||
| /** | ||||
| @@ -172,12 +166,12 @@ const getTmdbMovieListByName = ( | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Request response | ||||
|  */ | ||||
| const getRequests = (page: number = 1) => { | ||||
| const getRequests = (page = 1) => { | ||||
|   const url = new URL("/api/v2/request", SEASONED_URL); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console | ||||
| }; | ||||
|  | ||||
| const getUserRequests = (page = 1) => { | ||||
| @@ -206,7 +200,7 @@ const searchTmdb = (query, page = 1, adult = false, mediaType = null) => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error searching: ${query}, page: ${page}`); | ||||
|       console.error(`api error searching: ${query}, page: ${page}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -226,7 +220,7 @@ const searchTorrents = query => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error searching torrents: ${query}`); | ||||
|       console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -235,26 +229,26 @@ const searchTorrents = query => { | ||||
|  * Add magnet to download queue. | ||||
|  * @param {string} magnet Magnet link | ||||
|  * @param {boolean} name Name of torrent | ||||
|  * @param {boolean} tmdb_id | ||||
|  * @param {boolean} tmdbId | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const addMagnet = (magnet: string, name: string, tmdb_id: number | null) => { | ||||
| const addMagnet = (magnet: string, name: string, tmdbId: number | null) => { | ||||
|   const url = new URL("/api/v1/pirate/add", SEASONED_URL); | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       magnet: magnet, | ||||
|       name: name, | ||||
|       tmdb_id: tmdb_id | ||||
|       magnet, | ||||
|       name, | ||||
|       tmdb_id: tmdbId | ||||
|     }) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error adding magnet: ${name} ${error}`); | ||||
|       console.error(`api error adding magnet: ${name} ${error}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -280,7 +274,7 @@ const request = (id, type) => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error requesting: ${id}, type: ${type}`); | ||||
|       console.error(`api error requesting: ${id}, type: ${type}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -298,16 +292,13 @@ const getRequestStatus = (id, type = undefined) => { | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => { | ||||
|       const status = resp.status; | ||||
|       if (status === 200) { | ||||
|         return true; | ||||
|       } else if (status === 404) { | ||||
|       const { status } = resp; | ||||
|       if (status === 200) return true; | ||||
|  | ||||
|       const errorMessage = `api error getting request status for id ${id} and type ${type}`; | ||||
|       // eslint-disable-next-line no-console | ||||
|       console.error(errorMessage); | ||||
|       return false; | ||||
|       } else { | ||||
|         console.error( | ||||
|           `api error getting request status for id ${id} and type ${type}` | ||||
|         ); | ||||
|       } | ||||
|     }) | ||||
|     .catch(err => Promise.reject(err)); | ||||
| }; | ||||
| @@ -341,10 +332,10 @@ const register = (username, password) => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error( | ||||
|         "Unexpected error occured before receiving response. Error:", | ||||
|         error | ||||
|       ); | ||||
|       const errorMessage = | ||||
|         "Unexpected error occured before receiving response. Error:"; | ||||
|       // eslint-disable-next-line no-console | ||||
|       console.error(errorMessage, error); | ||||
|       // TODO log to sentry the issue here | ||||
|       throw error; | ||||
|     }); | ||||
| @@ -359,10 +350,11 @@ const login = (username, password, throwError = false) => { | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => { | ||||
|     if (resp.status == 200) return resp.json(); | ||||
|     if (resp.status === 200) return resp.json(); | ||||
|  | ||||
|     if (throwError) throw resp; | ||||
|     else console.error("Error occured when trying to sign in.\nError:", resp); | ||||
|     console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console | ||||
|     return Promise.reject(resp); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| @@ -371,10 +363,11 @@ const logout = (throwError = false) => { | ||||
|   const options = { method: "POST" }; | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => { | ||||
|     if (resp.status == 200) return resp.json(); | ||||
|     if (resp.status === 200) return resp.json(); | ||||
|  | ||||
|     if (throwError) throw resp; | ||||
|     else console.error("Error occured when trying to log out.\nError:", resp); | ||||
|     console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console | ||||
|     return Promise.reject(resp); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| @@ -384,7 +377,7 @@ const getSettings = () => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error getting user settings"); | ||||
|       console.log("api error getting user settings"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -401,7 +394,7 @@ const updateSettings = settings => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error updating user settings"); | ||||
|       console.log("api error updating user settings"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -421,7 +414,7 @@ const linkPlexAccount = (username, password) => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error linking plex account: ${username}`); | ||||
|       console.error(`api error linking plex account: ${username}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -437,7 +430,7 @@ const unlinkPlexAccount = () => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error unlinking your plex account`); | ||||
|       console.error(`api error unlinking your plex account`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -445,13 +438,13 @@ const unlinkPlexAccount = () => { | ||||
| // - - - User graphs - - - | ||||
|  | ||||
| const fetchGraphData = (urlPath, days, chartType) => { | ||||
|   const url = new URL("/api/v1/user/" + urlPath, SEASONED_URL); | ||||
|   const url = new URL(`/api/v1/user/${urlPath}`, SEASONED_URL); | ||||
|   url.searchParams.append("days", days); | ||||
|   url.searchParams.append("y_axis", chartType); | ||||
|  | ||||
|   return fetch(url.href).then(resp => { | ||||
|     if (!resp.ok) { | ||||
|       console.log("DAMN WE FAILED!", resp); | ||||
|       console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console | ||||
|       throw Error(resp.statusText); | ||||
|     } | ||||
|  | ||||
| @@ -467,7 +460,7 @@ const getEmoji = () => { | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error getting emoji"); | ||||
|       console.log("api error getting emoji"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
| @@ -515,7 +508,7 @@ const elasticSearchMoviesAndShows = (query, count = 22) => { | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log(`api error searching elasticsearch: ${query}`); | ||||
|       console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|   | ||||
| @@ -3,8 +3,8 @@ | ||||
|     <ol class="persons"> | ||||
|       <CastListItem | ||||
|         v-for="credit in cast" | ||||
|         :creditItem="credit" | ||||
|         :key="credit.id" | ||||
|         :credit-item="credit" | ||||
|       /> | ||||
|     </ol> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <li class="card"> | ||||
|     <a @click="openCastItem"> | ||||
|       <img :src="pictureUrl" /> | ||||
|     <a @click="openCastItem" @keydown.enter="openCastItem"> | ||||
|       <img :src="pictureUrl" alt="Movie or person poster image" /> | ||||
|       <p class="name">{{ creditItem.name || creditItem.title }}</p> | ||||
|       <p class="meta">{{ creditItem.character || creditItem.year }}</p> | ||||
|     </a> | ||||
| @@ -25,7 +25,8 @@ | ||||
|  | ||||
|     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) { | ||||
|     } | ||||
|     if ("poster" in props.creditItem && props.creditItem.poster) { | ||||
|       return baseUrl + props.creditItem.poster; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, onMounted, watch } from "vue"; | ||||
|   import { ref, defineProps, onMounted, watch } from "vue"; | ||||
|   import { | ||||
|     Chart, | ||||
|     LineElement, | ||||
| @@ -19,6 +19,11 @@ | ||||
|     ChartType | ||||
|   } from "chart.js"; | ||||
|  | ||||
|   import type { Ref } from "vue"; | ||||
|   import { convertSecondsToHumanReadable } from "../utils"; | ||||
|   import { GraphValueTypes } from "../interfaces/IGraph"; | ||||
|   import type { IGraphDataset, IGraphData } from "../interfaces/IGraph"; | ||||
|  | ||||
|   Chart.register( | ||||
|     LineElement, | ||||
|     BarElement, | ||||
| @@ -31,23 +36,6 @@ | ||||
|     Title, | ||||
|     Tooltip | ||||
|   ); | ||||
|   import {} from "chart.js"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   enum GraphValueTypes { | ||||
|     Number = "number", | ||||
|     Time = "time" | ||||
|   } | ||||
|  | ||||
|   interface IGraphDataset { | ||||
|     name: string; | ||||
|     data: Array<number>; | ||||
|   } | ||||
|  | ||||
|   interface IGraphData { | ||||
|     labels: Array<string>; | ||||
|     series: Array<IGraphDataset>; | ||||
|   } | ||||
|  | ||||
|   interface Props { | ||||
|     name?: string; | ||||
| @@ -69,8 +57,10 @@ | ||||
|   const graphCanvas: Ref<HTMLCanvasElement> = ref(null); | ||||
|   let graphInstance = null; | ||||
|  | ||||
|   /* eslint-disable no-use-before-define */ | ||||
|   onMounted(() => generateGraph()); | ||||
|   watch(() => props.data, generateGraph); | ||||
|   /* eslint-enable no-use-before-define */ | ||||
|  | ||||
|   const graphTemplates = [ | ||||
|     { | ||||
| @@ -92,9 +82,9 @@ | ||||
|       tension: 0.4 | ||||
|     } | ||||
|   ]; | ||||
|   const gridColor = getComputedStyle(document.documentElement).getPropertyValue( | ||||
|     "--text-color-5" | ||||
|   ); | ||||
|   // const gridColor = getComputedStyle(document.documentElement).getPropertyValue( | ||||
|   //   "--text-color-5" | ||||
|   // ); | ||||
|  | ||||
|   function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) { | ||||
|     return { | ||||
| @@ -105,6 +95,7 @@ | ||||
|   } | ||||
|  | ||||
|   function removeEmptyDataset(dataset: IGraphDataset) { | ||||
|     /* eslint-disable-next-line no-unneeded-ternary */ | ||||
|     return dataset.data.every(point => point === 0) ? false : true; | ||||
|   } | ||||
|  | ||||
| @@ -143,7 +134,7 @@ | ||||
|         yAxes: { | ||||
|           stacked: props.stacked, | ||||
|           ticks: { | ||||
|             callback: (value, index, values) => { | ||||
|             callback: value => { | ||||
|               if (props.graphValueType === GraphValueTypes.Time) { | ||||
|                 return convertSecondsToHumanReadable(value); | ||||
|               } | ||||
| @@ -174,40 +165,6 @@ | ||||
|       options: graphOptions | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function convertSecondsToHumanReadable(value, values = null) { | ||||
|     const highestValue = values ? values[0] : value; | ||||
|  | ||||
|     // minutes | ||||
|     if (highestValue < 3600) { | ||||
|       const minutes = Math.floor(value / 60); | ||||
|  | ||||
|       value = `${minutes} m`; | ||||
|     } | ||||
|     // hours and minutes | ||||
|     else if (highestValue > 3600 && highestValue < 86400) { | ||||
|       const hours = Math.floor(value / 3600); | ||||
|       const minutes = Math.floor((value % 3600) / 60); | ||||
|  | ||||
|       value = hours != 0 ? `${hours} h ${minutes} m` : `${minutes} m`; | ||||
|     } | ||||
|     // days and hours | ||||
|     else if (highestValue > 86400 && highestValue < 31557600) { | ||||
|       const days = Math.floor(value / 86400); | ||||
|       const hours = Math.floor((value % 86400) / 3600); | ||||
|  | ||||
|       value = days != 0 ? `${days} d ${hours} h` : `${hours} h`; | ||||
|     } | ||||
|     // years and days | ||||
|     else if (highestValue > 31557600) { | ||||
|       const years = Math.floor(value / 31557600); | ||||
|       const days = Math.floor((value % 31557600) / 86400); | ||||
|  | ||||
|       value = years != 0 ? `${years} y ${days} d` : `${days} d`; | ||||
|     } | ||||
|  | ||||
|     return value; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped></style> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <header ref="headerElement" :class="{ expanded, noselect: true }"> | ||||
|     <img :src="bannerImage" ref="imageElement" /> | ||||
|     <img ref="imageElement" :src="bannerImage" alt="Page banner image" /> | ||||
|     <div class="container"> | ||||
|       <h1 class="title">Request movies or tv shows</h1> | ||||
|       <strong class="subtitle" | ||||
| @@ -8,7 +8,13 @@ | ||||
|       > | ||||
|     </div> | ||||
|  | ||||
|     <div class="expand-icon" @click="expand" @mouseover="upgradeImage"> | ||||
|     <div | ||||
|       class="expand-icon" | ||||
|       @click="expand" | ||||
|       @keydown.enter="expand" | ||||
|       @mouseover="upgradeImage" | ||||
|       @focus="focus" | ||||
|     > | ||||
|       <IconExpand v-if="!expanded" /> | ||||
|       <IconShrink v-else /> | ||||
|     </div> | ||||
| @@ -16,7 +22,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, onMounted } from "vue"; | ||||
|   import { ref } from "vue"; | ||||
|   import IconExpand from "@/icons/IconExpand.vue"; | ||||
|   import IconShrink from "@/icons/IconShrink.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
| @@ -35,9 +41,7 @@ | ||||
|   const headerElement: Ref<HTMLElement> = ref(null); | ||||
|   const imageElement: Ref<HTMLImageElement> = ref(null); | ||||
|   const defaultHeaderHeight: Ref<string> = ref(); | ||||
|   const disableProxy = true; | ||||
|  | ||||
|   bannerImage.value = randomImage(); | ||||
|   // const disableProxy = true; | ||||
|  | ||||
|   function expand() { | ||||
|     expanded.value = !expanded.value; | ||||
| @@ -53,11 +57,17 @@ | ||||
|     headerElement.value.style.setProperty("--header-height", height); | ||||
|   } | ||||
|  | ||||
|   function focus(event: FocusEvent) { | ||||
|     event.preventDefault(); | ||||
|   } | ||||
|  | ||||
|   function randomImage(): string { | ||||
|     const image = images[Math.floor(Math.random() * images?.length)]; | ||||
|     const image = images[Math.floor(Math.random() * images.length)]; | ||||
|     return ASSET_URL + image; | ||||
|   } | ||||
|  | ||||
|   bannerImage.value = randomImage(); | ||||
|  | ||||
|   // function sliceToHeaderSize(url: string): string { | ||||
|   //   let width = headerElement.value?.getBoundingClientRect()?.width || 1349; | ||||
|   //   let height = headerElement.value?.getBoundingClientRect()?.height || 261; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div v-if="isOpen" class="movie-popup" @click="close"> | ||||
|   <div v-if="isOpen" class="movie-popup" @click="close" @keydown.enter="close"> | ||||
|     <div class="movie-popup__box" @click.stop> | ||||
|       <person v-if="type === 'person'" :id="id" type="person" /> | ||||
|       <movie v-else :id="id" :type="type"></movie> | ||||
| @@ -14,8 +14,8 @@ | ||||
|   import { useStore } from "vuex"; | ||||
|   import Movie from "@/components/popup/Movie.vue"; | ||||
|   import Person from "@/components/popup/Person.vue"; | ||||
|   import { MediaTypes } from "../interfaces/IList"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { MediaTypes } from "../interfaces/IList"; | ||||
|  | ||||
|   interface URLQueryParameters { | ||||
|     id: number; | ||||
| @@ -34,14 +34,16 @@ | ||||
|     id.value = state.popup.id; | ||||
|     type.value = state.popup.type; | ||||
|  | ||||
|     isOpen.value | ||||
|       ? document.getElementsByTagName("body")[0].classList.add("no-scroll") | ||||
|       : document.getElementsByTagName("body")[0].classList.remove("no-scroll"); | ||||
|     if (isOpen.value) { | ||||
|       document.getElementsByTagName("body")[0].classList.add("no-scroll"); | ||||
|     } else { | ||||
|       document.getElementsByTagName("body")[0].classList.remove("no-scroll"); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   function getFromURLQuery(): URLQueryParameters { | ||||
|     let id: number; | ||||
|     let type: MediaTypes; | ||||
|     let _id: number; | ||||
|     let _type: MediaTypes; | ||||
|  | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     params.forEach((value, key) => { | ||||
| @@ -55,16 +57,16 @@ | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       id = Number(params.get(key)); | ||||
|       type = MediaTypes[key]; | ||||
|       _id = Number(params.get(key)); | ||||
|       _type = MediaTypes[key]; | ||||
|     }); | ||||
|  | ||||
|     return { id, type }; | ||||
|     return { id: _id, type: _type }; | ||||
|   } | ||||
|  | ||||
|   function open(id: Number, type: string) { | ||||
|     if (!id || !type) return; | ||||
|     store.dispatch("popup/open", { id, type }); | ||||
|   function open(_id: number, _type: string) { | ||||
|     if (!_id || !_type) return; | ||||
|     store.dispatch("popup/open", { id: _id, type: _type }); | ||||
|   } | ||||
|  | ||||
|   function close() { | ||||
| @@ -79,8 +81,8 @@ | ||||
|   window.addEventListener("keyup", checkEventForEscapeKey); | ||||
|  | ||||
|   onMounted(() => { | ||||
|     const { id, type } = getFromURLQuery(); | ||||
|     open(id, type); | ||||
|     const query = getFromURLQuery(); | ||||
|     open(query?.id, query?.type); | ||||
|   }); | ||||
|  | ||||
|   onBeforeUnmount(() => { | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
|       <results-list-item | ||||
|         v-for="(result, index) in results" | ||||
|         :key="generateResultKey(index, `${result.type}-${result.id}`)" | ||||
|         :listItem="result" | ||||
|         :list-item="result" | ||||
|       /> | ||||
|     </ul> | ||||
|  | ||||
| @@ -23,11 +23,11 @@ | ||||
|  | ||||
|   interface Props { | ||||
|     results: Array<ListResults>; | ||||
|     shortList?: Boolean; | ||||
|     loading?: Boolean; | ||||
|     shortList?: boolean; | ||||
|     loading?: boolean; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   defineProps<Props>(); | ||||
|  | ||||
|   function generateResultKey(index: string | number | symbol, value: string) { | ||||
|     return `${String(index)}-${value}`; | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| <template> | ||||
|   <li class="movie-item" ref="list-item"> | ||||
|   <li ref="list-item" class="movie-item"> | ||||
|     <figure | ||||
|       ref="posterElement" | ||||
|       class="movie-item__poster" | ||||
|       @click="openMoviePopup" | ||||
|       @keydown.enter="openMoviePopup" | ||||
|     > | ||||
|       <img | ||||
|         class="movie-item__img" | ||||
| @@ -36,9 +37,8 @@ | ||||
| <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 { IMovie, IShow, IPerson, IRequest } from "../interfaces/IList"; | ||||
|   import type { IMovie, IShow, IPerson } from "../interfaces/IList"; | ||||
|  | ||||
|   interface Props { | ||||
|     listItem: IMovie | IShow | IPerson; | ||||
| @@ -53,19 +53,31 @@ | ||||
|   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; | ||||
|   if (props.listItem?.poster) { | ||||
|     poster.value = IMAGE_BASE_URL + props.listItem.poster; | ||||
|   } else { | ||||
|     poster.value = IMAGE_FALLBACK; | ||||
|   } | ||||
|  | ||||
|   onMounted(observePosterAndSetImageSource); | ||||
|   const posterAltText = computed(() => { | ||||
|     const type = props.listItem.type || ""; | ||||
|     let title = ""; | ||||
|  | ||||
|     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}`; | ||||
|   }); | ||||
|  | ||||
|   function observePosterAndSetImageSource() { | ||||
|     const imageElement = posterElement.value.getElementsByTagName("img")[0]; | ||||
|     if (imageElement == null) return; | ||||
|  | ||||
|     const imageObserver = new IntersectionObserver((entries, imgObserver) => { | ||||
|     const imageObserver = new IntersectionObserver(entries => { | ||||
|       entries.forEach(entry => { | ||||
|         if (entry.isIntersecting && observed.value == false) { | ||||
|         if (entry.isIntersecting && observed.value === false) { | ||||
|           const lazyImage = entry.target as HTMLImageElement; | ||||
|           lazyImage.src = lazyImage.dataset.src; | ||||
|           posterElement.value.classList.add("is-loaded"); | ||||
| @@ -77,30 +89,20 @@ | ||||
|     imageObserver.observe(imageElement); | ||||
|   } | ||||
|  | ||||
|   onMounted(observePosterAndSetImageSource); | ||||
|  | ||||
|   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) | ||||
|     }; | ||||
|   }); | ||||
|   // 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: { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|       v-if="!loadedPages.includes(1) && loading == false" | ||||
|       class="button-container" | ||||
|     > | ||||
|       <seasoned-button @click="loadLess" class="load-button" :fullWidth="true" | ||||
|       <seasoned-button class="load-button" :full-width="true" @click="loadLess" | ||||
|         >load previous</seasoned-button | ||||
|       > | ||||
|     </div> | ||||
| @@ -16,10 +16,10 @@ | ||||
|  | ||||
|     <div ref="loadMoreButton" class="button-container"> | ||||
|       <seasoned-button | ||||
|         class="load-button" | ||||
|         v-if="!loading && !shortList && page != totalPages && results.length" | ||||
|         class="load-button" | ||||
|         :full-width="true" | ||||
|         @click="loadMore" | ||||
|         :fullWidth="true" | ||||
|         >load more</seasoned-button | ||||
|       > | ||||
|     </div> | ||||
| @@ -28,12 +28,10 @@ | ||||
|  | ||||
| <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"; | ||||
| @@ -44,7 +42,6 @@ | ||||
|     shortList?: boolean; | ||||
|   } | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const results: Ref<ListResults> = ref([]); | ||||
| @@ -54,12 +51,23 @@ | ||||
|   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 observer: Ref<IntersectionObserver> = ref(null); | ||||
|   const resultSection = ref(null); | ||||
|   const loadMoreButton = ref(null); | ||||
|  | ||||
|   page.value = getPageFromUrl() || page.value; | ||||
|   if (results.value?.length === 0) getListResults(); | ||||
|   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 setLoading(state: boolean) { | ||||
|     loading.value = state; | ||||
|   } | ||||
|  | ||||
|   const info = computed(() => { | ||||
|     if (results.value.length === 0) return [null, null]; | ||||
| @@ -69,25 +77,30 @@ | ||||
|     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; | ||||
|     const _page = new URLSearchParams(window.location.search).get("page"); | ||||
|     if (!_page) return null; | ||||
|  | ||||
|     return Number(page); | ||||
|     return Number(_page); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams() { | ||||
|     const 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 getListResults(front = false) { | ||||
| @@ -105,7 +118,7 @@ | ||||
|         totalResults.value = listResponse.total_results; | ||||
|       }) | ||||
|       .then(updateQueryParams) | ||||
|       .finally(() => (loading.value = false)); | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   function loadMore() { | ||||
| @@ -114,9 +127,9 @@ | ||||
|     } | ||||
|  | ||||
|     loading.value = true; | ||||
|     let maxPage = [...loadedPages.value].slice(-1)[0]; | ||||
|     const maxPage = [...loadedPages.value].slice(-1)[0]; | ||||
|  | ||||
|     if (maxPage == NaN) return; | ||||
|     if (Number.isNaN(maxPage)) return; | ||||
|     page.value = maxPage + 1; | ||||
|     getListResults(); | ||||
|   } | ||||
| @@ -130,25 +143,6 @@ | ||||
|     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 | ||||
| @@ -165,14 +159,12 @@ | ||||
|     observer.value.observe(loadMoreButton.value); | ||||
|   } | ||||
|  | ||||
|   //   created() { | ||||
|   //     if (!this.shortList) { | ||||
|   //       store.dispatch( | ||||
|   //         "documentTitle/updateTitle", | ||||
|   //         `${this.$router.history.current.name} ${this.title}` | ||||
|   //       ); | ||||
|   //     } | ||||
|   //   }, | ||||
|   page.value = getPageFromUrl() || page.value; | ||||
|   if (results.value?.length === 0) getListResults(); | ||||
|   onMounted(() => { | ||||
|     if (!props?.shortList) setupAutoloadObserver(); | ||||
|   }); | ||||
|  | ||||
|   //   beforeDestroy() { | ||||
|   //     this.observer = undefined; | ||||
|   //   } | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| <template> | ||||
|   <transition name="shut"> | ||||
|     <ul class="dropdown"> | ||||
|       <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events --> | ||||
|       <li | ||||
|         v-for="result in searchResults" | ||||
|         :key="`${result.index}-${result.title}-${result.type}`" | ||||
|         v-for="(result, _index) in searchResults" | ||||
|         :key="`${_index}-${result.title}-${result.type}`" | ||||
|         :class="`result di-${_index} ${_index === index ? 'active' : ''}`" | ||||
|         @click="openPopup(result)" | ||||
|         :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" /> | ||||
| @@ -29,36 +28,38 @@ | ||||
|   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"; | ||||
|   import { elasticSearchMoviesAndShows } from "../../api"; | ||||
|   import { MediaTypes } from "../../interfaces/IList"; | ||||
|   import { Index } from "../../interfaces/IAutocompleteSearch"; | ||||
|   import type { | ||||
|     IAutocompleteResult, | ||||
|     IAutocompleteSearchResults | ||||
|   } from "../../interfaces/IAutocompleteSearch"; | ||||
|  | ||||
|   interface Props { | ||||
|     query?: string; | ||||
|     index?: Number; | ||||
|     results?: Array<any>; | ||||
|     index?: number; | ||||
|     results?: Array<IAutocompleteResult>; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:results", value: Array<any>); | ||||
|     (e: "update:results", value: Array<IAutocompleteResult>); | ||||
|   } | ||||
|  | ||||
|   const numberOfResults: number = 10; | ||||
|   const numberOfResults = 10; | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const searchResults: Ref<Array<any>> = ref([]); | ||||
|   const searchResults: Ref<Array<IAutocompleteResult>> = ref([]); | ||||
|   const keyboardNavigationIndex: Ref<number> = ref(0); | ||||
|  | ||||
|   // on load functions | ||||
|   fetchAutocompleteResults(); | ||||
|   // end on load functions | ||||
|  | ||||
|   watch( | ||||
|     () => props.query, | ||||
|     newQuery => { | ||||
|       if (newQuery?.length > 0) fetchAutocompleteResults(); | ||||
|       if (newQuery?.length > 0) | ||||
|         fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */ | ||||
|     } | ||||
|   ); | ||||
|  | ||||
| @@ -68,6 +69,53 @@ | ||||
|     store.dispatch("popup/open", { ...result }); | ||||
|   } | ||||
|  | ||||
|   function removeDuplicates(_searchResults) { | ||||
|     const filteredResults = []; | ||||
|     _searchResults.forEach(result => { | ||||
|       if (result === undefined) return; | ||||
|       const numberOfDuplicates = filteredResults.filter( | ||||
|         filterItem => filterItem.id === result.id | ||||
|       ); | ||||
|       if (numberOfDuplicates.length >= 1) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       filteredResults.push(result); | ||||
|     }); | ||||
|  | ||||
|     return filteredResults; | ||||
|   } | ||||
|  | ||||
|   function elasticIndexToMediaType(index: Index): MediaTypes { | ||||
|     if (index === Index.Movies) return MediaTypes.Movie; | ||||
|     if (index === Index.Shows) return MediaTypes.Show; | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) { | ||||
|     const data = elasticResponse.hits.hits; | ||||
|  | ||||
|     const results: Array<IAutocompleteResult> = []; | ||||
|  | ||||
|     data.forEach(item => { | ||||
|       if (!Object.values(Index).includes(item._index)) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       results.push({ | ||||
|         title: item._source?.original_name || item._source.original_title, | ||||
|         id: item._source.id, | ||||
|         adult: item._source.adult, | ||||
|         type: elasticIndexToMediaType(item._index) | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return removeDuplicates(results).map((el, index) => { | ||||
|       return { ...el, index }; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function fetchAutocompleteResults() { | ||||
|     keyboardNavigationIndex.value = 0; | ||||
|     searchResults.value = []; | ||||
| @@ -80,44 +128,9 @@ | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   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; | ||||
|   } | ||||
|   // on load functions | ||||
|   fetchAutocompleteResults(); | ||||
|   // end on load functions | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| <template> | ||||
|   <nav> | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/anchor-has-content --> | ||||
|     <a v-if="isHome" class="nav__logo" href="/"> | ||||
|       <TmdbLogo class="logo" /> | ||||
|     </a> | ||||
|  | ||||
|     <router-link v-else class="nav__logo" to="/" exact> | ||||
|       <TmdbLogo class="logo" /> | ||||
|     </router-link> | ||||
| @@ -13,7 +15,7 @@ | ||||
|     <NavigationIcon class="desktop-only" :route="profileRoute" /> | ||||
|  | ||||
|     <!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> --> | ||||
|     <div class="navigation-icons-grid mobile-only" v-if="isOpen"> | ||||
|     <div v-if="isOpen" class="navigation-icons-grid mobile-only"> | ||||
|       <NavigationIcons> | ||||
|         <NavigationIcon :route="profileRoute" /> | ||||
|       </NavigationIcons> | ||||
| @@ -22,8 +24,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { computed, defineProps, PropType } from "vue"; | ||||
|   import type { App } from "vue"; | ||||
|   import { computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import SearchInput from "@/components/header/SearchInput.vue"; | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| <template> | ||||
|   <router-link | ||||
|     :to="{ path: route?.route }" | ||||
|     :key="route?.title" | ||||
|     v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)" | ||||
|     :key="route?.title" | ||||
|     :to="{ path: route?.route }" | ||||
|   > | ||||
|     <li class="navigation-link" :class="{ active: route.route == active }"> | ||||
|       <component class="navigation-icon" :is="route.icon"></component> | ||||
|       <component :is="route.icon" class="navigation-icon"></component> | ||||
|       <span>{{ route.title }}</span> | ||||
|     </li> | ||||
|   </router-link> | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| <template> | ||||
|   <ul class="navigation-icons"> | ||||
|     <NavigationIcon | ||||
|       v-for="route in routes" | ||||
|       :key="route.route" | ||||
|       :route="route" | ||||
|       v-for="_route in routes" | ||||
|       :key="_route.route" | ||||
|       :route="_route" | ||||
|       :active="activeRoute" | ||||
|     /> | ||||
|     <slot></slot> | ||||
| @@ -66,7 +66,11 @@ | ||||
|     } | ||||
|   ]; | ||||
|  | ||||
|   watch(route, () => (activeRoute.value = window?.location?.pathname)); | ||||
|   function setActiveRoute(_route: string) { | ||||
|     activeRoute.value = _route; | ||||
|   } | ||||
|  | ||||
|   watch(route, () => setActiveRoute(window?.location?.pathname || "")); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -3,15 +3,16 @@ | ||||
|     <div class="search" :class="{ active: inputIsActive }"> | ||||
|       <IconSearch class="search-icon" tabindex="-1" /> | ||||
|  | ||||
|       <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label --> | ||||
|       <input | ||||
|         ref="inputElement" | ||||
|         v-model="query" | ||||
|         type="text" | ||||
|         placeholder="Search for movie or show" | ||||
|         aria-label="Search input for finding a movie or show" | ||||
|         autocorrect="off" | ||||
|         autocapitalize="off" | ||||
|         tabindex="0" | ||||
|         v-model="query" | ||||
|         @input="handleInput" | ||||
|         @click="focus" | ||||
|         @keydown.escape="handleEscape" | ||||
| @@ -23,20 +24,20 @@ | ||||
|       /> | ||||
|  | ||||
|       <IconClose | ||||
|         v-if="query && query.length" | ||||
|         tabindex="0" | ||||
|         aria-label="button" | ||||
|         v-if="query && query.length" | ||||
|         class="close-icon" | ||||
|         @click="clearInput" | ||||
|         @keydown.enter.stop="clearInput" | ||||
|         class="close-icon" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <AutocompleteDropdown | ||||
|       v-if="showAutocompleteResults" | ||||
|       v-model:results="dropdownResults" | ||||
|       :query="query" | ||||
|       :index="dropdownIndex" | ||||
|       v-model:results="dropdownResults" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -44,14 +45,12 @@ | ||||
| <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 { useRouter, useRoute } from "vue-router"; | ||||
|   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 config from "../../config"; | ||||
|   import type { MediaTypes } from "../../interfaces/IList"; | ||||
|  | ||||
|   interface ISearchResult { | ||||
| @@ -70,8 +69,7 @@ | ||||
|   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 inputElement: Ref<HTMLInputElement> = ref(null); | ||||
|  | ||||
|   const isOpen = computed(() => store.getters["popup/isOpen"]); | ||||
|   const showAutocompleteResults = computed(() => { | ||||
| @@ -95,12 +93,12 @@ | ||||
|  | ||||
|   function navigateDown() { | ||||
|     if (dropdownIndex.value < dropdownResults.value.length - 1) { | ||||
|       dropdownIndex.value++; | ||||
|       dropdownIndex.value += 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function navigateUp() { | ||||
|     if (dropdownIndex.value > -1) dropdownIndex.value--; | ||||
|     if (dropdownIndex.value > -1) dropdownIndex.value -= 1; | ||||
|  | ||||
|     const textLength = inputElement.value.value.length; | ||||
|  | ||||
| @@ -122,12 +120,31 @@ | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function handleInput(e) { | ||||
|   function handleInput() { | ||||
|     dropdownIndex.value = -1; | ||||
|   } | ||||
|  | ||||
|   function focus() { | ||||
|     inputIsActive.value = true; | ||||
|   } | ||||
|  | ||||
|   function reset() { | ||||
|     inputElement.value.blur(); | ||||
|     dropdownIndex.value = -1; | ||||
|     inputIsActive.value = false; | ||||
|   } | ||||
|  | ||||
|   function blur() { | ||||
|     return setTimeout(reset, 150); | ||||
|   } | ||||
|  | ||||
|   function clearInput() { | ||||
|     query.value = ""; | ||||
|     inputElement.value.focus(); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit() { | ||||
|     if (!query.value || query.value.length == 0) return; | ||||
|     if (!query.value || query.value.length === 0) return; | ||||
|  | ||||
|     if (dropdownIndex.value >= 0) { | ||||
|       const resultItem = dropdownResults.value[dropdownIndex.value]; | ||||
| @@ -143,25 +160,6 @@ | ||||
|     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(); | ||||
|   } | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| <template> | ||||
|   <li | ||||
|     class="sidebar-list-element" | ||||
|     @click="emit('click')" | ||||
|     :class="{ active, disabled }" | ||||
|     @click="emit('click')" | ||||
|     @keydown.enter="emit('click')" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </li> | ||||
| @@ -12,8 +13,8 @@ | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     active?: Boolean; | ||||
|     disabled?: Boolean; | ||||
|     active?: boolean; | ||||
|     disabled?: boolean; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|     ref="descriptionElement" | ||||
|     class="movie-description noselect" | ||||
|     @click="overflow ? (truncated = !truncated) : null" | ||||
|     @keydown.enter="overflow ? (truncated = !truncated) : null" | ||||
|   > | ||||
|     <span :class="{ truncated }">{{ description }}</span> | ||||
|  | ||||
| @@ -14,8 +15,8 @@ | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, onMounted } from "vue"; | ||||
|   import IconArrowDown from "../../icons/IconArrowDown.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import IconArrowDown from "../../icons/IconArrowDown.vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     description: string; | ||||
| @@ -26,7 +27,10 @@ | ||||
|   const overflow: Ref<boolean> = ref(false); | ||||
|   const descriptionElement: Ref<HTMLElement> = ref(null); | ||||
|  | ||||
|   onMounted(checkDescriptionOverflowing); | ||||
|   // eslint-disable-next-line no-undef | ||||
|   function removeElements(elems: NodeListOf<Element>) { | ||||
|     elems.forEach(el => el.remove()); | ||||
|   } | ||||
|  | ||||
|   // The description element overflows text after 4 rows with css | ||||
|   // line-clamp this takes the same text and adds to a temporary | ||||
| @@ -53,15 +57,13 @@ | ||||
|  | ||||
|     document.body.appendChild(descriptionComparisonElement); | ||||
|     const elemWithoutOverflowHeight = | ||||
|       descriptionComparisonElement.getBoundingClientRect()["height"]; | ||||
|       descriptionComparisonElement.getBoundingClientRect().height; | ||||
|  | ||||
|     overflow.value = elemWithoutOverflowHeight > height; | ||||
|     removeElements(document.querySelectorAll(".dummy-non-overflow")); | ||||
|   } | ||||
|  | ||||
|   function removeElements(elems: NodeListOf<Element>) { | ||||
|     elems.forEach(el => el.remove()); | ||||
|   } | ||||
|   onMounted(checkDescriptionOverflowing); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <template> | ||||
|   <section class="movie"> | ||||
|     <!-- HEADER w/ POSTER --> | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events --> | ||||
|     <header | ||||
|       ref="backdropElement" | ||||
|       :class="compact ? 'compact' : ''" | ||||
| @@ -8,8 +9,9 @@ | ||||
|     > | ||||
|       <figure class="movie__poster"> | ||||
|         <img | ||||
|           class="movie-item__img is-loaded" | ||||
|           ref="poster-image" | ||||
|           class="movie-item__img is-loaded" | ||||
|           alt="Movie poster" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
| @@ -25,7 +27,7 @@ | ||||
|     <div class="movie__main"> | ||||
|       <div class="movie__wrap movie__wrap--main"> | ||||
|         <!-- SIDEBAR ACTIONS --> | ||||
|         <div class="movie__actions" v-if="media"> | ||||
|         <div v-if="media" class="movie__actions"> | ||||
|           <action-button :active="media?.exists_in_plex" :disabled="true"> | ||||
|             <IconThumbsUp v-if="media?.exists_in_plex" /> | ||||
|             <IconThumbsDown v-else /> | ||||
| @@ -36,7 +38,7 @@ | ||||
|             }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button @click="sendRequest" :active="requested"> | ||||
|           <action-button :active="requested" @click="sendRequest"> | ||||
|             <transition name="fade" mode="out-in"> | ||||
|               <div v-if="!requested" key="request"><IconRequest /></div> | ||||
|               <div v-else key="requested"><IconRequested /></div> | ||||
| @@ -63,8 +65,8 @@ | ||||
|  | ||||
|           <action-button | ||||
|             v-if="admin === true" | ||||
|             @click="showTorrents = !showTorrents" | ||||
|             :active="showTorrents" | ||||
|             @click="showTorrents = !showTorrents" | ||||
|           > | ||||
|             <IconBinoculars /> | ||||
|             Search for torrents | ||||
| @@ -80,11 +82,11 @@ | ||||
|         </div> | ||||
|  | ||||
|         <!-- Loading placeholder --> | ||||
|         <div class="movie__actions text-input__loading" v-else> | ||||
|         <div v-else class="movie__actions text-input__loading"> | ||||
|           <div | ||||
|             v-for="index in admin ? Array(4) : Array(3)" | ||||
|             class="movie__actions-link" | ||||
|             :key="index" | ||||
|             class="movie__actions-link" | ||||
|           > | ||||
|             <div | ||||
|               class="movie__actions-text text-input__loading--line" | ||||
| @@ -105,7 +107,7 @@ | ||||
|             :description="media.overview" | ||||
|           /> | ||||
|  | ||||
|           <div class="movie__details" v-if="media"> | ||||
|           <div v-if="media" class="movie__details"> | ||||
|             <Detail | ||||
|               v-if="media.year" | ||||
|               title="Release date" | ||||
| @@ -144,7 +146,7 @@ | ||||
|  | ||||
|         <!-- TODO: change this classname, this is general  --> | ||||
|  | ||||
|         <div class="movie__admin" v-if="showCast && cast?.length"> | ||||
|         <div v-if="showCast && cast?.length" class="movie__admin"> | ||||
|           <Detail title="cast"> | ||||
|             <CastList :cast="cast" /> | ||||
|           </Detail> | ||||
| @@ -156,7 +158,7 @@ | ||||
|         v-if="media && admin && showTorrents" | ||||
|         class="torrents" | ||||
|         :query="media?.title" | ||||
|         :tmdb_id="id" | ||||
|         :tmdb-id="id" | ||||
|       ></TorrentList> | ||||
|     </div> | ||||
|   </section> | ||||
| @@ -181,7 +183,7 @@ | ||||
|   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 { Ref } from "vue"; | ||||
|   import type { | ||||
|     IMovie, | ||||
|     IShow, | ||||
| @@ -194,12 +196,11 @@ | ||||
|   import { | ||||
|     getMovie, | ||||
|     getShow, | ||||
|     getPerson, | ||||
|     getMovieCredits, | ||||
|     getShowCredits, | ||||
|     request, | ||||
|     getRequestStatus, | ||||
|     watchLink | ||||
|     getRequestStatus | ||||
|     // watchLink | ||||
|   } from "../../api"; | ||||
|  | ||||
|   interface Props { | ||||
| @@ -222,45 +223,26 @@ | ||||
|  | ||||
|   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 poster = computed(() => { | ||||
|     if (!media.value) return "/assets/placeholder.png"; | ||||
|     if (!media.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`; | ||||
|   }); | ||||
|  | ||||
|   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; | ||||
|   function setCast(_cast: ICast[]) { | ||||
|     cast.value = _cast; | ||||
|   } | ||||
|  | ||||
|     let apiFunction: Function; | ||||
|     let parameters: object; | ||||
|  | ||||
|     if (props.type === MediaTypes.Movie) { | ||||
|       apiFunction = getMovie; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } else if (props.type === MediaTypes.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 setRequested(status: boolean) { | ||||
|     requested.value = status; | ||||
|   } | ||||
|  | ||||
|   function getCredits( | ||||
| @@ -268,7 +250,8 @@ | ||||
|   ): Promise<IMediaCredits> { | ||||
|     if (type === MediaTypes.Movie) { | ||||
|       return getMovieCredits(props.id); | ||||
|     } else if (type === MediaTypes.Show) { | ||||
|     } | ||||
|     if (type === MediaTypes.Show) { | ||||
|       return getShowCredits(props.id); | ||||
|     } | ||||
|  | ||||
| @@ -280,35 +263,64 @@ | ||||
|     return _media; | ||||
|   } | ||||
|  | ||||
|   const computePoster = () => { | ||||
|     if (!media.value) return "/assets/placeholder.png"; | ||||
|     else if (!media.value?.poster) return "/assets/no-image.svg"; | ||||
|   function fetchMedia() { | ||||
|     if (!props.id || !props.type) { | ||||
|       console.error("Unable to fetch media, requires id & type"); // eslint-disable-line no-console | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`; | ||||
|     let apiFunction: typeof getMovie; | ||||
|     let parameters: { | ||||
|       checkExistance: boolean; | ||||
|       credits: boolean; | ||||
|       releaseDates?: boolean; | ||||
|     }; | ||||
|  | ||||
|   function setBackdrop() { | ||||
|     if (!media.value?.backdrop || !backdropElement.value?.style) return ""; | ||||
|     if (props.type === MediaTypes.Movie) { | ||||
|       apiFunction = getMovie; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } else if (props.type === MediaTypes.Show) { | ||||
|       apiFunction = getShow; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } | ||||
|  | ||||
|     apiFunction(props.id, { ...parameters }) | ||||
|       .then(setAndReturnMedia) | ||||
|       .then(() => getCredits(props.type)) | ||||
|       .then(credits => setCast(credits?.cast || [])) | ||||
|       .then(() => getRequestStatus(props.id, props.type)) | ||||
|       .then(requestStatus => setRequested(requestStatus || false)); | ||||
|   } | ||||
|  | ||||
|   function setBackdrop(): void { | ||||
|     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) | ||||
|     request(props.id, props.type).then(resp => | ||||
|       setRequested(resp?.success || false) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function openInPlex() { | ||||
|     return; | ||||
|   function openInPlex(): boolean { | ||||
|     // watchLink() | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   function openTmdb() { | ||||
|     const tmdbType = props.type === MediaTypes.Show ? "tv" : props.type; | ||||
|     const tmdbURL = "https://www.themoviedb.org/" + tmdbType + "/" + props.id; | ||||
|     const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`; | ||||
|     window.location.href = tmdbURL; | ||||
|   } | ||||
|  | ||||
|   // On created functions | ||||
|   fetchMedia(); | ||||
|   setBackdrop(); | ||||
|   store.dispatch("torrentModule/setResultCount", null); | ||||
|   // End on create functions | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -7,10 +7,10 @@ | ||||
|         </h1> | ||||
|         <div v-else> | ||||
|           <loading-placeholder :count="1" /> | ||||
|           <loading-placeholder :count="1" lineClass="short" :top="3.5" /> | ||||
|           <loading-placeholder :count="1" line-class="short" :top="3.5" /> | ||||
|         </div> | ||||
|  | ||||
|         <span class="known-for" v-if="person && person['known_for_department']"> | ||||
|         <span v-if="person && person['known_for_department']" class="known-for"> | ||||
|           {{ | ||||
|             person.known_for_department === "Acting" | ||||
|               ? "Actor" | ||||
| @@ -21,8 +21,9 @@ | ||||
|  | ||||
|       <figure class="person__poster"> | ||||
|         <img | ||||
|           class="person-item__img is-loaded" | ||||
|           ref="poster-image" | ||||
|           class="person-item__img is-loaded" | ||||
|           :alt="`Image of ${person.name}`" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
| @@ -30,9 +31,9 @@ | ||||
|  | ||||
|     <div v-if="loading"> | ||||
|       <loading-placeholder :count="6" /> | ||||
|       <loading-placeholder lineClass="short" :top="3" /> | ||||
|       <loading-placeholder :count="6" lineClass="fullwidth" /> | ||||
|       <loading-placeholder lineClass="short" :top="4.5" /> | ||||
|       <loading-placeholder line-class="short" :top="3" /> | ||||
|       <loading-placeholder :count="6" line-class="fullwidth" /> | ||||
|       <loading-placeholder line-class="short" :top="4.5" /> | ||||
|       <loading-placeholder /> | ||||
|     </div> | ||||
|  | ||||
| @@ -50,17 +51,17 @@ | ||||
|       </Detail> | ||||
|  | ||||
|       <Detail | ||||
|         v-if="creditedShows.length" | ||||
|         title="movies" | ||||
|         :detail="`Credited in ${creditedMovies.length} movies`" | ||||
|         v-if="creditedShows.length" | ||||
|       > | ||||
|         <CastList :cast="creditedMovies" /> | ||||
|       </Detail> | ||||
|  | ||||
|       <Detail | ||||
|         v-if="creditedShows.length" | ||||
|         title="shows" | ||||
|         :detail="`Credited in ${creditedShows.length} shows`" | ||||
|         v-if="creditedShows.length" | ||||
|       > | ||||
|         <CastList :cast="creditedShows" /> | ||||
|       </Detail> | ||||
| @@ -70,17 +71,15 @@ | ||||
|  | ||||
| <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 { getPerson, getPersonCredits } from "../../api"; | ||||
|   import type { | ||||
|     IPerson, | ||||
|     IPersonCredits, | ||||
|     ICast, | ||||
|     IMovie, | ||||
|     IShow | ||||
|   } from "../../interfaces/IList"; | ||||
| @@ -100,38 +99,39 @@ | ||||
|   const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|   const creditedShows: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|  | ||||
|   const poster: ComputedRef<string> = computed(() => computePoster()); | ||||
|   const poster: ComputedRef<string> = computed(() => { | ||||
|     if (!person.value) return "/assets/placeholder.png"; | ||||
|     if (!person.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`; | ||||
|   }); | ||||
|  | ||||
|   const age: ComputedRef<string> = computed(() => { | ||||
|     if (!person.value?.birthday) return; | ||||
|     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; | ||||
|   function setCredits(_credits: IPersonCredits) { | ||||
|     credits.value = _credits; | ||||
|   } | ||||
|  | ||||
|     getPerson(props.id) | ||||
|       .then(_person => (person.value = _person)) | ||||
|       .then(() => getPersonCredits(person.value?.id)) | ||||
|       .then(_credits => (credits.value = _credits)) | ||||
|       .then(() => personCreditedFrom(credits.value?.cast)); | ||||
|   function setPerson(_person: IPerson) { | ||||
|     person.value = _person; | ||||
|   } | ||||
|  | ||||
|   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); | ||||
|   function alreadyExists( | ||||
|     item: IMovie | IShow, | ||||
|     pos: number, | ||||
|     self: Array<IMovie | IShow> | ||||
|   ) { | ||||
|     const names = self.map(_item => _item.title); | ||||
|     return names.indexOf(item.title) === pos; | ||||
|   } | ||||
|  | ||||
| @@ -147,12 +147,21 @@ | ||||
|       .sort(sortPopularity); | ||||
|   } | ||||
|  | ||||
|   const computePoster = () => { | ||||
|     if (!person.value) return "/assets/placeholder.png"; | ||||
|     else if (!person.value?.poster) return "/assets/no-image.svg"; | ||||
|   function fetchPerson() { | ||||
|     if (!props.id) { | ||||
|       console.error("Unable to fetch person, missing id!"); // eslint-disable-line no-console | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`; | ||||
|   }; | ||||
|     getPerson(props.id) | ||||
|       .then(setPerson) | ||||
|       .then(() => getPersonCredits(person.value?.id)) | ||||
|       .then(setCredits) | ||||
|       .then(() => personCreditedFrom(credits.value?.cast)); | ||||
|   } | ||||
|  | ||||
|   // On create functions | ||||
|   fetchPerson(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -3,24 +3,24 @@ | ||||
|     <h3 class="settings__header">Change password</h3> | ||||
|     <form class="form"> | ||||
|       <seasoned-input | ||||
|         v-model="oldPassword" | ||||
|         placeholder="old password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="oldPassword" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         v-model="newPassword" | ||||
|         placeholder="new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="newPassword" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         v-model="newPasswordRepeat" | ||||
|         placeholder="repeat new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="newPasswordRepeat" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-button @click="changePassword">change password</seasoned-button> | ||||
| @@ -34,24 +34,20 @@ | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; | ||||
|   import type { IErrorMessage } from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   interface ResetPasswordPayload { | ||||
|     old_password: string; | ||||
|     new_password: string; | ||||
|   } | ||||
|   // 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, | ||||
| @@ -64,12 +60,12 @@ | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!oldPassword.value || oldPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing old password!", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (!newPassword.value || newPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing new password!", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (newPassword.value !== newPasswordRepeat.value) { | ||||
| @@ -77,7 +73,7 @@ | ||||
|           "Password and password repeat do not match!", | ||||
|           "Validation error" | ||||
|         ); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
| @@ -89,15 +85,14 @@ | ||||
|     try { | ||||
|       validate(); | ||||
|     } catch (error) { | ||||
|       console.log("not valid!"); | ||||
|       return; | ||||
|       console.log("not valid!"); // eslint-disable-line no-console | ||||
|     } | ||||
|  | ||||
|     const body: ResetPasswordPayload = { | ||||
|       old_password: oldPassword.value, | ||||
|       new_password: newPassword.value | ||||
|     }; | ||||
|     const options = {}; | ||||
|     // const body: ResetPasswordPayload = { | ||||
|     //   old_password: oldPassword.value, | ||||
|     //   new_password: newPassword.value | ||||
|     // }; | ||||
|     // const options = {}; | ||||
|     // fetch() | ||||
|   } | ||||
| </script> | ||||
|   | ||||
| @@ -10,14 +10,14 @@ | ||||
|  | ||||
|       <form class="form"> | ||||
|         <seasoned-input | ||||
|           v-model="username" | ||||
|           placeholder="plex username" | ||||
|           type="email" | ||||
|           v-model="username" | ||||
|         /> | ||||
|         <seasoned-input | ||||
|           v-model="password" | ||||
|           placeholder="plex password" | ||||
|           type="password" | ||||
|           v-model="password" | ||||
|           @enter="authenticatePlex" | ||||
|         > | ||||
|         </seasoned-input> | ||||
| @@ -48,9 +48,9 @@ | ||||
|   import seasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import { linkPlexAccount, unlinkPlexAccount } from "../../api"; | ||||
|   import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import type { IErrorMessage } from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   interface Emit { | ||||
| @@ -64,20 +64,11 @@ | ||||
|   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 | ||||
| @@ -92,7 +83,7 @@ | ||||
|     messages.value.push({ | ||||
|       type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error, | ||||
|       title: success ? "Authenticated with plex" : "Something went wrong", | ||||
|       message: message | ||||
|       message | ||||
|     } as IErrorMessage); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div class="container" v-if="query?.length"> | ||||
|   <div v-if="query?.length" class="container"> | ||||
|     <h2 class="torrent-header-text"> | ||||
|       Searching for: <span class="query">{{ query }}</span> | ||||
|     </h2> | ||||
| @@ -22,22 +22,19 @@ | ||||
| <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 { searchTorrents, addMagnet } from "../../api"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdb_id?: number; | ||||
|     tmdbId?: 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(); | ||||
| @@ -47,15 +44,12 @@ | ||||
|     error; | ||||
|   } = inject("notifications"); | ||||
|  | ||||
|   fetchTorrents(); | ||||
|   function setTorrents(_torrents: ITorrent[]) { | ||||
|     torrents.value = _torrents || []; | ||||
|   } | ||||
|  | ||||
|   function fetchTorrents() { | ||||
|     loading.value = true; | ||||
|  | ||||
|     searchTorrents(props.query) | ||||
|       .then(torrentResponse => (torrents.value = torrentResponse?.results)) | ||||
|       .then(() => updateResultCountDisplay()) | ||||
|       .finally(() => (loading.value = false)); | ||||
|   function setLoading(state: boolean) { | ||||
|     loading.value = state; | ||||
|   } | ||||
|  | ||||
|   function updateResultCountDisplay() { | ||||
| @@ -66,6 +60,15 @@ | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function fetchTorrents() { | ||||
|     loading.value = true; | ||||
|  | ||||
|     searchTorrents(props.query) | ||||
|       .then(torrentResponse => setTorrents(torrentResponse?.results)) | ||||
|       .then(() => updateResultCountDisplay()) | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   function addTorrent(torrent: ITorrent) { | ||||
|     const { name, magnet } = torrent; | ||||
|  | ||||
| @@ -75,16 +78,15 @@ | ||||
|       timeout: 3000 | ||||
|     }); | ||||
|  | ||||
|     addMagnet(magnet, name, props.tmdb_id) | ||||
|       .then(resp => { | ||||
|     addMagnet(magnet, name, props.tmdbId) | ||||
|       .then(() => { | ||||
|         notifications.success({ | ||||
|           title: "Torrent added 🎉", | ||||
|           description: props.query, | ||||
|           timeout: 3000 | ||||
|         }); | ||||
|       }) | ||||
|       .catch(resp => { | ||||
|         console.log("Error while adding torrent:", resp?.data); | ||||
|       .catch(() => { | ||||
|         notifications.error({ | ||||
|           title: "Failed to add torrent 🙅♀️", | ||||
|           description: "Check console for more info", | ||||
| @@ -92,6 +94,8 @@ | ||||
|         }); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   fetchTorrents(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -5,8 +5,8 @@ | ||||
|         <th | ||||
|           v-for="column in columns" | ||||
|           :key="column" | ||||
|           @click="sortTable(column)" | ||||
|           :class="column === selectedColumn ? 'active' : null" | ||||
|           @click="sortTable(column)" | ||||
|         > | ||||
|           {{ column }} | ||||
|           <span v-if="prevCol === column && direction">↑</span> | ||||
| @@ -18,13 +18,32 @@ | ||||
|     <tbody> | ||||
|       <tr | ||||
|         v-for="torrent in torrents" | ||||
|         class="table__content" | ||||
|         :key="torrent.magnet" | ||||
|         class="table__content" | ||||
|       > | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.name }} | ||||
|         </td> | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.seed }} | ||||
|         </td> | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.size }} | ||||
|         </td> | ||||
|         <td | ||||
|           class="download" | ||||
|           @click="() => emit('magnet', torrent)" | ||||
|           @keydown.enter="() => emit('magnet', torrent)" | ||||
|         > | ||||
|         <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> | ||||
| @@ -35,8 +54,8 @@ | ||||
| <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 { sortableSize } from "../../utils"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
| @@ -87,18 +106,6 @@ | ||||
|     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) { | ||||
| @@ -112,11 +119,11 @@ | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(a.seed) - parseInt(b.seed) | ||||
|         (a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10) | ||||
|       ); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(b.seed) - parseInt(a.seed) | ||||
|         (a, b) => parseInt(b.seed, 10) - parseInt(a.seed, 10) | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| @@ -133,6 +140,18 @@ | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -2,13 +2,15 @@ | ||||
|   <div> | ||||
|     <torrent-search-results | ||||
|       :query="query" | ||||
|       :tmdb_id="tmdb_id" | ||||
|       :tmdb-id="tmdbId" | ||||
|       :class="{ truncated: truncated }" | ||||
|       ><div | ||||
|         v-if="truncated" | ||||
|         class="load-more" | ||||
|         tabindex="0" | ||||
|         role="button" | ||||
|         @click="truncated = false" | ||||
|         @keydown.enter="truncated = false" | ||||
|       > | ||||
|         <icon-arrow-down /> | ||||
|       </div> | ||||
| @@ -32,7 +34,7 @@ | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdb_id?: number; | ||||
|     tmdbId?: number; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   | ||||
| @@ -1,13 +1,23 @@ | ||||
| <template> | ||||
|   <div class="darkToggle"> | ||||
|     <span @click="toggleDarkmode">{{ darkmodeToggleIcon }}</span> | ||||
|     <span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{ | ||||
|       darkmodeToggleIcon | ||||
|     }}</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|  | ||||
|   let darkmode = ref(systemDarkModeEnabled()); | ||||
|   function systemDarkModeEnabled() { | ||||
|     const computedStyle = window.getComputedStyle(document.body); | ||||
|     if (computedStyle?.colorScheme != null) { | ||||
|       return computedStyle.colorScheme.includes("dark"); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   const darkmode = ref(systemDarkModeEnabled()); | ||||
|   const darkmodeToggleIcon = computed(() => { | ||||
|     return darkmode.value ? "🌝" : "🌚"; | ||||
|   }); | ||||
| @@ -16,14 +26,6 @@ | ||||
|     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> | ||||
|   | ||||
| @@ -2,9 +2,9 @@ | ||||
|   <div | ||||
|     class="nav__hamburger" | ||||
|     :class="{ open: isOpen }" | ||||
|     tabindex="0" | ||||
|     @click="toggle" | ||||
|     @keydown.enter="toggle" | ||||
|     tabindex="0" | ||||
|   > | ||||
|     <div v-for="(_, index) in 3" :key="index" class="bar"></div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div :class="`loader type-${type}`"> | ||||
|   <div :class="`loader type-${type || LoaderHeightType.Page}`"> | ||||
|     <i class="loader--icon"> | ||||
|       <i class="loader--icon-spinner" /> | ||||
|     </i> | ||||
| @@ -13,17 +13,13 @@ | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   enum LoaderHeightType { | ||||
|     Page = "page", | ||||
|     Section = "section" | ||||
|   } | ||||
|   import LoaderHeightType from "../../interfaces/ILoader"; | ||||
|  | ||||
|   interface Props { | ||||
|     type?: LoaderHeightType; | ||||
|   } | ||||
|  | ||||
|   const { type = LoaderHeightType.Page } = defineProps<Props>(); | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| <template> | ||||
|   <div class="text-input__loading" :style="`margin-top: ${top}rem`"> | ||||
|   <div class="text-input__loading" :style="`margin-top: ${top || 0}rem`"> | ||||
|     <div | ||||
|       class="text-input__loading--line" | ||||
|       :class="lineClass" | ||||
|       v-for="l in Array(count)" | ||||
|       v-for="l in Array(count || 1)" | ||||
|       :key="l" | ||||
|       class="text-input__loading--line" | ||||
|       :class="lineClass || ''" | ||||
|     ></div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -13,12 +13,12 @@ | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     count?: Number; | ||||
|     lineClass?: String; | ||||
|     top?: Number; | ||||
|     count?: number; | ||||
|     lineClass?: string; | ||||
|     top?: number; | ||||
|   } | ||||
|  | ||||
|   const { count = 1, lineClass = "", top = 0 } = defineProps<Props>(); | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,27 +1,26 @@ | ||||
| <template> | ||||
|   <button | ||||
|     type="button" | ||||
|     @click="emit('click')" | ||||
|     :class="{ active: active, fullwidth: fullWidth }" | ||||
|     @click="emit('click')" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     active?: Boolean; | ||||
|     fullWidth?: Boolean; | ||||
|     active?: boolean; | ||||
|     fullWidth?: boolean; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "click"); | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -2,9 +2,10 @@ | ||||
|   <div class="group" :class="{ completed: modelValue, focus }"> | ||||
|     <component :is="inputIcon" v-if="inputIcon" /> | ||||
|  | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label --> | ||||
|     <input | ||||
|       class="input" | ||||
|       :type="toggledType || type" | ||||
|       :type="toggledType || type || 'text'" | ||||
|       :placeholder="placeholder" | ||||
|       :value="modelValue" | ||||
|       @input="handleInput" | ||||
| @@ -15,10 +16,10 @@ | ||||
|  | ||||
|     <i | ||||
|       v-if="modelValue && type === 'password'" | ||||
|       @click="toggleShowPassword" | ||||
|       @keydown.enter="toggleShowPassword" | ||||
|       class="show noselect" | ||||
|       tabindex="0" | ||||
|       @click="toggleShowPassword" | ||||
|       @keydown.enter="toggleShowPassword" | ||||
|       >{{ toggledType == "password" ? "show" : "hide" }}</i | ||||
|     > | ||||
|   </div> | ||||
| @@ -43,16 +44,16 @@ | ||||
|     (e: "update:modelValue", value: string); | ||||
|   } | ||||
|  | ||||
|   const { placeholder, type = "text", modelValue } = defineProps<Props>(); | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const toggledType: Ref<string> = ref(type); | ||||
|   const toggledType: Ref<string> = ref(props.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; | ||||
|     if (props.type === "password") return IconKey; | ||||
|     if (props.type === "email") return IconEmail; | ||||
|     if (props.type === "torrents") return IconBinoculars; | ||||
|     return false; | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| <template> | ||||
|   <transition-group name="fade"> | ||||
|     <div | ||||
|       class="card" | ||||
|       v-for="(message, index) in messages" | ||||
|       :key="generateMessageKey(index, message)" | ||||
|       class="card" | ||||
|       :class="message.type || 'warning'" | ||||
|     > | ||||
|       <span class="pinstripe"></span> | ||||
|       <div class="content"> | ||||
|         <h2 class="title"> | ||||
|           {{ message.title }} | ||||
|           {{ message.title || titleFromType(message.type) }} | ||||
|         </h2> | ||||
|         <span v-if="message.message" class="message">{{ | ||||
|           message.message | ||||
| @@ -27,7 +27,6 @@ | ||||
|     ErrorMessageTypes, | ||||
|     IErrorMessage | ||||
|   } from "../../interfaces/IErrorMessage"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     messages: IErrorMessage[]; | ||||
| @@ -52,8 +51,9 @@ | ||||
|   } | ||||
|  | ||||
|   function dismiss(index: number) { | ||||
|     props.messages.splice(index, 1); | ||||
|     emit("update:messages", [...props.messages]); | ||||
|     const _messages = [...props.messages]; | ||||
|     _messages.splice(index, 1); | ||||
|     emit("update:messages", _messages); | ||||
|   } | ||||
|  | ||||
|   function generateMessageKey( | ||||
|   | ||||
| @@ -4,8 +4,8 @@ | ||||
|       v-for="option in options" | ||||
|       :key="option" | ||||
|       class="toggle-button" | ||||
|       @click="toggleTo(option)" | ||||
|       :class="selected === option ? 'selected' : null" | ||||
|       @click="() => toggleTo(option)" | ||||
|     > | ||||
|       {{ option }} | ||||
|     </button> | ||||
| @@ -13,8 +13,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     options: string[]; | ||||
|   | ||||
| @@ -4,9 +4,9 @@ | ||||
|     viewBox="0 0 32 32" | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     style="transition-duration: 0s" | ||||
|     @click="$emit('click')" | ||||
|     @keydown="event => $emit('keydown', event)" | ||||
|     style="transition-duration: 0s" | ||||
|   > | ||||
|     <path | ||||
|       fill="inherit" | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     @click="$emit('click')" | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     viewBox="0 0 32 32" | ||||
|     @click="$emit('click')" | ||||
|     @keydown.enter="$emit('click')" | ||||
|   > | ||||
|     <path | ||||
|       d="M30.229 1.771c-1.142-1.142-2.658-1.771-4.275-1.771s-3.133 0.629-4.275 1.771l-18.621 18.621c-0.158 0.158-0.275 0.358-0.337 0.575l-2.667 9.333c-0.133 0.467-0.004 0.967 0.338 1.308 0.254 0.254 0.596 0.392 0.942 0.392 0.121 0 0.246-0.017 0.367-0.050l9.333-2.667c0.217-0.063 0.417-0.179 0.575-0.337l18.621-18.621c2.358-2.362 2.358-6.196 0-8.554zM6.079 21.137l14.392-14.392 4.779 4.779-14.387 14.396-4.783-4.783zM21.413 5.804l1.058-1.058 4.779 4.779-1.058 1.058-4.779-4.779zM5.167 22.108l4.725 4.725-6.617 1.892 1.892-6.617zM28.346 8.438l-0.15 0.15-4.783-4.783 0.15-0.15c0.642-0.637 1.488-0.988 2.392-0.988s1.75 0.35 2.392 0.992c1.317 1.317 1.317 3.458 0 4.779z" | ||||
|   | ||||
| @@ -4,18 +4,18 @@ | ||||
|     viewBox="0 0 32 32" | ||||
|     width="100%" | ||||
|     height="100%" | ||||
|     style="transition-duration: 0s;" | ||||
|     style="transition-duration: 0s" | ||||
|   > | ||||
|     <path | ||||
|       style="transition-duration: 0s;" | ||||
|       style="transition-duration: 0s" | ||||
|       d="M29.333 2.667h-26.667c-1.471 0-2.667 1.196-2.667 2.667v21.333c0 1.471 1.196 2.667 2.667 2.667h26.667c1.471 0 2.667-1.196 2.667-2.667v-21.333c0-1.471-1.196-2.667-2.667-2.667zM29.333 26.667h-26.667v-21.333h26.667v21.333c0.004 0 0 0 0 0z" | ||||
|     ></path> | ||||
|     <path | ||||
|       style="transition-duration: 0s;" | ||||
|       style="transition-duration: 0s" | ||||
|       d="M11.333 17.058l-4.667 4.667v-1.725h-1.333v3.333c0 0.367 0.3 0.667 0.667 0.667h3.333v-1.333h-1.725l4.667-4.667-0.942-0.942z" | ||||
|     ></path> | ||||
|     <path | ||||
|       style="transition-duration: 0s;" | ||||
|       style="transition-duration: 0s" | ||||
|       d="M26 8h-3.333v1.333h1.725l-4.667 4.667 0.942 0.942 4.667-4.667v1.725h1.333v-3.333c0-0.367-0.3-0.667-0.667-0.667z" | ||||
|     ></path> | ||||
|   </svg> | ||||
|   | ||||
							
								
								
									
										163
									
								
								src/interfaces/IAutocompleteSearch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								src/interfaces/IAutocompleteSearch.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| /* eslint-disable no-use-before-define */ | ||||
| import { MediaTypes } from "./IList"; | ||||
|  | ||||
| export interface IAutocompleteResult { | ||||
|   title: string; | ||||
|   id: number; | ||||
|   adult: boolean; | ||||
|   type: MediaTypes; | ||||
| } | ||||
|  | ||||
| export interface IAutocompleteSearchResults { | ||||
|   took: number; | ||||
|   timed_out: boolean; | ||||
|   _shards: Shards; | ||||
|   hits: Hits; | ||||
| } | ||||
|  | ||||
| export interface Shards { | ||||
|   total: number; | ||||
|   successful: number; | ||||
|   skipped: number; | ||||
|   failed: number; | ||||
| } | ||||
|  | ||||
| export interface Hits { | ||||
|   total: Total; | ||||
|   max_score: null; | ||||
|   hits: Hit[]; | ||||
| } | ||||
|  | ||||
| export interface Hit { | ||||
|   _index: Index; | ||||
|   _type: Type; | ||||
|   _id: string; | ||||
|   _score: number; | ||||
|   _source: Source; | ||||
|   sort: number[]; | ||||
| } | ||||
|  | ||||
| export enum Index { | ||||
|   Movies = "movies", | ||||
|   Shows = "shows" | ||||
| } | ||||
|  | ||||
| export interface Source { | ||||
|   tags: Tag[]; | ||||
|   ecs: Ecs; | ||||
|   "@timestamp": Date; | ||||
|   adult: boolean; | ||||
|   input: Input; | ||||
|   host: Host; | ||||
|   "@version": string; | ||||
|   popularity: number; | ||||
|   log: Log; | ||||
|   video: boolean; | ||||
|   id: number; | ||||
|   agent: Agent; | ||||
|   original_title: string; | ||||
|   original_name?: string; | ||||
| } | ||||
|  | ||||
| export interface Agent { | ||||
|   version: AgentVersion; | ||||
|   ephemeral_id: string; | ||||
|   id: string; | ||||
|   hostname: HostnameEnum; | ||||
|   type: AgentType; | ||||
| } | ||||
|  | ||||
| export enum HostnameEnum { | ||||
|   MACProLocal = "macPro.local" | ||||
| } | ||||
|  | ||||
| export enum AgentType { | ||||
|   Filebeat = "filebeat" | ||||
| } | ||||
|  | ||||
| export enum AgentVersion { | ||||
|   The700 = "7.0.0" | ||||
| } | ||||
|  | ||||
| export interface Ecs { | ||||
|   version: EcsVersion; | ||||
| } | ||||
|  | ||||
| export enum EcsVersion { | ||||
|   The100 = "1.0.0" | ||||
| } | ||||
|  | ||||
| export interface Host { | ||||
|   os: OS; | ||||
|   name: HostnameEnum; | ||||
|   id: ID; | ||||
|   hostname: HostnameEnum; | ||||
|   architecture: Architecture; | ||||
| } | ||||
|  | ||||
| export enum Architecture { | ||||
|   X8664 = "x86_64" | ||||
| } | ||||
|  | ||||
| export enum ID { | ||||
|   The30D157C386235739Aa1E30A9464Fa192 = "30D157C3-8623-5739-AA1E-30A9464FA192" | ||||
| } | ||||
|  | ||||
| export interface OS { | ||||
|   version: OSVersion; | ||||
|   name: OSName; | ||||
|   build: Build; | ||||
|   family: Family; | ||||
|   platform: Family; | ||||
|   kernel: Kernel; | ||||
| } | ||||
|  | ||||
| export enum Build { | ||||
|   The18D109 = "18D109" | ||||
| } | ||||
|  | ||||
| export enum Family { | ||||
|   Darwin = "darwin" | ||||
| } | ||||
|  | ||||
| export enum Kernel { | ||||
|   The1820 = "18.2.0" | ||||
| } | ||||
|  | ||||
| export enum OSName { | ||||
|   MACOSX = "Mac OS X" | ||||
| } | ||||
|  | ||||
| export enum OSVersion { | ||||
|   The10143 = "10.14.3" | ||||
| } | ||||
|  | ||||
| export interface Input { | ||||
|   type: InputType; | ||||
| } | ||||
|  | ||||
| export enum InputType { | ||||
|   Log = "log" | ||||
| } | ||||
|  | ||||
| export interface Log { | ||||
|   offset: number; | ||||
|   file: File; | ||||
| } | ||||
|  | ||||
| export interface File { | ||||
|   path: string; | ||||
| } | ||||
|  | ||||
| export enum Tag { | ||||
|   BeatsInputRawEvent = "beats_input_raw_event" | ||||
| } | ||||
|  | ||||
| export enum Type { | ||||
|   Doc = "_doc" | ||||
| } | ||||
|  | ||||
| export interface Total { | ||||
|   value: number; | ||||
|   relation: string; | ||||
| } | ||||
							
								
								
									
										36
									
								
								src/interfaces/IGraph.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/interfaces/IGraph.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| /* eslint-disable no-use-before-define */ | ||||
|  | ||||
| export enum GraphTypes { | ||||
|   Plays = "plays", | ||||
|   Duration = "duration" | ||||
| } | ||||
|  | ||||
| export enum GraphValueTypes { | ||||
|   Number = "number", | ||||
|   Time = "time" | ||||
| } | ||||
|  | ||||
| export interface IGraphDataset { | ||||
|   name: string; | ||||
|   data: Array<number>; | ||||
| } | ||||
|  | ||||
| export interface IGraphData { | ||||
|   labels: Array<string>; | ||||
|   series: Array<IGraphDataset>; | ||||
| } | ||||
|  | ||||
| export interface IGraphResponse { | ||||
|   success: boolean; | ||||
|   data: Data; | ||||
| } | ||||
|  | ||||
| export interface Data { | ||||
|   categories: Date[]; | ||||
|   series: Series[]; | ||||
| } | ||||
|  | ||||
| export interface Series { | ||||
|   name: string; | ||||
|   data: number[]; | ||||
| } | ||||
| @@ -1,25 +1,3 @@ | ||||
| export interface IList { | ||||
|   results: ListResults; | ||||
|   page: number; | ||||
|   total_results: number; | ||||
|   total_pages: number; | ||||
| } | ||||
|  | ||||
| export interface IMediaCredits { | ||||
|   cast: Array<ICast>; | ||||
|   crew: Array<ICrew>; | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
| export interface IPersonCredits { | ||||
|   cast: Array<IMovie | IShow>; | ||||
|   crew: Array<ICrew>; | ||||
|   id: number; | ||||
|   type?: string; | ||||
| } | ||||
|  | ||||
| export type ListResults = Array<IMovie | IShow | IPerson | IRequest>; | ||||
|  | ||||
| export enum MediaTypes { | ||||
|   Movie = "movie", | ||||
|   Show = "show", | ||||
| @@ -155,3 +133,25 @@ export interface ICrew { | ||||
|   profile_path: string | null; | ||||
|   type: string; | ||||
| } | ||||
|  | ||||
| export interface IMediaCredits { | ||||
|   cast: Array<ICast>; | ||||
|   crew: Array<ICrew>; | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
| export interface IPersonCredits { | ||||
|   cast: Array<IMovie | IShow>; | ||||
|   crew: Array<ICrew>; | ||||
|   id: number; | ||||
|   type?: string; | ||||
| } | ||||
|  | ||||
| export type ListResults = Array<IMovie | IShow | IPerson | IRequest>; | ||||
|  | ||||
| export interface IList { | ||||
|   results: ListResults; | ||||
|   page: number; | ||||
|   total_results: number; | ||||
|   total_pages: number; | ||||
| } | ||||
|   | ||||
							
								
								
									
										6
									
								
								src/interfaces/ILoader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/interfaces/ILoader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| enum LoaderHeightType { | ||||
|   Page = "page", | ||||
|   Section = "section" | ||||
| } | ||||
|  | ||||
| export default LoaderHeightType; | ||||
| @@ -1,6 +1,6 @@ | ||||
| export default interface INavigationIcon { | ||||
|   title: string; | ||||
|   route: string; | ||||
|   icon: any; | ||||
|   icon: any; // eslint-disable-line @typescript-eslint/no-explicit-any | ||||
|   requiresAuth?: boolean; | ||||
| } | ||||
|   | ||||
| @@ -1,17 +1,13 @@ | ||||
| import type { MediaTypes } from "./IList"; | ||||
| // export enum PopupTypes { | ||||
| //   Movie = "movie", | ||||
| //   Show = "show", | ||||
| //   Person = "person" | ||||
| // } | ||||
|  | ||||
| // export interface IPopupOpen { | ||||
| //   id: string | number; | ||||
| //   type: PopupTypes; | ||||
| // } | ||||
|  | ||||
| export interface IStatePopup { | ||||
|   id: number; | ||||
|   type: MediaTypes; | ||||
|   open: boolean; | ||||
| } | ||||
|  | ||||
| export interface IPopupQuery { | ||||
|   movie?: number | string; | ||||
|   show?: number | string; | ||||
|   person?: number | string; | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import { createApp } from "vue"; | ||||
| import router from "./routes"; | ||||
| import store from "./store"; | ||||
|  | ||||
| import Toast from "./plugins/Toast"; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||||
| const App = require("./App.vue").default; | ||||
|  | ||||
| store.dispatch("darkmodeModule/findAndSetDarkmodeSupported"); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ const state: IStateDarkmode = { | ||||
|   userChoice: undefined | ||||
| }; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-shadow */ | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import type IStateDocumentTitle from "../interfaces/IStateDocumentTitle"; | ||||
| const capitalize = (string: string) => { | ||||
|   if (!string) return; | ||||
|  | ||||
|   /* eslint-disable-next-line consistent-return */ | ||||
|   return string.includes(" ") | ||||
|     ? string | ||||
|         .split(" ") | ||||
| @@ -25,6 +26,7 @@ const state: IStateDocumentTitle = { | ||||
|   title: undefined | ||||
| }; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-shadow, no-return-assign */ | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
| @@ -42,10 +44,10 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   actions: { | ||||
|     updateEmoji({ commit }, emoji: String) { | ||||
|     updateEmoji({ commit }, emoji: string) { | ||||
|       commit("SET_EMOJI", emoji); | ||||
|     }, | ||||
|     updateTitle({ commit }, title: String) { | ||||
|     updateTitle({ commit }, title: string) { | ||||
|       commit("SET_TITLE", title); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ const state: IStateHamburger = { | ||||
|   open: false | ||||
| }; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-shadow, no-return-assign */ | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import router from "../routes"; | ||||
| import { MediaTypes } from "../interfaces/IList"; | ||||
| import type { IStatePopup } from "../interfaces/IStatePopup"; | ||||
| import type { IStatePopup, IPopupQuery } from "../interfaces/IStatePopup"; | ||||
|  | ||||
| /* eslint-disable-next-line import/no-cycle */ | ||||
| import router from "../routes"; | ||||
|  | ||||
| const removeIncludedQueryParams = (params, key) => { | ||||
|   if (params.has(key)) params.delete(key); | ||||
| @@ -9,11 +11,9 @@ const removeIncludedQueryParams = (params, key) => { | ||||
|  | ||||
| function paramsToObject(entries) { | ||||
|   const result = {}; | ||||
|   for (const [key, value] of entries) { | ||||
|     // each 'entry' is a [key, value] tupple | ||||
|   return entries.forEach((key, value) => { | ||||
|     result[key] = value; | ||||
|   } | ||||
|   return result; | ||||
|   }); | ||||
| } | ||||
|  | ||||
| const updateQueryParams = (id: number = null, type: MediaTypes = null) => { | ||||
| @@ -38,6 +38,7 @@ const state: IStatePopup = { | ||||
|   open: false | ||||
| }; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-shadow */ | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
| @@ -60,7 +61,10 @@ export default { | ||||
|   }, | ||||
|   actions: { | ||||
|     open: ({ commit }, { id, type }: { id: number; type: MediaTypes }) => { | ||||
|       if (!isNaN(id)) id = Number(id); | ||||
|       if (!Number.isNaN(id)) { | ||||
|         id = Number(id); /* eslint-disable-line no-param-reassign */ | ||||
|       } | ||||
|  | ||||
|       commit("SET_OPEN", { id, type }); | ||||
|       updateQueryParams(id, type); | ||||
|     }, | ||||
| @@ -68,11 +72,11 @@ export default { | ||||
|       commit("SET_CLOSE"); | ||||
|       updateQueryParams(); // reset | ||||
|     }, | ||||
|     resetStateFromUrlQuery: ({ commit }, query: any) => { | ||||
|     resetStateFromUrlQuery: ({ commit }, query: IPopupQuery) => { | ||||
|       let { movie, show, person } = query; | ||||
|       movie = !isNaN(movie) ? Number(movie) : movie; | ||||
|       show = !isNaN(show) ? Number(show) : show; | ||||
|       person = !isNaN(person) ? Number(person) : person; | ||||
|       movie = !Number.isNaN(movie) ? Number(movie) : movie; | ||||
|       show = !Number.isNaN(show) ? Number(show) : show; | ||||
|       person = !Number.isNaN(person) ? Number(person) : person; | ||||
|  | ||||
|       if (movie) commit("SET_OPEN", { id: movie, type: "movie" }); | ||||
|       else if (show) commit("SET_OPEN", { id: show, type: "show" }); | ||||
|   | ||||
| @@ -6,12 +6,10 @@ const state: IStateTorrent = { | ||||
|   resultCount: null | ||||
| }; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-shadow */ | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state: { | ||||
|     results: [], | ||||
|     resultCount: null | ||||
|   }, | ||||
|   state, | ||||
|   getters: { | ||||
|     results: (state: IStateTorrent) => { | ||||
|       return state.results; | ||||
|   | ||||
| @@ -56,6 +56,7 @@ export default { | ||||
|     username: state => state.username, | ||||
|     settings: state => state.settings, | ||||
|     token: state => state.token, | ||||
|     // loggedIn: state => true, | ||||
|     loggedIn: state => state && state.username !== null, | ||||
|     admin: state => state.admin, | ||||
|     plexId: state => { | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| <template> | ||||
|   <div class="wrapper" v-if="plexId"> | ||||
|   <div v-if="plexId" class="wrapper"> | ||||
|     <h1>Your watch activity</h1> | ||||
|  | ||||
|     <div style="display: flex; flex-direction: row"> | ||||
|       <label class="filter"> | ||||
|       <label class="filter" for="dayinput"> | ||||
|         <span>Days:</span> | ||||
|         <input | ||||
|           class="dayinput" | ||||
|           id="dayinput" | ||||
|           v-model="days" | ||||
|           class="dayinput" | ||||
|           placeholder="days" | ||||
|           type="number" | ||||
|           pattern="[0-9]*" | ||||
| @@ -15,15 +16,15 @@ | ||||
|         /> | ||||
|       </label> | ||||
|  | ||||
|       <label class="filter"> | ||||
|       <div class="filter"> | ||||
|         <span>Data sorted by:</span> | ||||
|         <toggle-button | ||||
|           v-model:selected="graphViewMode" | ||||
|           class="filter-item" | ||||
|           :options="[GraphTypes.Plays, GraphTypes.Duration]" | ||||
|           v-model:selected="graphViewMode" | ||||
|           @change="fetchChartData" | ||||
|         /> | ||||
|       </label> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="chart-section"> | ||||
| @@ -34,9 +35,9 @@ | ||||
|           :data="playsByDayData" | ||||
|           type="line" | ||||
|           :stacked="false" | ||||
|           :datasetDescriptionSuffix="`watch last ${days} days`" | ||||
|           :tooltipDescriptionSuffix="selectedGraphViewMode.tooltipLabel" | ||||
|           :graphValueType="selectedGraphViewMode.valueType" | ||||
|           :dataset-description-suffix="`watch last ${days} days`" | ||||
|           :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel" | ||||
|           :graph-value-type="selectedGraphViewMode.valueType" | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -44,13 +45,12 @@ | ||||
|       <div class="graph"> | ||||
|         <Graph | ||||
|           v-if="playsByDayofweekData" | ||||
|           class="graph" | ||||
|           :data="playsByDayofweekData" | ||||
|           type="bar" | ||||
|           :stacked="true" | ||||
|           :datasetDescriptionSuffix="`watch last ${days} days`" | ||||
|           :tooltipDescriptionSuffix="selectedGraphViewMode.tooltipLabel" | ||||
|           :graphValueType="selectedGraphViewMode.valueType" | ||||
|           :dataset-description-suffix="`watch last ${days} days`" | ||||
|           :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel" | ||||
|           :graph-value-type="selectedGraphViewMode.valueType" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
| @@ -61,23 +61,18 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, onMounted } from "vue"; | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import Graph from "@/components/Graph.vue"; | ||||
|   import ToggleButton from "@/components/ui/ToggleButton.vue"; | ||||
|   import IconStop from "@/icons/IconStop.vue"; | ||||
|   import { fetchGraphData } from "../api"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   enum GraphTypes { | ||||
|     Plays = "plays", | ||||
|     Duration = "duration" | ||||
|   } | ||||
|  | ||||
|   enum GraphValueTypes { | ||||
|     Number = "number", | ||||
|     Time = "time" | ||||
|   } | ||||
|   import { fetchGraphData } from "../api"; | ||||
|   import { | ||||
|     GraphTypes, | ||||
|     GraphValueTypes, | ||||
|     IGraphData | ||||
|   } from "../interfaces/IGraph"; | ||||
|  | ||||
|   const store = useStore(); | ||||
|  | ||||
| @@ -85,9 +80,6 @@ | ||||
|   const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays); | ||||
|   const plexId = computed(() => store.getters["user/plexId"]); | ||||
|  | ||||
|   const selectedGraphViewMode = computed(() => | ||||
|     graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value) | ||||
|   ); | ||||
|   const graphValueViewMode = [ | ||||
|     { | ||||
|       type: GraphTypes.Plays, | ||||
| @@ -101,13 +93,27 @@ | ||||
|     } | ||||
|   ]; | ||||
|  | ||||
|   const playsByDayData: Ref<any> = ref(null); | ||||
|   const playsByDayofweekData: Ref<any> = ref(null); | ||||
|   fetchChartData(); | ||||
|   const playsByDayData: Ref<IGraphData> = ref(null); | ||||
|   const playsByDayofweekData: Ref<IGraphData> = ref(null); | ||||
|  | ||||
|   function fetchChartData() { | ||||
|     fetchPlaysByDay(); | ||||
|     fetchPlaysByDayOfWeek(); | ||||
|   const selectedGraphViewMode = computed(() => | ||||
|     graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value) | ||||
|   ); | ||||
|  | ||||
|   function convertDateStringToDayMonth(date: string): string { | ||||
|     if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) { | ||||
|       return date; | ||||
|     } | ||||
|  | ||||
|     const [, month, day] = date.split("-"); | ||||
|     return `${day}.${month}`; | ||||
|   } | ||||
|  | ||||
|   function convertDateLabels(data) { | ||||
|     return { | ||||
|       labels: data.categories.map(convertDateStringToDayMonth), | ||||
|       series: data.series | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async function fetchPlaysByDay() { | ||||
| @@ -126,21 +132,12 @@ | ||||
|     ).then(data => convertDateLabels(data?.data)); | ||||
|   } | ||||
|  | ||||
|   function convertDateLabels(data) { | ||||
|     return { | ||||
|       labels: data.categories.map(convertDateStringToDayMonth), | ||||
|       series: data.series | ||||
|     }; | ||||
|   function fetchChartData() { | ||||
|     fetchPlaysByDay(); | ||||
|     fetchPlaysByDayOfWeek(); | ||||
|   } | ||||
|  | ||||
|   function convertDateStringToDayMonth(date: string): string { | ||||
|     if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) { | ||||
|       return date; | ||||
|     } | ||||
|  | ||||
|     const [year, month, day] = date.split("-"); | ||||
|     return `${day}.${month}`; | ||||
|   } | ||||
|   fetchChartData(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <ResultsSection :title="listName" :apiFunction="_getTmdbMovieListByName" /> | ||||
|   <ResultsSection :title="listName" :api-function="_getTmdbMovieListByName" /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| <template> | ||||
|   <section class="profile"> | ||||
|     <div class="profile__content" v-if="loggedIn"> | ||||
|     <div v-if="loggedIn" class="profile__content"> | ||||
|       <header class="profile__header"> | ||||
|         <h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2> | ||||
|  | ||||
|         <div class="button--group"> | ||||
|           <seasoned-button @click="toggleSettings" :active="showSettings">{{ | ||||
|           <seasoned-button :active="showSettings" @click="toggleSettings">{{ | ||||
|             showSettings ? "hide settings" : "show settings" | ||||
|           }}</seasoned-button> | ||||
|           <seasoned-button @click="toggleActivity" :active="showActivity">{{ | ||||
|           <seasoned-button :active="showActivity" @click="toggleActivity">{{ | ||||
|             showActivity ? "hide activity" : "show activity" | ||||
|           }}</seasoned-button> | ||||
|  | ||||
| @@ -16,15 +16,15 @@ | ||||
|         </div> | ||||
|       </header> | ||||
|  | ||||
|       <settings v-if="showSettings" /> | ||||
|       <settings-page v-if="showSettings" /> | ||||
|  | ||||
|       <activity v-if="showActivity" /> | ||||
|       <activity-page v-if="showActivity" /> | ||||
|  | ||||
|       <page-header title="Your requests" :info="resultCount" /> | ||||
|       <results-list v-if="results" :results="results" /> | ||||
|     </div> | ||||
|  | ||||
|     <section class="not-found" v-if="!loggedIn"> | ||||
|     <section v-if="!loggedIn" class="not-found"> | ||||
|       <div class="not-found__content"> | ||||
|         <h2 class="not-found__title">Authentication Request Failed</h2> | ||||
|         <router-link :to="{ name: 'signin' }" exact title="Sign in here"> | ||||
| @@ -40,11 +40,11 @@ | ||||
|   import { useStore } from "vuex"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ResultsList from "@/components/ResultsList.vue"; | ||||
|   import Settings from "@/pages/SettingsPage.vue"; | ||||
|   import Activity from "@/pages/ActivityPage.vue"; | ||||
|   import SettingsPage from "@/pages/SettingsPage.vue"; | ||||
|   import ActivityPage from "@/pages/ActivityPage.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import { getEmoji, getUserRequests, getSettings, logout } from "../api"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import { getEmoji, getUserRequests } from "../api"; | ||||
|   import type { ListResults } from "../interfaces/IList"; | ||||
|  | ||||
|   const emoji: Ref<string> = ref(""); | ||||
| @@ -57,7 +57,6 @@ | ||||
|  | ||||
|   const loggedIn: Ref<boolean> = computed(() => store.getters["user/loggedIn"]); | ||||
|   const username: Ref<string> = computed(() => store.getters["user/username"]); | ||||
|   const settings: Ref<object> = computed(() => store.getters["user/settings"]); | ||||
|  | ||||
|   const resultCount: ComputedRef<number | string> = computed(() => { | ||||
|     const currentCount = results?.value?.length || 0; | ||||
| @@ -65,6 +64,10 @@ | ||||
|     return `${currentCount} of ${totalCount} results`; | ||||
|   }); | ||||
|  | ||||
|   function setEmoji(_emoji: string) { | ||||
|     emoji.value = _emoji; | ||||
|   } | ||||
|  | ||||
|   // Component loaded actions | ||||
|   getUserRequests().then(requestResults => { | ||||
|     if (!requestResults?.results) return; | ||||
| @@ -72,26 +75,12 @@ | ||||
|     totalResults.value = requestResults.total_results; | ||||
|   }); | ||||
|  | ||||
|   getEmoji().then(resp => (emoji.value = resp?.emoji)); | ||||
|   getEmoji().then(resp => setEmoji(resp?.emoji)); | ||||
|  | ||||
|   showSettings.value = window.location.toString().includes("settings=true"); | ||||
|   showActivity.value = window.location.toString().includes("activity=true"); | ||||
|   // Component loaded actions end | ||||
|  | ||||
|   function toggleSettings() { | ||||
|     showSettings.value = !showSettings.value; | ||||
|     updateQueryParams("settings", showSettings.value); | ||||
|   } | ||||
|  | ||||
|   function toggleActivity() { | ||||
|     showActivity.value = !showActivity.value; | ||||
|     updateQueryParams("activity", showActivity.value); | ||||
|   } | ||||
|  | ||||
|   function _logout() { | ||||
|     store.dispatch("user/logout"); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams(key, value = false) { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     if (params.has(key)) { | ||||
| @@ -112,6 +101,20 @@ | ||||
|       }` | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function toggleSettings() { | ||||
|     showSettings.value = !showSettings.value; | ||||
|     updateQueryParams("settings", showSettings.value); | ||||
|   } | ||||
|  | ||||
|   function toggleActivity() { | ||||
|     showActivity.value = !showActivity.value; | ||||
|     updateQueryParams("activity", showActivity.value); | ||||
|   } | ||||
|  | ||||
|   function _logout() { | ||||
|     store.dispatch("user/logout"); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -2,27 +2,27 @@ | ||||
|   <section> | ||||
|     <h1>Register new user</h1> | ||||
|  | ||||
|     <form class="form" ref="formElement"> | ||||
|     <form ref="formElement" class="form"> | ||||
|       <seasoned-input | ||||
|         v-model="username" | ||||
|         placeholder="username" | ||||
|         icon="Email" | ||||
|         type="email" | ||||
|         v-model="username" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         v-model="password" | ||||
|         placeholder="password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="password" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|       <seasoned-input | ||||
|         v-model="passwordRepeat" | ||||
|         placeholder="repeat password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="passwordRepeat" | ||||
|         @keydown.enter="submit" | ||||
|       /> | ||||
|  | ||||
| @@ -44,10 +44,10 @@ | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { register } from "../api"; | ||||
|   import { focusFirstFormInput, focusOnNextElement } from "../utils"; | ||||
|   import { ErrorMessageTypes } from "../interfaces/IErrorMessage"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { IErrorMessage } from "../interfaces/IErrorMessage"; | ||||
|  | ||||
|   const username: Ref<string> = ref(""); | ||||
| @@ -85,32 +85,27 @@ | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!username.value || username?.value?.length === 0) { | ||||
|         addWarningMessage("Missing username", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (!password.value || password?.value?.length === 0) { | ||||
|         addWarningMessage("Missing password", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (passwordRepeat.value == null || passwordRepeat.value.length == 0) { | ||||
|       if (passwordRepeat.value == null || passwordRepeat.value.length === 0) { | ||||
|         addWarningMessage("Missing repeat password", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|       if (passwordRepeat != password) { | ||||
|       if (passwordRepeat.value !== password.value) { | ||||
|         addWarningMessage("Passwords do not match", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(registerUser); | ||||
|   } | ||||
|  | ||||
|   function registerUser() { | ||||
|     register(username.value, password.value) | ||||
|       .then(data => { | ||||
| @@ -120,15 +115,19 @@ | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error?.status === 401) { | ||||
|           return addErrorMessage( | ||||
|             "Incorrect username or password", | ||||
|             "Access denied" | ||||
|           ); | ||||
|           addErrorMessage("Incorrect username or password", "Access denied"); | ||||
|           return null; | ||||
|         } | ||||
|  | ||||
|         addErrorMessage(error?.message, "Unexpected error"); | ||||
|         return null; | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(registerUser); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <ResultsSection title="Requests" :apiFunction="getRequests" /> | ||||
|   <ResultsSection title="Requests" :api-function="getRequests" /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   | ||||
| @@ -1,18 +1,18 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div style="display: flex; flex-direction: row"> | ||||
|       <label class="filter"> | ||||
|       <div class="filter"> | ||||
|         <span>Search filter:</span> | ||||
|  | ||||
|         <toggle-button | ||||
|           :options="toggleOptions" | ||||
|           v-model:selected="mediaType" | ||||
|           :options="toggleOptions" | ||||
|           @change="toggleChanged" | ||||
|         /> | ||||
|       </label> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <ResultsSection v-if="query" :title="title" :apiFunction="search" /> | ||||
|     <ResultsSection v-if="query" :title="title" :api-function="search" /> | ||||
|     <h1 v-else class="no-results">No query found, please search above</h1> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -20,12 +20,11 @@ | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useRoute, useRouter } from "vue-router"; | ||||
|   import { searchTmdb } from "../api"; | ||||
|  | ||||
|   import ResultsSection from "@/components/ResultsSection.vue"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ToggleButton from "@/components/ui/ToggleButton.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { searchTmdb } from "../api"; | ||||
|   import { MediaTypes } from "../interfaces/IList"; | ||||
|  | ||||
|   // interface ISearchParams { | ||||
| @@ -54,20 +53,14 @@ | ||||
|     mediaType.value = (urlQuery?.media_type as MediaTypes) || mediaType.value; | ||||
|   } | ||||
|  | ||||
|   let search = ( | ||||
|   const search = ( | ||||
|     _page = page.value || 1, | ||||
|     _mediaType = mediaType.value || "all" | ||||
|   ) => { | ||||
|     return searchTmdb(query.value, _page, adult.value, _mediaType); | ||||
|   }; | ||||
|  | ||||
|   function toggleChanged() { | ||||
|     updateQueryParams(); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams() { | ||||
|     const { query, page, adult, media_type } = route.query; | ||||
|  | ||||
|     router.push({ | ||||
|       path: "search", | ||||
|       query: { | ||||
| @@ -76,6 +69,10 @@ | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function toggleChanged() { | ||||
|     updateQueryParams(); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -11,12 +11,28 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { inject } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import ChangePassword from "@/components/profile/ChangePassword.vue"; | ||||
|   import LinkPlexAccount from "@/components/profile/LinkPlexAccount.vue"; | ||||
|   import { getSettings } from "../api"; | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const route = useRoute(); | ||||
|   const notifications: { | ||||
|     error; | ||||
|   } = inject("notifications"); | ||||
|  | ||||
|   function displayWarningIfMissingPlexAccount() { | ||||
|     if (route.query?.missingPlexAccount === "true") { | ||||
|       notifications.error({ | ||||
|         title: "Missing plex account 🧲", | ||||
|         description: "Link your plex account to view activity", | ||||
|         timeout: 10000 | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function reloadSettings() { | ||||
|     return getSettings().then(response => { | ||||
| @@ -26,6 +42,9 @@ | ||||
|       store.dispatch("user/setSettings", settings); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Functions called on component load | ||||
|   displayWarningIfMissingPlexAccount(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   | ||||
| @@ -2,19 +2,19 @@ | ||||
|   <section> | ||||
|     <h1>Sign in</h1> | ||||
|  | ||||
|     <form class="form" ref="formElement"> | ||||
|     <form ref="formElement" class="form"> | ||||
|       <seasoned-input | ||||
|         v-model="username" | ||||
|         placeholder="username" | ||||
|         icon="Email" | ||||
|         type="email" | ||||
|         v-model="username" | ||||
|         @keydown.enter="focusOnNextElement" | ||||
|       /> | ||||
|       <seasoned-input | ||||
|         v-model="password" | ||||
|         placeholder="password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|         v-model="password" | ||||
|         @keydown.enter="submit" | ||||
|       /> | ||||
|  | ||||
| @@ -35,10 +35,10 @@ | ||||
|   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 { login } from "../api"; | ||||
|   import { focusFirstFormInput, focusOnNextElement } from "../utils"; | ||||
|   import { ErrorMessageTypes } from "../interfaces/IErrorMessage"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { IErrorMessage } from "../interfaces/IErrorMessage"; | ||||
|  | ||||
|   const username: Ref<string> = ref(""); | ||||
| @@ -75,23 +75,18 @@ | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!username.value || username?.value?.length === 0) { | ||||
|         addWarningMessage("Missing username", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (!password.value || password?.value?.length === 0) { | ||||
|         addWarningMessage("Missing password", "Validation error"); | ||||
|         return reject(); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(signin); | ||||
|   } | ||||
|  | ||||
|   function signin() { | ||||
|     login(username.value, password.value, true) | ||||
|       .then(data => { | ||||
| @@ -101,15 +96,19 @@ | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error?.status === 401) { | ||||
|           return addErrorMessage( | ||||
|             "Incorrect username or password", | ||||
|             "Access denied" | ||||
|           ); | ||||
|           addErrorMessage("Incorrect username or password", "Access denied"); | ||||
|           return null; | ||||
|         } | ||||
|  | ||||
|         addErrorMessage(error?.message, "Unexpected error"); | ||||
|         return null; | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   function submit() { | ||||
|     clearMessages(); | ||||
|     validate().then(signin); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -7,8 +7,8 @@ | ||||
|         <seasoned-input | ||||
|           v-model="query" | ||||
|           type="torrents" | ||||
|           @keydown.enter="setTorrentQuery" | ||||
|           placeholder="Search torrents" | ||||
|           @keydown.enter="setTorrentQuery" | ||||
|         /> | ||||
|         <seasoned-button @click="setTorrentQuery">Search</seasoned-button> | ||||
|       </div> | ||||
| @@ -27,8 +27,8 @@ | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import TorrentList from "@/components/torrent/TorrentSearchResults.vue"; | ||||
|   import ActiveTorrents from "@/components/torrent/ActiveTorrents.vue"; | ||||
|   import { getValueFromUrlQuery, setUrlQueryParameter } from "../utils"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { getValueFromUrlQuery, setUrlQueryParameter } from "../utils"; | ||||
|  | ||||
|   const urlQuery = getValueFromUrlQuery("query"); | ||||
|  | ||||
|   | ||||
| @@ -1,20 +1,24 @@ | ||||
| <!-- eslint-disable vuejs-accessibility/click-events-have-key-events, vue/no-v-html --> | ||||
| <template> | ||||
|   <transition name="slide"> | ||||
|     <div v-if="show" @click="clicked" class="toast" :class="type"> | ||||
|     <div v-if="show" class="toast" :class="type || 'info'" @click="clicked"> | ||||
|       <div class="toast--content"> | ||||
|         <div class="toast--icon"> | ||||
|           <i v-if="image"><img class="toast--icon-image" :src="image" /></i> | ||||
|           <i v-if="image" | ||||
|             ><img class="toast--icon-image" :src="image" alt="Toast icon" | ||||
|           /></i> | ||||
|         </div> | ||||
|         <div class="toast--text" v-if="description"> | ||||
|         <div v-if="description" class="toast--text"> | ||||
|           <span class="toast--text__title">{{ title }}</span> | ||||
|           <br /><span | ||||
|             class="toast--text__description" | ||||
|             v-html="description" | ||||
|           ></span> | ||||
|         </div> | ||||
|         <div class="toast--text" v-else> | ||||
|         <div v-else class="toast--text"> | ||||
|           <span class="toast--text__title-large">{{ title }}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div class="toast--dismiss" @click="dismiss"> | ||||
|           <i class="fas fa-times"></i> | ||||
|         </div> | ||||
| @@ -37,14 +41,7 @@ | ||||
|     timeout?: number; | ||||
|   } | ||||
|  | ||||
|   const { | ||||
|     type = "info", | ||||
|     title, | ||||
|     description, | ||||
|     image, | ||||
|     link, | ||||
|     timeout = 2000 | ||||
|   } = defineProps<Props>(); | ||||
|   const props = defineProps<Props>(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|   const show: Ref<boolean> = ref(false); | ||||
| @@ -53,16 +50,16 @@ | ||||
|     show.value = true; | ||||
|  | ||||
|     setTimeout(() => { | ||||
|       console.log("Notification time is up 👋"); | ||||
|       console.log("Notification time is up 👋"); // eslint-disable-line no-console | ||||
|       show.value = false; | ||||
|     }, timeout); | ||||
|     }, props.timeout || 2000); | ||||
|   }); | ||||
|  | ||||
|   function clicked() { | ||||
|     show.value = false; | ||||
|  | ||||
|     if (link) { | ||||
|       router.push(link); | ||||
|     if (props.link) { | ||||
|       router.push(props.link); | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|   | ||||
| @@ -6,11 +6,7 @@ const optionsDefaults = { | ||||
|   data: { | ||||
|     type: "info", | ||||
|     show: true, | ||||
|     timeout: 3000, | ||||
|  | ||||
|     onCreate(created = null) {}, | ||||
|     onEdit(editted = null) {}, | ||||
|     onRemove(removed = null) {} | ||||
|     timeout: 3000 | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -29,8 +25,8 @@ function toast(options) { | ||||
| } | ||||
|  | ||||
| export default { | ||||
|   install(app, options) { | ||||
|     console.log("installing toast plugin!"); | ||||
|   install(app) { | ||||
|     console.log("installing toast plugin!"); // eslint-disable-line no-console | ||||
|  | ||||
|     function info(options) { | ||||
|       toast({ type: "info", ...options }); | ||||
| @@ -53,6 +49,8 @@ export default { | ||||
|     } | ||||
|  | ||||
|     const notifications = { info, success, warning, error, simple }; | ||||
|  | ||||
|     /* eslint-disable-next-line no-param-reassign */ | ||||
|     app.config.globalProperties.$notifications = notifications; | ||||
|  | ||||
|     app.provide("notifications", notifications); | ||||
|   | ||||
| @@ -1,11 +1,7 @@ | ||||
| import { defineAsyncComponent } from "vue"; | ||||
| import { | ||||
|   createRouter, | ||||
|   createWebHistory, | ||||
|   RouteRecordRaw, | ||||
|   NavigationGuardNext, | ||||
|   RouteLocationNormalized | ||||
| } from "vue-router"; | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router"; | ||||
|  | ||||
| /* eslint-disable-next-line import/no-cycle */ | ||||
| import store from "./store"; | ||||
|  | ||||
| declare global { | ||||
| @@ -14,16 +10,16 @@ declare global { | ||||
|   } | ||||
| } | ||||
|  | ||||
| let routes: Array<RouteRecordRaw> = [ | ||||
| const routes: Array<RouteRecordRaw> = [ | ||||
|   { | ||||
|     name: "home", | ||||
|     path: "/", | ||||
|     component: () => import("./pages/Home.vue") | ||||
|     component: () => import("./pages/HomePage.vue") | ||||
|   }, | ||||
|   { | ||||
|     name: "activity", | ||||
|     path: "/activity", | ||||
|     meta: { requiresAuth: true }, | ||||
|     meta: { requiresAuth: true, requiresPlexAccount: true }, | ||||
|     component: () => import("./pages/ActivityPage.vue") | ||||
|   }, | ||||
|   { | ||||
| @@ -80,7 +76,7 @@ let routes: Array<RouteRecordRaw> = [ | ||||
|   { | ||||
|     name: "404", | ||||
|     path: "/404", | ||||
|     component: () => import("./pages/404.vue") | ||||
|     component: () => import("./pages/404Page.vue") | ||||
|   } | ||||
|   // { | ||||
|   //   path: "*", | ||||
| @@ -100,9 +96,10 @@ const router = createRouter({ | ||||
| }); | ||||
|  | ||||
| const loggedIn = () => store.getters["user/loggedIn"]; | ||||
| const popupIsOpen = () => store.getters["popup/isOpen"]; | ||||
| const hasPlexAccount = () => store.getters["user/plexId"] !== null; | ||||
| const hamburgerIsOpen = () => store.getters["hamburger/isOpen"]; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||||
| router.beforeEach( | ||||
|   (to: RouteLocationNormalized, from: RouteLocationNormalized, next: any) => { | ||||
|     store.dispatch("documentTitle/updateTitle", to.name); | ||||
| @@ -119,6 +116,15 @@ router.beforeEach( | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (to.matched.some(record => record.meta.requiresPlexAccount)) { | ||||
|       if (!hasPlexAccount()) { | ||||
|         next({ | ||||
|           path: "/settings", | ||||
|           query: { missingPlexAccount: true } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     next(); | ||||
|   } | ||||
| ); | ||||
|   | ||||
| @@ -4,9 +4,11 @@ import darkmodeModule from "./modules/darkmodeModule"; | ||||
| import documentTitle from "./modules/documentTitle"; | ||||
| import torrentModule from "./modules/torrentModule"; | ||||
| import user from "./modules/user"; | ||||
| import popup from "./modules/popup"; | ||||
| import hamburger from "./modules/hamburger"; | ||||
|  | ||||
| /* eslint-disable-next-line import/no-cycle */ | ||||
| import popup from "./modules/popup"; | ||||
|  | ||||
| const store = createStore({ | ||||
|   modules: { | ||||
|     darkmodeModule, | ||||
|   | ||||
							
								
								
									
										58
									
								
								src/utils.ts
									
									
									
									
									
								
							
							
						
						
									
										58
									
								
								src/utils.ts
									
									
									
									
									
								
							| @@ -5,17 +5,17 @@ export const sortableSize = (string: string): number => { | ||||
|   if (UNITS.indexOf(unit) === -1) return null; | ||||
|  | ||||
|   const exponent = UNITS.indexOf(unit) * 3; | ||||
|   return Number(numStr) * Math.pow(10, exponent); | ||||
|   return Number(numStr) * exponent ** 10; | ||||
| }; | ||||
|  | ||||
| export const parseJwt = (token: string) => { | ||||
|   var base64Url = token.split(".")[1]; | ||||
|   var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); | ||||
|   var jsonPayload = decodeURIComponent( | ||||
|   const base64Url = token.split(".")[1]; | ||||
|   const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); | ||||
|   const jsonPayload = decodeURIComponent( | ||||
|     atob(base64) | ||||
|       .split("") | ||||
|       .map(function (c) { | ||||
|         return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); | ||||
|       .map(c => { | ||||
|         return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`; | ||||
|       }) | ||||
|       .join("") | ||||
|   ); | ||||
| @@ -48,19 +48,15 @@ export function focusFirstFormInput(formElement: HTMLFormElement): void { | ||||
|  | ||||
| export function focusOnNextElement(elementEvent: KeyboardEvent): void { | ||||
|   const { target } = elementEvent; | ||||
|   console.log("target:", target); | ||||
|   if (!target) return; | ||||
|  | ||||
|   const form = document.getElementsByTagName("form")[0]; | ||||
|   console.log("form:", form); | ||||
|   if (!form) return; | ||||
|  | ||||
|   const inputElements = form.getElementsByTagName("input"); | ||||
|   console.log("inputElements:", inputElements); | ||||
|   const targetIndex = Array.from(inputElements).findIndex( | ||||
|     element => element === target | ||||
|   ); | ||||
|   console.log("targetIndex:", targetIndex); | ||||
|   if (targetIndex < inputElements.length) { | ||||
|     inputElements[targetIndex + 1].focus(); | ||||
|   } | ||||
| @@ -68,15 +64,18 @@ export function focusOnNextElement(elementEvent: KeyboardEvent): void { | ||||
|  | ||||
| export function humanMinutes(minutes) { | ||||
|   if (minutes instanceof Array) { | ||||
|     /* eslint-disable-next-line prefer-destructuring, no-param-reassign */ | ||||
|     minutes = minutes[0]; | ||||
|   } | ||||
|  | ||||
|   const hours = Math.floor(minutes / 60); | ||||
|   const minutesLeft = minutes - hours * 60; | ||||
|  | ||||
|   if (minutesLeft == 0) { | ||||
|   if (minutesLeft === 0) { | ||||
|     return hours > 1 ? `${hours} hours` : `${hours} hour`; | ||||
|   } else if (hours == 0) { | ||||
|   } | ||||
|  | ||||
|   if (hours === 0) { | ||||
|     return `${minutesLeft} min`; | ||||
|   } | ||||
|  | ||||
| @@ -98,3 +97,38 @@ export function setUrlQueryParameter(parameter: string, value: string): void { | ||||
|  | ||||
|   window.history.pushState({}, "search", url); | ||||
| } | ||||
|  | ||||
| export function convertSecondsToHumanReadable(_value, values = null) { | ||||
|   let value = _value; | ||||
|   const highestValue = values ? values[0] : value; | ||||
|  | ||||
|   // minutes | ||||
|   if (highestValue < 3600) { | ||||
|     const minutes = Math.floor(value / 60); | ||||
|  | ||||
|     value = `${minutes} m`; | ||||
|   } | ||||
|   // hours and minutes | ||||
|   else if (highestValue > 3600 && highestValue < 86400) { | ||||
|     const hours = Math.floor(value / 3600); | ||||
|     const minutes = Math.floor((value % 3600) / 60); | ||||
|  | ||||
|     value = hours !== 0 ? `${hours} h ${minutes} m` : `${minutes} m`; | ||||
|   } | ||||
|   // days and hours | ||||
|   else if (highestValue > 86400 && highestValue < 31557600) { | ||||
|     const days = Math.floor(value / 86400); | ||||
|     const hours = Math.floor((value % 86400) / 3600); | ||||
|  | ||||
|     value = days !== 0 ? `${days} d ${hours} h` : `${hours} h`; | ||||
|   } | ||||
|   // years and days | ||||
|   else if (highestValue > 31557600) { | ||||
|     const years = Math.floor(value / 31557600); | ||||
|     const days = Math.floor((value % 31557600) / 86400); | ||||
|  | ||||
|     value = years !== 0 ? `${years} y ${days} d` : `${days} d`; | ||||
|   } | ||||
|  | ||||
|   return value; | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								src/vue-shim.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/vue-shim.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,6 @@ | ||||
| declare module "*.vue" { | ||||
|   import { defineComponent } from "vue"; | ||||
|  | ||||
|   const Component: ReturnType<typeof defineComponent>; | ||||
|   export default Component; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user