Resolved ALL eslint issues for project

This commit is contained in:
2022-08-12 23:46:55 +02:00
parent 29dfe55974
commit 3594b18872
63 changed files with 1064 additions and 800 deletions

View File

@@ -1,17 +1,14 @@
<template> <template>
<div id="app"> <div id="app">
<!-- Header and hamburger navigation --> <!-- Header and hamburger navigation -->
<NavigationHeader class="header"></NavigationHeader> <NavigationHeader class="header" />
<div class="navigation-icons-gutter desktop-only"> <div class="navigation-icons-gutter desktop-only">
<NavigationIcons /> <NavigationIcons />
</div> </div>
<!-- Display the component assigned to the given route (default: home) --> <!-- Display the component assigned to the given route (default: home) -->
<router-view <router-view :key="router.currentRoute.value.path" class="content" />
class="content"
:key="router.currentRoute.value.path"
></router-view>
<!-- Popup that will show above existing rendered content --> <!-- Popup that will show above existing rendered content -->
<popup /> <popup />

View File

@@ -1,34 +1,25 @@
import config from "./config"; import config from "./config";
import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList"; import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList";
let { SEASONED_URL, ELASTIC_URL, ELASTIC_INDEX } = config; const { ELASTIC_URL, ELASTIC_INDEX } = config;
if (!SEASONED_URL) {
SEASONED_URL = window.location.origin;
}
// TODO let { SEASONED_URL } = config;
// - Move autorization token and errors here? if (!SEASONED_URL) SEASONED_URL = window.location.origin;
const checkStatusAndReturnJson = response => {
if (!response.ok) {
throw response;
}
return response.json();
};
// - - - TMDB - - - // - - - TMDB - - -
/** /**
* Fetches tmdb movie by id. Can optionally include cast credits in result object. * Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id * @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getMovie = ( const getMovie = (
id, id,
checkExistance = false, {
credits = false, checkExistance,
release_dates = false credits,
releaseDates
}: { checkExistance: boolean; credits: boolean; releaseDates?: boolean }
) => { ) => {
const url = new URL("/api/v2/movie", SEASONED_URL); const url = new URL("/api/v2/movie", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}`; url.pathname = `${url.pathname}/${id.toString()}`;
@@ -38,14 +29,14 @@ const getMovie = (
if (credits) { if (credits) {
url.searchParams.append("credits", "true"); url.searchParams.append("credits", "true");
} }
if (release_dates) { if (releaseDates) {
url.searchParams.append("release_dates", "true"); url.searchParams.append("release_dates", "true");
} }
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting movie: ${id}`); console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -56,7 +47,14 @@ const getMovie = (
* @param {boolean} [credits=false] Include credits * @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response * @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); const url = new URL("/api/v2/show", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}`; url.pathname = `${url.pathname}/${id.toString()}`;
if (checkExistance) { if (checkExistance) {
@@ -65,19 +63,18 @@ const getShow = (id, checkExistance = false, credits = false) => {
if (credits) { if (credits) {
url.searchParams.append("credits", "true"); url.searchParams.append("credits", "true");
} }
if (releaseDates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting show: ${id}`); console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error; 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. * Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id * @param {number} id
@@ -94,7 +91,7 @@ const getPerson = (id, credits = false) => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting person: ${id}`); console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -111,7 +108,7 @@ const getMovieCredits = (id: number): Promise<IMediaCredits> => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting movie: ${id}`); console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -128,7 +125,7 @@ const getShowCredits = (id: number): Promise<IMediaCredits> => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting show: ${id}`); console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -145,7 +142,7 @@ const getPersonCredits = (id: number): Promise<IPersonCredits> => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error getting person: ${id}`); console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -156,15 +153,12 @@ const getPersonCredits = (id: number): Promise<IPersonCredits> => {
* @param {number} [page=1] * @param {number} [page=1]
* @returns {object} Tmdb list response * @returns {object} Tmdb list response
*/ */
const getTmdbMovieListByName = ( const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => {
name: string, const url = new URL(`/api/v2/movie/${name}`, SEASONED_URL);
page: number = 1
): Promise<IList> => {
const url = new URL("/api/v2/movie/" + name, SEASONED_URL);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json()); 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] * @param {number} [page=1]
* @returns {object} Request response * @returns {object} Request response
*/ */
const getRequests = (page: number = 1) => { const getRequests = (page = 1) => {
const url = new URL("/api/v2/request", SEASONED_URL); const url = new URL("/api/v2/request", SEASONED_URL);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json()); 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) => { const getUserRequests = (page = 1) => {
@@ -206,7 +200,7 @@ const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .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; throw error;
}); });
}; };
@@ -226,7 +220,7 @@ const searchTorrents = query => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error(`api error searching torrents: ${query}`); console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -235,26 +229,26 @@ const searchTorrents = query => {
* Add magnet to download queue. * Add magnet to download queue.
* @param {string} magnet Magnet link * @param {string} magnet Magnet link
* @param {boolean} name Name of torrent * @param {boolean} name Name of torrent
* @param {boolean} tmdb_id * @param {boolean} tmdbId
* @returns {object} Success/Failure response * @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 url = new URL("/api/v1/pirate/add", SEASONED_URL);
const options = { const options = {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
magnet: magnet, magnet,
name: name, name,
tmdb_id: tmdb_id tmdb_id: tmdbId
}) })
}; };
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .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; throw error;
}); });
}; };
@@ -280,7 +274,7 @@ const request = (id, type) => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .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; throw error;
}); });
}; };
@@ -298,16 +292,13 @@ const getRequestStatus = (id, type = undefined) => {
return fetch(url.href) return fetch(url.href)
.then(resp => { .then(resp => {
const status = resp.status; const { status } = resp;
if (status === 200) { if (status === 200) return true;
return true;
} else if (status === 404) { const errorMessage = `api error getting request status for id ${id} and type ${type}`;
// eslint-disable-next-line no-console
console.error(errorMessage);
return false; return false;
} else {
console.error(
`api error getting request status for id ${id} and type ${type}`
);
}
}) })
.catch(err => Promise.reject(err)); .catch(err => Promise.reject(err));
}; };
@@ -341,10 +332,10 @@ const register = (username, password) => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.error( const errorMessage =
"Unexpected error occured before receiving response. Error:", "Unexpected error occured before receiving response. Error:";
error // eslint-disable-next-line no-console
); console.error(errorMessage, error);
// TODO log to sentry the issue here // TODO log to sentry the issue here
throw error; throw error;
}); });
@@ -359,10 +350,11 @@ const login = (username, password, throwError = false) => {
}; };
return fetch(url.href, options).then(resp => { 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; 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" }; const options = { method: "POST" };
return fetch(url.href, options).then(resp => { 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; 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) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.log("api error getting user settings"); console.log("api error getting user settings"); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -401,7 +394,7 @@ const updateSettings = settings => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.log("api error updating user settings"); console.log("api error updating user settings"); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -421,7 +414,7 @@ const linkPlexAccount = (username, password) => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .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; throw error;
}); });
}; };
@@ -437,7 +430,7 @@ const unlinkPlexAccount = () => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .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; throw error;
}); });
}; };
@@ -445,13 +438,13 @@ const unlinkPlexAccount = () => {
// - - - User graphs - - - // - - - User graphs - - -
const fetchGraphData = (urlPath, days, chartType) => { 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("days", days);
url.searchParams.append("y_axis", chartType); url.searchParams.append("y_axis", chartType);
return fetch(url.href).then(resp => { return fetch(url.href).then(resp => {
if (!resp.ok) { if (!resp.ok) {
console.log("DAMN WE FAILED!", resp); console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console
throw Error(resp.statusText); throw Error(resp.statusText);
} }
@@ -467,7 +460,7 @@ const getEmoji = () => {
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.log("api error getting emoji"); console.log("api error getting emoji"); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };
@@ -515,7 +508,7 @@ const elasticSearchMoviesAndShows = (query, count = 22) => {
return fetch(url.href, options) return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.log(`api error searching elasticsearch: ${query}`); console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console
throw error; throw error;
}); });
}; };

View File

@@ -3,8 +3,8 @@
<ol class="persons"> <ol class="persons">
<CastListItem <CastListItem
v-for="credit in cast" v-for="credit in cast"
:creditItem="credit"
:key="credit.id" :key="credit.id"
:credit-item="credit"
/> />
</ol> </ol>
</div> </div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<li class="card"> <li class="card">
<a @click="openCastItem"> <a @click="openCastItem" @keydown.enter="openCastItem">
<img :src="pictureUrl" /> <img :src="pictureUrl" alt="Movie or person poster image" />
<p class="name">{{ creditItem.name || creditItem.title }}</p> <p class="name">{{ creditItem.name || creditItem.title }}</p>
<p class="meta">{{ creditItem.character || creditItem.year }}</p> <p class="meta">{{ creditItem.character || creditItem.year }}</p>
</a> </a>
@@ -25,7 +25,8 @@
if ("profile_path" in props.creditItem && props.creditItem.profile_path) { if ("profile_path" in props.creditItem && props.creditItem.profile_path) {
return baseUrl + 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; return baseUrl + props.creditItem.poster;
} }

View File

@@ -3,7 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, onMounted, watch } from "vue"; import { ref, defineProps, onMounted, watch } from "vue";
import { import {
Chart, Chart,
LineElement, LineElement,
@@ -19,6 +19,11 @@
ChartType ChartType
} from "chart.js"; } 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( Chart.register(
LineElement, LineElement,
BarElement, BarElement,
@@ -31,23 +36,6 @@
Title, Title,
Tooltip 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 { interface Props {
name?: string; name?: string;
@@ -69,8 +57,10 @@
const graphCanvas: Ref<HTMLCanvasElement> = ref(null); const graphCanvas: Ref<HTMLCanvasElement> = ref(null);
let graphInstance = null; let graphInstance = null;
/* eslint-disable no-use-before-define */
onMounted(() => generateGraph()); onMounted(() => generateGraph());
watch(() => props.data, generateGraph); watch(() => props.data, generateGraph);
/* eslint-enable no-use-before-define */
const graphTemplates = [ const graphTemplates = [
{ {
@@ -92,9 +82,9 @@
tension: 0.4 tension: 0.4
} }
]; ];
const gridColor = getComputedStyle(document.documentElement).getPropertyValue( // const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
"--text-color-5" // "--text-color-5"
); // );
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) { function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) {
return { return {
@@ -105,6 +95,7 @@
} }
function removeEmptyDataset(dataset: IGraphDataset) { function removeEmptyDataset(dataset: IGraphDataset) {
/* eslint-disable-next-line no-unneeded-ternary */
return dataset.data.every(point => point === 0) ? false : true; return dataset.data.every(point => point === 0) ? false : true;
} }
@@ -143,7 +134,7 @@
yAxes: { yAxes: {
stacked: props.stacked, stacked: props.stacked,
ticks: { ticks: {
callback: (value, index, values) => { callback: value => {
if (props.graphValueType === GraphValueTypes.Time) { if (props.graphValueType === GraphValueTypes.Time) {
return convertSecondsToHumanReadable(value); return convertSecondsToHumanReadable(value);
} }
@@ -174,40 +165,6 @@
options: graphOptions 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> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<header ref="headerElement" :class="{ expanded, noselect: true }"> <header ref="headerElement" :class="{ expanded, noselect: true }">
<img :src="bannerImage" ref="imageElement" /> <img ref="imageElement" :src="bannerImage" alt="Page banner image" />
<div class="container"> <div class="container">
<h1 class="title">Request movies or tv shows</h1> <h1 class="title">Request movies or tv shows</h1>
<strong class="subtitle" <strong class="subtitle"
@@ -8,7 +8,13 @@
> >
</div> </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" /> <IconExpand v-if="!expanded" />
<IconShrink v-else /> <IconShrink v-else />
</div> </div>
@@ -16,7 +22,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref } from "vue";
import IconExpand from "@/icons/IconExpand.vue"; import IconExpand from "@/icons/IconExpand.vue";
import IconShrink from "@/icons/IconShrink.vue"; import IconShrink from "@/icons/IconShrink.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
@@ -35,9 +41,7 @@
const headerElement: Ref<HTMLElement> = ref(null); const headerElement: Ref<HTMLElement> = ref(null);
const imageElement: Ref<HTMLImageElement> = ref(null); const imageElement: Ref<HTMLImageElement> = ref(null);
const defaultHeaderHeight: Ref<string> = ref(); const defaultHeaderHeight: Ref<string> = ref();
const disableProxy = true; // const disableProxy = true;
bannerImage.value = randomImage();
function expand() { function expand() {
expanded.value = !expanded.value; expanded.value = !expanded.value;
@@ -53,11 +57,17 @@
headerElement.value.style.setProperty("--header-height", height); headerElement.value.style.setProperty("--header-height", height);
} }
function focus(event: FocusEvent) {
event.preventDefault();
}
function randomImage(): string { 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; return ASSET_URL + image;
} }
bannerImage.value = randomImage();
// function sliceToHeaderSize(url: string): string { // function sliceToHeaderSize(url: string): string {
// let width = headerElement.value?.getBoundingClientRect()?.width || 1349; // let width = headerElement.value?.getBoundingClientRect()?.width || 1349;
// let height = headerElement.value?.getBoundingClientRect()?.height || 261; // let height = headerElement.value?.getBoundingClientRect()?.height || 261;

View File

@@ -1,5 +1,5 @@
<template> <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> <div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" /> <person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie> <movie v-else :id="id" :type="type"></movie>
@@ -14,8 +14,8 @@
import { useStore } from "vuex"; import { useStore } from "vuex";
import Movie from "@/components/popup/Movie.vue"; import Movie from "@/components/popup/Movie.vue";
import Person from "@/components/popup/Person.vue"; import Person from "@/components/popup/Person.vue";
import { MediaTypes } from "../interfaces/IList";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { MediaTypes } from "../interfaces/IList";
interface URLQueryParameters { interface URLQueryParameters {
id: number; id: number;
@@ -34,14 +34,16 @@
id.value = state.popup.id; id.value = state.popup.id;
type.value = state.popup.type; type.value = state.popup.type;
isOpen.value if (isOpen.value) {
? document.getElementsByTagName("body")[0].classList.add("no-scroll") document.getElementsByTagName("body")[0].classList.add("no-scroll");
: document.getElementsByTagName("body")[0].classList.remove("no-scroll"); } else {
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
}); });
function getFromURLQuery(): URLQueryParameters { function getFromURLQuery(): URLQueryParameters {
let id: number; let _id: number;
let type: MediaTypes; let _type: MediaTypes;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.forEach((value, key) => { params.forEach((value, key) => {
@@ -55,16 +57,16 @@
return; return;
} }
id = Number(params.get(key)); _id = Number(params.get(key));
type = MediaTypes[key]; _type = MediaTypes[key];
}); });
return { id, type }; return { id: _id, type: _type };
} }
function open(id: Number, type: string) { function open(_id: number, _type: string) {
if (!id || !type) return; if (!_id || !_type) return;
store.dispatch("popup/open", { id, type }); store.dispatch("popup/open", { id: _id, type: _type });
} }
function close() { function close() {
@@ -79,8 +81,8 @@
window.addEventListener("keyup", checkEventForEscapeKey); window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => { onMounted(() => {
const { id, type } = getFromURLQuery(); const query = getFromURLQuery();
open(id, type); open(query?.id, query?.type);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -8,7 +8,7 @@
<results-list-item <results-list-item
v-for="(result, index) in results" v-for="(result, index) in results"
:key="generateResultKey(index, `${result.type}-${result.id}`)" :key="generateResultKey(index, `${result.type}-${result.id}`)"
:listItem="result" :list-item="result"
/> />
</ul> </ul>
@@ -23,11 +23,11 @@
interface Props { interface Props {
results: Array<ListResults>; results: Array<ListResults>;
shortList?: Boolean; shortList?: boolean;
loading?: Boolean; loading?: boolean;
} }
const props = defineProps<Props>(); defineProps<Props>();
function generateResultKey(index: string | number | symbol, value: string) { function generateResultKey(index: string | number | symbol, value: string) {
return `${String(index)}-${value}`; return `${String(index)}-${value}`;

View File

@@ -1,9 +1,10 @@
<template> <template>
<li class="movie-item" ref="list-item"> <li ref="list-item" class="movie-item">
<figure <figure
ref="posterElement" ref="posterElement"
class="movie-item__poster" class="movie-item__poster"
@click="openMoviePopup" @click="openMoviePopup"
@keydown.enter="openMoviePopup"
> >
<img <img
class="movie-item__img" class="movie-item__img"
@@ -36,9 +37,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, onMounted } from "vue"; import { ref, computed, defineProps, onMounted } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { buildImageProxyUrl } from "../utils";
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { IMovie, IShow, IPerson, IRequest } from "../interfaces/IList"; import type { IMovie, IShow, IPerson } from "../interfaces/IList";
interface Props { interface Props {
listItem: IMovie | IShow | IPerson; listItem: IMovie | IShow | IPerson;
@@ -53,19 +53,31 @@
const posterElement: Ref<HTMLElement> = ref(null); const posterElement: Ref<HTMLElement> = ref(null);
const observed: Ref<boolean> = ref(false); const observed: Ref<boolean> = ref(false);
poster.value = props.listItem?.poster if (props.listItem?.poster) {
? IMAGE_BASE_URL + props.listItem?.poster poster.value = IMAGE_BASE_URL + props.listItem.poster;
: IMAGE_FALLBACK; } 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() { function observePosterAndSetImageSource() {
const imageElement = posterElement.value.getElementsByTagName("img")[0]; const imageElement = posterElement.value.getElementsByTagName("img")[0];
if (imageElement == null) return; if (imageElement == null) return;
const imageObserver = new IntersectionObserver((entries, imgObserver) => { const imageObserver = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting && observed.value == false) { if (entry.isIntersecting && observed.value === false) {
const lazyImage = entry.target as HTMLImageElement; const lazyImage = entry.target as HTMLImageElement;
lazyImage.src = lazyImage.dataset.src; lazyImage.src = lazyImage.dataset.src;
posterElement.value.classList.add("is-loaded"); posterElement.value.classList.add("is-loaded");
@@ -77,30 +89,20 @@
imageObserver.observe(imageElement); imageObserver.observe(imageElement);
} }
onMounted(observePosterAndSetImageSource);
function openMoviePopup() { function openMoviePopup() {
store.dispatch("popup/open", { ...props.listItem }); store.dispatch("popup/open", { ...props.listItem });
} }
const posterAltText = computed(() => { // const imageSize = computed(() => {
const type = props.listItem.type || ""; // if (!posterElement.value) return;
let title: string = ""; // const { height, width } = posterElement.value.getBoundingClientRect();
// return {
if ("name" in props.listItem) title = props.listItem.name; // height: Math.ceil(height),
else if ("title" in props.listItem) title = props.listItem.title; // width: Math.ceil(width)
// };
return props.listItem.poster // });
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
});
const imageSize = computed(() => {
if (!posterElement.value) return;
const { height, width } = posterElement.value.getBoundingClientRect();
return {
height: Math.ceil(height),
width: Math.ceil(width)
};
});
// import img from "../directives/v-image"; // import img from "../directives/v-image";
// directives: { // directives: {

View File

@@ -6,7 +6,7 @@
v-if="!loadedPages.includes(1) && loading == false" v-if="!loadedPages.includes(1) && loading == false"
class="button-container" 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 >load previous</seasoned-button
> >
</div> </div>
@@ -16,10 +16,10 @@
<div ref="loadMoreButton" class="button-container"> <div ref="loadMoreButton" class="button-container">
<seasoned-button <seasoned-button
class="load-button"
v-if="!loading && !shortList && page != totalPages && results.length" v-if="!loading && !shortList && page != totalPages && results.length"
class="load-button"
:full-width="true"
@click="loadMore" @click="loadMore"
:fullWidth="true"
>load more</seasoned-button >load more</seasoned-button
> >
</div> </div>
@@ -28,12 +28,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, ref, computed, onMounted } from "vue"; import { defineProps, ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import ResultsList from "@/components/ResultsList.vue"; import ResultsList from "@/components/ResultsList.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import Loader from "@/components/ui/Loader.vue"; import Loader from "@/components/ui/Loader.vue";
import { getTmdbMovieListByName } from "../api";
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { IList, ListResults } from "../interfaces/IList"; import type { IList, ListResults } from "../interfaces/IList";
import type ISection from "../interfaces/ISection"; import type ISection from "../interfaces/ISection";
@@ -44,7 +42,6 @@
shortList?: boolean; shortList?: boolean;
} }
const store = useStore();
const props = defineProps<Props>(); const props = defineProps<Props>();
const results: Ref<ListResults> = ref([]); const results: Ref<ListResults> = ref([]);
@@ -54,12 +51,23 @@
const totalPages: Ref<number> = ref(0); const totalPages: Ref<number> = ref(0);
const loading: Ref<boolean> = ref(true); const loading: Ref<boolean> = ref(true);
const autoLoad: Ref<boolean> = ref(false); const autoLoad: Ref<boolean> = ref(false);
const observer: Ref<any> = ref(null); const observer: Ref<IntersectionObserver> = ref(null);
const resultSection = ref(null); const resultSection = ref(null);
const loadMoreButton = ref(null); const loadMoreButton = ref(null);
page.value = getPageFromUrl() || page.value; function pageCountString(_page: number, _totalPages: number) {
if (results.value?.length === 0) getListResults(); 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(() => { const info = computed(() => {
if (results.value.length === 0) return [null, null]; if (results.value.length === 0) return [null, null];
@@ -69,25 +77,30 @@
return [pageCount, resultCount]; 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() { function getPageFromUrl() {
const page = new URLSearchParams(window.location.search).get("page"); const _page = new URLSearchParams(window.location.search).get("page");
if (!page) return null; 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) { function getListResults(front = false) {
@@ -105,7 +118,7 @@
totalResults.value = listResponse.total_results; totalResults.value = listResponse.total_results;
}) })
.then(updateQueryParams) .then(updateQueryParams)
.finally(() => (loading.value = false)); .finally(() => setLoading(false));
} }
function loadMore() { function loadMore() {
@@ -114,9 +127,9 @@
} }
loading.value = true; 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; page.value = maxPage + 1;
getListResults(); getListResults();
} }
@@ -130,25 +143,6 @@
getListResults(true); 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) { function handleButtonIntersection(entries) {
entries.map(entry => entries.map(entry =>
entry.isIntersecting && autoLoad.value ? loadMore() : null entry.isIntersecting && autoLoad.value ? loadMore() : null
@@ -165,14 +159,12 @@
observer.value.observe(loadMoreButton.value); observer.value.observe(loadMoreButton.value);
} }
// created() { page.value = getPageFromUrl() || page.value;
// if (!this.shortList) { if (results.value?.length === 0) getListResults();
// store.dispatch( onMounted(() => {
// "documentTitle/updateTitle", if (!props?.shortList) setupAutoloadObserver();
// `${this.$router.history.current.name} ${this.title}` });
// );
// }
// },
// beforeDestroy() { // beforeDestroy() {
// this.observer = undefined; // this.observer = undefined;
// } // }

View File

@@ -1,13 +1,12 @@
<template> <template>
<transition name="shut"> <transition name="shut">
<ul class="dropdown"> <ul class="dropdown">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<li <li
v-for="result in searchResults" v-for="(result, _index) in searchResults"
:key="`${result.index}-${result.title}-${result.type}`" :key="`${_index}-${result.title}-${result.type}`"
:class="`result di-${_index} ${_index === index ? 'active' : ''}`"
@click="openPopup(result)" @click="openPopup(result)"
:class="`result di-${result.index} ${
result.index === index ? 'active' : ''
}`"
> >
<IconMovie v-if="result.type == 'movie'" class="type-icon" /> <IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" /> <IconShow v-if="result.type == 'show'" class="type-icon" />
@@ -29,36 +28,38 @@
import { useStore } from "vuex"; import { useStore } from "vuex";
import IconMovie from "@/icons/IconMovie.vue"; import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue"; import IconShow from "@/icons/IconShow.vue";
import IconPerson from "@/icons/IconPerson.vue";
import { elasticSearchMoviesAndShows } from "../../api";
import type { Ref } from "vue"; 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 { interface Props {
query?: string; query?: string;
index?: Number; index?: number;
results?: Array<any>; results?: Array<IAutocompleteResult>;
} }
interface Emit { 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 props = defineProps<Props>();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
const store = useStore(); const store = useStore();
const searchResults: Ref<Array<any>> = ref([]); const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0); const keyboardNavigationIndex: Ref<number> = ref(0);
// on load functions
fetchAutocompleteResults();
// end on load functions
watch( watch(
() => props.query, () => props.query,
newQuery => { 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 }); 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() { function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0; keyboardNavigationIndex.value = 0;
searchResults.value = []; searchResults.value = [];
@@ -80,44 +128,9 @@
}); });
} }
function parseElasticResponse(elasticResponse: any) { // on load functions
const data = elasticResponse.hits.hits; fetchAutocompleteResults();
// end on load functions
let results = data.map(item => {
let index = null;
if (item._source.log.file.path.includes("movie")) index = "movie";
if (item._source.log.file.path.includes("series")) index = "show";
if (index === "movie" || index === "show") {
return {
title: item._source.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: index
};
}
});
return removeDuplicates(results).map((el, index) => {
return { ...el, index };
});
}
function removeDuplicates(searchResults) {
let filteredResults = [];
searchResults.map(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id == result.id
);
if (numberOfDuplicates.length >= 1) {
return null;
}
filteredResults.push(result);
});
return filteredResults;
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,8 +1,10 @@
<template> <template>
<nav> <nav>
<!-- eslint-disable-next-line vuejs-accessibility/anchor-has-content -->
<a v-if="isHome" class="nav__logo" href="/"> <a v-if="isHome" class="nav__logo" href="/">
<TmdbLogo class="logo" /> <TmdbLogo class="logo" />
</a> </a>
<router-link v-else class="nav__logo" to="/" exact> <router-link v-else class="nav__logo" to="/" exact>
<TmdbLogo class="logo" /> <TmdbLogo class="logo" />
</router-link> </router-link>
@@ -13,7 +15,7 @@
<NavigationIcon class="desktop-only" :route="profileRoute" /> <NavigationIcon class="desktop-only" :route="profileRoute" />
<!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> --> <!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> -->
<div class="navigation-icons-grid mobile-only" v-if="isOpen"> <div v-if="isOpen" class="navigation-icons-grid mobile-only">
<NavigationIcons> <NavigationIcons>
<NavigationIcon :route="profileRoute" /> <NavigationIcon :route="profileRoute" />
</NavigationIcons> </NavigationIcons>
@@ -22,8 +24,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineProps, PropType } from "vue"; import { computed } from "vue";
import type { App } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import SearchInput from "@/components/header/SearchInput.vue"; import SearchInput from "@/components/header/SearchInput.vue";

View File

@@ -1,11 +1,11 @@
<template> <template>
<router-link <router-link
:to="{ path: route?.route }"
:key="route?.title"
v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)" v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)"
:key="route?.title"
:to="{ path: route?.route }"
> >
<li class="navigation-link" :class="{ active: route.route == active }"> <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> <span>{{ route.title }}</span>
</li> </li>
</router-link> </router-link>

View File

@@ -1,9 +1,9 @@
<template> <template>
<ul class="navigation-icons"> <ul class="navigation-icons">
<NavigationIcon <NavigationIcon
v-for="route in routes" v-for="_route in routes"
:key="route.route" :key="_route.route"
:route="route" :route="_route"
:active="activeRoute" :active="activeRoute"
/> />
<slot></slot> <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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -3,15 +3,16 @@
<div class="search" :class="{ active: inputIsActive }"> <div class="search" :class="{ active: inputIsActive }">
<IconSearch class="search-icon" tabindex="-1" /> <IconSearch class="search-icon" tabindex="-1" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input <input
ref="inputElement" ref="inputElement"
v-model="query"
type="text" type="text"
placeholder="Search for movie or show" placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show" aria-label="Search input for finding a movie or show"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
tabindex="0" tabindex="0"
v-model="query"
@input="handleInput" @input="handleInput"
@click="focus" @click="focus"
@keydown.escape="handleEscape" @keydown.escape="handleEscape"
@@ -23,20 +24,20 @@
/> />
<IconClose <IconClose
v-if="query && query.length"
tabindex="0" tabindex="0"
aria-label="button" aria-label="button"
v-if="query && query.length" class="close-icon"
@click="clearInput" @click="clearInput"
@keydown.enter.stop="clearInput" @keydown.enter.stop="clearInput"
class="close-icon"
/> />
</div> </div>
<AutocompleteDropdown <AutocompleteDropdown
v-if="showAutocompleteResults" v-if="showAutocompleteResults"
v-model:results="dropdownResults"
:query="query" :query="query"
:index="dropdownIndex" :index="dropdownIndex"
v-model:results="dropdownResults"
/> />
</div> </div>
</template> </template>
@@ -44,14 +45,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useRouter } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useRoute } from "vue-router";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import AutocompleteDropdown from "@/components/header/AutocompleteDropdown.vue"; import AutocompleteDropdown from "@/components/header/AutocompleteDropdown.vue";
import IconSearch from "@/icons/IconSearch.vue"; import IconSearch from "@/icons/IconSearch.vue";
import IconClose from "@/icons/IconClose.vue"; import IconClose from "@/icons/IconClose.vue";
import config from "../../config";
import type { Ref } from "vue"; import type { Ref } from "vue";
import config from "../../config";
import type { MediaTypes } from "../../interfaces/IList"; import type { MediaTypes } from "../../interfaces/IList";
interface ISearchResult { interface ISearchResult {
@@ -70,8 +69,7 @@
const dropdownIndex: Ref<number> = ref(-1); const dropdownIndex: Ref<number> = ref(-1);
const dropdownResults: Ref<ISearchResult[]> = ref([]); const dropdownResults: Ref<ISearchResult[]> = ref([]);
const inputIsActive: Ref<boolean> = ref(false); const inputIsActive: Ref<boolean> = ref(false);
const showAutocomplete: Ref<boolean> = ref(false); const inputElement: Ref<HTMLInputElement> = ref(null);
const inputElement: Ref<any> = ref(null);
const isOpen = computed(() => store.getters["popup/isOpen"]); const isOpen = computed(() => store.getters["popup/isOpen"]);
const showAutocompleteResults = computed(() => { const showAutocompleteResults = computed(() => {
@@ -95,12 +93,12 @@
function navigateDown() { function navigateDown() {
if (dropdownIndex.value < dropdownResults.value.length - 1) { if (dropdownIndex.value < dropdownResults.value.length - 1) {
dropdownIndex.value++; dropdownIndex.value += 1;
} }
} }
function navigateUp() { function navigateUp() {
if (dropdownIndex.value > -1) dropdownIndex.value--; if (dropdownIndex.value > -1) dropdownIndex.value -= 1;
const textLength = inputElement.value.value.length; const textLength = inputElement.value.value.length;
@@ -122,12 +120,31 @@
}); });
} }
function handleInput(e) { function handleInput() {
dropdownIndex.value = -1; 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() { function handleSubmit() {
if (!query.value || query.value.length == 0) return; if (!query.value || query.value.length === 0) return;
if (dropdownIndex.value >= 0) { if (dropdownIndex.value >= 0) {
const resultItem = dropdownResults.value[dropdownIndex.value]; const resultItem = dropdownResults.value[dropdownIndex.value];
@@ -143,25 +160,6 @@
reset(); 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() { function handleEscape() {
if (!isOpen.value) reset(); if (!isOpen.value) reset();
} }

View File

@@ -1,8 +1,9 @@
<template> <template>
<li <li
class="sidebar-list-element" class="sidebar-list-element"
@click="emit('click')"
:class="{ active, disabled }" :class="{ active, disabled }"
@click="emit('click')"
@keydown.enter="emit('click')"
> >
<slot></slot> <slot></slot>
</li> </li>
@@ -12,8 +13,8 @@
import { defineProps, defineEmits } from "vue"; import { defineProps, defineEmits } from "vue";
interface Props { interface Props {
active?: Boolean; active?: boolean;
disabled?: Boolean; disabled?: boolean;
} }
interface Emit { interface Emit {

View File

@@ -3,6 +3,7 @@
ref="descriptionElement" ref="descriptionElement"
class="movie-description noselect" class="movie-description noselect"
@click="overflow ? (truncated = !truncated) : null" @click="overflow ? (truncated = !truncated) : null"
@keydown.enter="overflow ? (truncated = !truncated) : null"
> >
<span :class="{ truncated }">{{ description }}</span> <span :class="{ truncated }">{{ description }}</span>
@@ -14,8 +15,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, onMounted } from "vue"; import { ref, defineProps, onMounted } from "vue";
import IconArrowDown from "../../icons/IconArrowDown.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import IconArrowDown from "../../icons/IconArrowDown.vue";
interface Props { interface Props {
description: string; description: string;
@@ -26,7 +27,10 @@
const overflow: Ref<boolean> = ref(false); const overflow: Ref<boolean> = ref(false);
const descriptionElement: Ref<HTMLElement> = ref(null); 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 // The description element overflows text after 4 rows with css
// line-clamp this takes the same text and adds to a temporary // line-clamp this takes the same text and adds to a temporary
@@ -53,15 +57,13 @@
document.body.appendChild(descriptionComparisonElement); document.body.appendChild(descriptionComparisonElement);
const elemWithoutOverflowHeight = const elemWithoutOverflowHeight =
descriptionComparisonElement.getBoundingClientRect()["height"]; descriptionComparisonElement.getBoundingClientRect().height;
overflow.value = elemWithoutOverflowHeight > height; overflow.value = elemWithoutOverflowHeight > height;
removeElements(document.querySelectorAll(".dummy-non-overflow")); removeElements(document.querySelectorAll(".dummy-non-overflow"));
} }
function removeElements(elems: NodeListOf<Element>) { onMounted(checkDescriptionOverflowing);
elems.forEach(el => el.remove());
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,6 +1,7 @@
<template> <template>
<section class="movie"> <section class="movie">
<!-- HEADER w/ POSTER --> <!-- HEADER w/ POSTER -->
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<header <header
ref="backdropElement" ref="backdropElement"
:class="compact ? 'compact' : ''" :class="compact ? 'compact' : ''"
@@ -8,8 +9,9 @@
> >
<figure class="movie__poster"> <figure class="movie__poster">
<img <img
class="movie-item__img is-loaded"
ref="poster-image" ref="poster-image"
class="movie-item__img is-loaded"
alt="Movie poster"
:src="poster" :src="poster"
/> />
</figure> </figure>
@@ -25,7 +27,7 @@
<div class="movie__main"> <div class="movie__main">
<div class="movie__wrap movie__wrap--main"> <div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS --> <!-- 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"> <action-button :active="media?.exists_in_plex" :disabled="true">
<IconThumbsUp v-if="media?.exists_in_plex" /> <IconThumbsUp v-if="media?.exists_in_plex" />
<IconThumbsDown v-else /> <IconThumbsDown v-else />
@@ -36,7 +38,7 @@
}} }}
</action-button> </action-button>
<action-button @click="sendRequest" :active="requested"> <action-button :active="requested" @click="sendRequest">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<div v-if="!requested" key="request"><IconRequest /></div> <div v-if="!requested" key="request"><IconRequest /></div>
<div v-else key="requested"><IconRequested /></div> <div v-else key="requested"><IconRequested /></div>
@@ -63,8 +65,8 @@
<action-button <action-button
v-if="admin === true" v-if="admin === true"
@click="showTorrents = !showTorrents"
:active="showTorrents" :active="showTorrents"
@click="showTorrents = !showTorrents"
> >
<IconBinoculars /> <IconBinoculars />
Search for torrents Search for torrents
@@ -80,11 +82,11 @@
</div> </div>
<!-- Loading placeholder --> <!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else> <div v-else class="movie__actions text-input__loading">
<div <div
v-for="index in admin ? Array(4) : Array(3)" v-for="index in admin ? Array(4) : Array(3)"
class="movie__actions-link"
:key="index" :key="index"
class="movie__actions-link"
> >
<div <div
class="movie__actions-text text-input__loading--line" class="movie__actions-text text-input__loading--line"
@@ -105,7 +107,7 @@
:description="media.overview" :description="media.overview"
/> />
<div class="movie__details" v-if="media"> <div v-if="media" class="movie__details">
<Detail <Detail
v-if="media.year" v-if="media.year"
title="Release date" title="Release date"
@@ -144,7 +146,7 @@
<!-- TODO: change this classname, this is general --> <!-- 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"> <Detail title="cast">
<CastList :cast="cast" /> <CastList :cast="cast" />
</Detail> </Detail>
@@ -156,7 +158,7 @@
v-if="media && admin && showTorrents" v-if="media && admin && showTorrents"
class="torrents" class="torrents"
:query="media?.title" :query="media?.title"
:tmdb_id="id" :tmdb-id="id"
></TorrentList> ></TorrentList>
</div> </div>
</section> </section>
@@ -181,7 +183,7 @@
import ActionButton from "@/components/popup/ActionButton.vue"; import ActionButton from "@/components/popup/ActionButton.vue";
import Description from "@/components/popup/Description.vue"; import Description from "@/components/popup/Description.vue";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue";
import type { Ref, ComputedRef } from "vue"; import type { Ref } from "vue";
import type { import type {
IMovie, IMovie,
IShow, IShow,
@@ -194,12 +196,11 @@
import { import {
getMovie, getMovie,
getShow, getShow,
getPerson,
getMovieCredits, getMovieCredits,
getShowCredits, getShowCredits,
request, request,
getRequestStatus, getRequestStatus
watchLink // watchLink
} from "../../api"; } from "../../api";
interface Props { interface Props {
@@ -222,45 +223,26 @@
const store = useStore(); const store = useStore();
const loggedIn = computed(() => store.getters["user/loggedIn"]);
const admin = computed(() => store.getters["user/admin"]); const admin = computed(() => store.getters["user/admin"]);
const plexId = computed(() => store.getters["user/plexId"]); 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 numberOfTorrentResults = computed(() => {
const count = store.getters["torrentModule/resultCount"]; const count = store.getters["torrentModule/resultCount"];
return count ? `${count} results` : null; return count ? `${count} results` : null;
}); });
// On created functions function setCast(_cast: ICast[]) {
fetchMedia(); cast.value = _cast;
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 setRequested(status: boolean) {
let apiFunction: Function; requested.value = status;
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 getCredits( function getCredits(
@@ -268,7 +250,8 @@
): Promise<IMediaCredits> { ): Promise<IMediaCredits> {
if (type === MediaTypes.Movie) { if (type === MediaTypes.Movie) {
return getMovieCredits(props.id); return getMovieCredits(props.id);
} else if (type === MediaTypes.Show) { }
if (type === MediaTypes.Show) {
return getShowCredits(props.id); return getShowCredits(props.id);
} }
@@ -280,35 +263,64 @@
return _media; return _media;
} }
const computePoster = () => { function fetchMedia() {
if (!media.value) return "/assets/placeholder.png"; if (!props.id || !props.type) {
else if (!media.value?.poster) return "/assets/no-image.svg"; 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 (props.type === MediaTypes.Movie) {
if (!media.value?.backdrop || !backdropElement.value?.style) return ""; 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}`; const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`;
backdropElement.value.style.backgroundImage = `url(${backdropURL})`; backdropElement.value.style.backgroundImage = `url(${backdropURL})`;
} }
function sendRequest() { function sendRequest() {
request(props.id, props.type).then( request(props.id, props.type).then(resp =>
resp => (requested.value = resp?.success || false) setRequested(resp?.success || false)
); );
} }
function openInPlex() { function openInPlex(): boolean {
return; // watchLink()
return false;
} }
function openTmdb() { function openTmdb() {
const tmdbType = props.type === MediaTypes.Show ? "tv" : props.type; 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; window.location.href = tmdbURL;
} }
// On created functions
fetchMedia();
setBackdrop();
store.dispatch("torrentModule/setResultCount", null);
// End on create functions
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -7,10 +7,10 @@
</h1> </h1>
<div v-else> <div v-else>
<loading-placeholder :count="1" /> <loading-placeholder :count="1" />
<loading-placeholder :count="1" lineClass="short" :top="3.5" /> <loading-placeholder :count="1" line-class="short" :top="3.5" />
</div> </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" person.known_for_department === "Acting"
? "Actor" ? "Actor"
@@ -21,8 +21,9 @@
<figure class="person__poster"> <figure class="person__poster">
<img <img
class="person-item__img is-loaded"
ref="poster-image" ref="poster-image"
class="person-item__img is-loaded"
:alt="`Image of ${person.name}`"
:src="poster" :src="poster"
/> />
</figure> </figure>
@@ -30,9 +31,9 @@
<div v-if="loading"> <div v-if="loading">
<loading-placeholder :count="6" /> <loading-placeholder :count="6" />
<loading-placeholder lineClass="short" :top="3" /> <loading-placeholder line-class="short" :top="3" />
<loading-placeholder :count="6" lineClass="fullwidth" /> <loading-placeholder :count="6" line-class="fullwidth" />
<loading-placeholder lineClass="short" :top="4.5" /> <loading-placeholder line-class="short" :top="4.5" />
<loading-placeholder /> <loading-placeholder />
</div> </div>
@@ -50,17 +51,17 @@
</Detail> </Detail>
<Detail <Detail
v-if="creditedShows.length"
title="movies" title="movies"
:detail="`Credited in ${creditedMovies.length} movies`" :detail="`Credited in ${creditedMovies.length} movies`"
v-if="creditedShows.length"
> >
<CastList :cast="creditedMovies" /> <CastList :cast="creditedMovies" />
</Detail> </Detail>
<Detail <Detail
v-if="creditedShows.length"
title="shows" title="shows"
:detail="`Credited in ${creditedShows.length} shows`" :detail="`Credited in ${creditedShows.length} shows`"
v-if="creditedShows.length"
> >
<CastList :cast="creditedShows" /> <CastList :cast="creditedShows" />
</Detail> </Detail>
@@ -70,17 +71,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps } from "vue"; import { ref, computed, defineProps } from "vue";
import img from "@/directives/v-image.vue";
import CastList from "@/components/CastList.vue"; import CastList from "@/components/CastList.vue";
import Detail from "@/components/popup/Detail.vue"; import Detail from "@/components/popup/Detail.vue";
import Description from "@/components/popup/Description.vue"; import Description from "@/components/popup/Description.vue";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue";
import { getPerson, getPersonCredits } from "../../api";
import type { Ref, ComputedRef } from "vue"; import type { Ref, ComputedRef } from "vue";
import { getPerson, getPersonCredits } from "../../api";
import type { import type {
IPerson, IPerson,
IPersonCredits, IPersonCredits,
ICast,
IMovie, IMovie,
IShow IShow
} from "../../interfaces/IList"; } from "../../interfaces/IList";
@@ -100,38 +99,39 @@
const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]); const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]);
const creditedShows: 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(() => { const age: ComputedRef<string> = computed(() => {
if (!person.value?.birthday) return; if (!person.value?.birthday) return "";
const today = new Date().getFullYear(); const today = new Date().getFullYear();
const birthYear = new Date(person.value.birthday).getFullYear(); const birthYear = new Date(person.value.birthday).getFullYear();
return `${today - birthYear} years old`; return `${today - birthYear} years old`;
}); });
// On create functions function setCredits(_credits: IPersonCredits) {
fetchPerson(); credits.value = _credits;
//
function fetchPerson() {
if (!props.id) {
console.error("Unable to fetch person, missing id!");
return;
} }
getPerson(props.id) function setPerson(_person: IPerson) {
.then(_person => (person.value = _person)) person.value = _person;
.then(() => getPersonCredits(person.value?.id))
.then(_credits => (credits.value = _credits))
.then(() => personCreditedFrom(credits.value?.cast));
} }
function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number { function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number {
return a.popularity < b.popularity ? 1 : -1; return a.popularity < b.popularity ? 1 : -1;
} }
function alreadyExists(item: IMovie | IShow, pos: number, self: any[]) { function alreadyExists(
const names = self.map(item => item.title); item: IMovie | IShow,
pos: number,
self: Array<IMovie | IShow>
) {
const names = self.map(_item => _item.title);
return names.indexOf(item.title) === pos; return names.indexOf(item.title) === pos;
} }
@@ -147,12 +147,21 @@
.sort(sortPopularity); .sort(sortPopularity);
} }
const computePoster = () => { function fetchPerson() {
if (!person.value) return "/assets/placeholder.png"; if (!props.id) {
else if (!person.value?.poster) return "/assets/no-image.svg"; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -3,24 +3,24 @@
<h3 class="settings__header">Change password</h3> <h3 class="settings__header">Change password</h3>
<form class="form"> <form class="form">
<seasoned-input <seasoned-input
v-model="oldPassword"
placeholder="old password" placeholder="old password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="oldPassword"
/> />
<seasoned-input <seasoned-input
v-model="newPassword"
placeholder="new password" placeholder="new password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="newPassword"
/> />
<seasoned-input <seasoned-input
v-model="newPasswordRepeat"
placeholder="repeat new password" placeholder="repeat new password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="newPasswordRepeat"
/> />
<seasoned-button @click="changePassword">change password</seasoned-button> <seasoned-button @click="changePassword">change password</seasoned-button>
@@ -34,24 +34,20 @@
import SeasonedInput from "@/components/ui/SeasonedInput.vue"; import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage"; import type { IErrorMessage } from "../../interfaces/IErrorMessage";
interface ResetPasswordPayload { // interface ResetPasswordPayload {
old_password: string; // old_password: string;
new_password: string; // new_password: string;
} // }
const oldPassword: Ref<string> = ref(""); const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref(""); const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref(""); const newPasswordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]); const messages: Ref<IErrorMessage[]> = ref([]);
function clearMessages() {
messages.value = [];
}
function addWarningMessage(message: string, title?: string) { function addWarningMessage(message: string, title?: string) {
messages.value.push({ messages.value.push({
message, message,
@@ -64,12 +60,12 @@
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!oldPassword.value || oldPassword?.value?.length === 0) { if (!oldPassword.value || oldPassword?.value?.length === 0) {
addWarningMessage("Missing old password!", "Validation error"); addWarningMessage("Missing old password!", "Validation error");
return reject(); reject();
} }
if (!newPassword.value || newPassword?.value?.length === 0) { if (!newPassword.value || newPassword?.value?.length === 0) {
addWarningMessage("Missing new password!", "Validation error"); addWarningMessage("Missing new password!", "Validation error");
return reject(); reject();
} }
if (newPassword.value !== newPasswordRepeat.value) { if (newPassword.value !== newPasswordRepeat.value) {
@@ -77,7 +73,7 @@
"Password and password repeat do not match!", "Password and password repeat do not match!",
"Validation error" "Validation error"
); );
return reject(); reject();
} }
resolve(true); resolve(true);
@@ -89,15 +85,14 @@
try { try {
validate(); validate();
} catch (error) { } catch (error) {
console.log("not valid!"); console.log("not valid!"); // eslint-disable-line no-console
return;
} }
const body: ResetPasswordPayload = { // const body: ResetPasswordPayload = {
old_password: oldPassword.value, // old_password: oldPassword.value,
new_password: newPassword.value // new_password: newPassword.value
}; // };
const options = {}; // const options = {};
// fetch() // fetch()
} }
</script> </script>

View File

@@ -10,14 +10,14 @@
<form class="form"> <form class="form">
<seasoned-input <seasoned-input
v-model="username"
placeholder="plex username" placeholder="plex username"
type="email" type="email"
v-model="username"
/> />
<seasoned-input <seasoned-input
v-model="password"
placeholder="plex password" placeholder="plex password"
type="password" type="password"
v-model="password"
@enter="authenticatePlex" @enter="authenticatePlex"
> >
</seasoned-input> </seasoned-input>
@@ -48,9 +48,9 @@
import seasonedInput from "@/components/ui/SeasonedInput.vue"; import seasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref, ComputedRef } from "vue";
import { linkPlexAccount, unlinkPlexAccount } from "../../api"; import { linkPlexAccount, unlinkPlexAccount } from "../../api";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { Ref, ComputedRef } from "vue";
import type { IErrorMessage } from "../../interfaces/IErrorMessage"; import type { IErrorMessage } from "../../interfaces/IErrorMessage";
interface Emit { interface Emit {
@@ -64,20 +64,11 @@
const store = useStore(); const store = useStore();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
const loggedIn: ComputedRef<boolean> = computed(
() => store.getters["user/loggedIn"]
);
const plexId: ComputedRef<boolean> = computed( const plexId: ComputedRef<boolean> = computed(
() => store.getters["user/plexId"] () => store.getters["user/plexId"]
); );
const settings: ComputedRef<boolean> = computed(
() => store.getters["user/settings"]
);
async function authenticatePlex() { async function authenticatePlex() {
let username = this.plexUsername;
let password = this.plexPassword;
const { success, message } = await linkPlexAccount( const { success, message } = await linkPlexAccount(
username.value, username.value,
password.value password.value
@@ -92,7 +83,7 @@
messages.value.push({ messages.value.push({
type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error, type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error,
title: success ? "Authenticated with plex" : "Something went wrong", title: success ? "Authenticated with plex" : "Something went wrong",
message: message message
} as IErrorMessage); } as IErrorMessage);
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="container" v-if="query?.length"> <div v-if="query?.length" class="container">
<h2 class="torrent-header-text"> <h2 class="torrent-header-text">
Searching for: <span class="query">{{ query }}</span> Searching for: <span class="query">{{ query }}</span>
</h2> </h2>
@@ -22,22 +22,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject, defineProps } from "vue"; import { ref, inject, defineProps } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { sortableSize } from "../../utils";
import { searchTorrents, addMagnet } from "../../api";
import Loader from "@/components/ui/Loader.vue"; import Loader from "@/components/ui/Loader.vue";
import TorrentTable from "@/components/torrent/TorrentTable.vue"; import TorrentTable from "@/components/torrent/TorrentTable.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { searchTorrents, addMagnet } from "../../api";
import type ITorrent from "../../interfaces/ITorrent"; import type ITorrent from "../../interfaces/ITorrent";
interface Props { interface Props {
query: string; query: string;
tmdb_id?: number; tmdbId?: number;
} }
const loading: Ref<boolean> = ref(true); const loading: Ref<boolean> = ref(true);
const torrents: Ref<ITorrent[]> = ref([]); const torrents: Ref<ITorrent[]> = ref([]);
const release_types: Ref<string[]> = ref(["all"]);
const props = defineProps<Props>(); const props = defineProps<Props>();
const store = useStore(); const store = useStore();
@@ -47,15 +44,12 @@
error; error;
} = inject("notifications"); } = inject("notifications");
fetchTorrents(); function setTorrents(_torrents: ITorrent[]) {
torrents.value = _torrents || [];
}
function fetchTorrents() { function setLoading(state: boolean) {
loading.value = true; loading.value = state;
searchTorrents(props.query)
.then(torrentResponse => (torrents.value = torrentResponse?.results))
.then(() => updateResultCountDisplay())
.finally(() => (loading.value = false));
} }
function updateResultCountDisplay() { 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) { function addTorrent(torrent: ITorrent) {
const { name, magnet } = torrent; const { name, magnet } = torrent;
@@ -75,16 +78,15 @@
timeout: 3000 timeout: 3000
}); });
addMagnet(magnet, name, props.tmdb_id) addMagnet(magnet, name, props.tmdbId)
.then(resp => { .then(() => {
notifications.success({ notifications.success({
title: "Torrent added 🎉", title: "Torrent added 🎉",
description: props.query, description: props.query,
timeout: 3000 timeout: 3000
}); });
}) })
.catch(resp => { .catch(() => {
console.log("Error while adding torrent:", resp?.data);
notifications.error({ notifications.error({
title: "Failed to add torrent 🙅‍♀️", title: "Failed to add torrent 🙅‍♀️",
description: "Check console for more info", description: "Check console for more info",
@@ -92,6 +94,8 @@
}); });
}); });
} }
fetchTorrents();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -5,8 +5,8 @@
<th <th
v-for="column in columns" v-for="column in columns"
:key="column" :key="column"
@click="sortTable(column)"
:class="column === selectedColumn ? 'active' : null" :class="column === selectedColumn ? 'active' : null"
@click="sortTable(column)"
> >
{{ column }} {{ column }}
<span v-if="prevCol === column && direction"></span> <span v-if="prevCol === column && direction"></span>
@@ -18,13 +18,32 @@
<tbody> <tbody>
<tr <tr
v-for="torrent in torrents" v-for="torrent in torrents"
class="table__content"
:key="torrent.magnet" :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 /> <IconMagnet />
</td> </td>
</tr> </tr>
@@ -35,8 +54,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits } from "vue"; import { ref, defineProps, defineEmits } from "vue";
import IconMagnet from "@/icons/IconMagnet.vue"; import IconMagnet from "@/icons/IconMagnet.vue";
import { sortableSize } from "../../utils";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { sortableSize } from "../../utils";
import type ITorrent from "../../interfaces/ITorrent"; import type ITorrent from "../../interfaces/ITorrent";
interface Props { interface Props {
@@ -87,18 +106,6 @@
tableRow.insertAdjacentElement("afterend", expandedRow); 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() { function sortName() {
const torrentsCopy = [...torrents.value]; const torrentsCopy = [...torrents.value];
if (direction.value) { if (direction.value) {
@@ -112,11 +119,11 @@
const torrentsCopy = [...torrents.value]; const torrentsCopy = [...torrents.value];
if (direction.value) { if (direction.value) {
torrents.value = torrentsCopy.sort( torrents.value = torrentsCopy.sort(
(a, b) => parseInt(a.seed) - parseInt(b.seed) (a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10)
); );
} else { } else {
torrents.value = torrentsCopy.sort( 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -2,13 +2,15 @@
<div> <div>
<torrent-search-results <torrent-search-results
:query="query" :query="query"
:tmdb_id="tmdb_id" :tmdb-id="tmdbId"
:class="{ truncated: truncated }" :class="{ truncated: truncated }"
><div ><div
v-if="truncated" v-if="truncated"
class="load-more" class="load-more"
tabindex="0"
role="button" role="button"
@click="truncated = false" @click="truncated = false"
@keydown.enter="truncated = false"
> >
<icon-arrow-down /> <icon-arrow-down />
</div> </div>
@@ -32,7 +34,7 @@
interface Props { interface Props {
query: string; query: string;
tmdb_id?: number; tmdbId?: number;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();

View File

@@ -1,13 +1,23 @@
<template> <template>
<div class="darkToggle"> <div class="darkToggle">
<span @click="toggleDarkmode">{{ darkmodeToggleIcon }}</span> <span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{
darkmodeToggleIcon
}}</span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; 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(() => { const darkmodeToggleIcon = computed(() => {
return darkmode.value ? "🌝" : "🌚"; return darkmode.value ? "🌝" : "🌚";
}); });
@@ -16,14 +26,6 @@
darkmode.value = !darkmode.value; darkmode.value = !darkmode.value;
document.body.className = darkmode.value ? "dark" : "light"; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -2,9 +2,9 @@
<div <div
class="nav__hamburger" class="nav__hamburger"
:class="{ open: isOpen }" :class="{ open: isOpen }"
tabindex="0"
@click="toggle" @click="toggle"
@keydown.enter="toggle" @keydown.enter="toggle"
tabindex="0"
> >
<div v-for="(_, index) in 3" :key="index" class="bar"></div> <div v-for="(_, index) in 3" :key="index" class="bar"></div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div :class="`loader type-${type}`"> <div :class="`loader type-${type || LoaderHeightType.Page}`">
<i class="loader--icon"> <i class="loader--icon">
<i class="loader--icon-spinner" /> <i class="loader--icon-spinner" />
</i> </i>
@@ -13,17 +13,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from "vue"; import { defineProps } from "vue";
import LoaderHeightType from "../../interfaces/ILoader";
enum LoaderHeightType {
Page = "page",
Section = "section"
}
interface Props { interface Props {
type?: LoaderHeightType; type?: LoaderHeightType;
} }
const { type = LoaderHeightType.Page } = defineProps<Props>(); defineProps<Props>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="text-input__loading" :style="`margin-top: ${top}rem`"> <div class="text-input__loading" :style="`margin-top: ${top || 0}rem`">
<div <div
class="text-input__loading--line" v-for="l in Array(count || 1)"
:class="lineClass"
v-for="l in Array(count)"
:key="l" :key="l"
class="text-input__loading--line"
:class="lineClass || ''"
></div> ></div>
</div> </div>
</template> </template>
@@ -13,12 +13,12 @@
import { defineProps } from "vue"; import { defineProps } from "vue";
interface Props { interface Props {
count?: Number; count?: number;
lineClass?: String; lineClass?: string;
top?: Number; top?: number;
} }
const { count = 1, lineClass = "", top = 0 } = defineProps<Props>(); defineProps<Props>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,27 +1,26 @@
<template> <template>
<button <button
type="button" type="button"
@click="emit('click')"
:class="{ active: active, fullwidth: fullWidth }" :class="{ active: active, fullwidth: fullWidth }"
@click="emit('click')"
> >
<slot></slot> <slot></slot>
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits } from "vue"; import { defineProps, defineEmits } from "vue";
import type { Ref } from "vue";
interface Props { interface Props {
active?: Boolean; active?: boolean;
fullWidth?: Boolean; fullWidth?: boolean;
} }
interface Emit { interface Emit {
(e: "click"); (e: "click");
} }
const props = defineProps<Props>(); defineProps<Props>();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
</script> </script>

View File

@@ -2,9 +2,10 @@
<div class="group" :class="{ completed: modelValue, focus }"> <div class="group" :class="{ completed: modelValue, focus }">
<component :is="inputIcon" v-if="inputIcon" /> <component :is="inputIcon" v-if="inputIcon" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input <input
class="input" class="input"
:type="toggledType || type" :type="toggledType || type || 'text'"
:placeholder="placeholder" :placeholder="placeholder"
:value="modelValue" :value="modelValue"
@input="handleInput" @input="handleInput"
@@ -15,10 +16,10 @@
<i <i
v-if="modelValue && type === 'password'" v-if="modelValue && type === 'password'"
@click="toggleShowPassword"
@keydown.enter="toggleShowPassword"
class="show noselect" class="show noselect"
tabindex="0" tabindex="0"
@click="toggleShowPassword"
@keydown.enter="toggleShowPassword"
>{{ toggledType == "password" ? "show" : "hide" }}</i >{{ toggledType == "password" ? "show" : "hide" }}</i
> >
</div> </div>
@@ -43,16 +44,16 @@
(e: "update:modelValue", value: string); (e: "update:modelValue", value: string);
} }
const { placeholder, type = "text", modelValue } = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
const toggledType: Ref<string> = ref(type); const toggledType: Ref<string> = ref(props.type);
const focus: Ref<boolean> = ref(false); const focus: Ref<boolean> = ref(false);
const inputIcon = computed(() => { const inputIcon = computed(() => {
if (type === "password") return IconKey; if (props.type === "password") return IconKey;
if (type === "email") return IconEmail; if (props.type === "email") return IconEmail;
if (type === "torrents") return IconBinoculars; if (props.type === "torrents") return IconBinoculars;
return false; return false;
}); });

View File

@@ -1,15 +1,15 @@
<template> <template>
<transition-group name="fade"> <transition-group name="fade">
<div <div
class="card"
v-for="(message, index) in messages" v-for="(message, index) in messages"
:key="generateMessageKey(index, message)" :key="generateMessageKey(index, message)"
class="card"
:class="message.type || 'warning'" :class="message.type || 'warning'"
> >
<span class="pinstripe"></span> <span class="pinstripe"></span>
<div class="content"> <div class="content">
<h2 class="title"> <h2 class="title">
{{ message.title }} {{ message.title || titleFromType(message.type) }}
</h2> </h2>
<span v-if="message.message" class="message">{{ <span v-if="message.message" class="message">{{
message.message message.message
@@ -27,7 +27,6 @@
ErrorMessageTypes, ErrorMessageTypes,
IErrorMessage IErrorMessage
} from "../../interfaces/IErrorMessage"; } from "../../interfaces/IErrorMessage";
import type { Ref } from "vue";
interface Props { interface Props {
messages: IErrorMessage[]; messages: IErrorMessage[];
@@ -52,8 +51,9 @@
} }
function dismiss(index: number) { function dismiss(index: number) {
props.messages.splice(index, 1); const _messages = [...props.messages];
emit("update:messages", [...props.messages]); _messages.splice(index, 1);
emit("update:messages", _messages);
} }
function generateMessageKey( function generateMessageKey(

View File

@@ -4,8 +4,8 @@
v-for="option in options" v-for="option in options"
:key="option" :key="option"
class="toggle-button" class="toggle-button"
@click="toggleTo(option)"
:class="selected === option ? 'selected' : null" :class="selected === option ? 'selected' : null"
@click="() => toggleTo(option)"
> >
{{ option }} {{ option }}
</button> </button>
@@ -13,8 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits } from "vue"; import { defineProps, defineEmits } from "vue";
import type { Ref } from "vue";
interface Props { interface Props {
options: string[]; options: string[];

View File

@@ -4,9 +4,9 @@
viewBox="0 0 32 32" viewBox="0 0 32 32"
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
style="transition-duration: 0s"
@click="$emit('click')" @click="$emit('click')"
@keydown="event => $emit('keydown', event)" @keydown="event => $emit('keydown', event)"
style="transition-duration: 0s"
> >
<path <path
fill="inherit" fill="inherit"

View File

@@ -1,9 +1,10 @@
<template> <template>
<svg <svg
@click="$emit('click')"
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32" viewBox="0 0 32 32"
@click="$emit('click')"
@keydown.enter="$emit('click')"
> >
<path <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" 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"

View File

@@ -4,18 +4,18 @@
viewBox="0 0 32 32" viewBox="0 0 32 32"
width="100%" width="100%"
height="100%" height="100%"
style="transition-duration: 0s;" style="transition-duration: 0s"
> >
<path <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" 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>
<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" 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>
<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" 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> ></path>
</svg> </svg>

View 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
View 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[];
}

View File

@@ -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 { export enum MediaTypes {
Movie = "movie", Movie = "movie",
Show = "show", Show = "show",
@@ -155,3 +133,25 @@ export interface ICrew {
profile_path: string | null; profile_path: string | null;
type: string; 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;
}

View File

@@ -0,0 +1,6 @@
enum LoaderHeightType {
Page = "page",
Section = "section"
}
export default LoaderHeightType;

View File

@@ -1,6 +1,6 @@
export default interface INavigationIcon { export default interface INavigationIcon {
title: string; title: string;
route: string; route: string;
icon: any; icon: any; // eslint-disable-line @typescript-eslint/no-explicit-any
requiresAuth?: boolean; requiresAuth?: boolean;
} }

View File

@@ -1,17 +1,13 @@
import type { MediaTypes } from "./IList"; 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 { export interface IStatePopup {
id: number; id: number;
type: MediaTypes; type: MediaTypes;
open: boolean; open: boolean;
} }
export interface IPopupQuery {
movie?: number | string;
show?: number | string;
person?: number | string;
}

View File

@@ -1,8 +1,9 @@
import { createApp } from "vue"; import { createApp } from "vue";
import router from "./routes"; import router from "./routes";
import store from "./store"; import store from "./store";
import Toast from "./plugins/Toast"; import Toast from "./plugins/Toast";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const App = require("./App.vue").default; const App = require("./App.vue").default;
store.dispatch("darkmodeModule/findAndSetDarkmodeSupported"); store.dispatch("darkmodeModule/findAndSetDarkmodeSupported");

View File

@@ -5,6 +5,7 @@ const state: IStateDarkmode = {
userChoice: undefined userChoice: undefined
}; };
/* eslint-disable @typescript-eslint/no-shadow */
export default { export default {
namespaced: true, namespaced: true,
state, state,

View File

@@ -3,6 +3,7 @@ import type IStateDocumentTitle from "../interfaces/IStateDocumentTitle";
const capitalize = (string: string) => { const capitalize = (string: string) => {
if (!string) return; if (!string) return;
/* eslint-disable-next-line consistent-return */
return string.includes(" ") return string.includes(" ")
? string ? string
.split(" ") .split(" ")
@@ -25,6 +26,7 @@ const state: IStateDocumentTitle = {
title: undefined title: undefined
}; };
/* eslint-disable @typescript-eslint/no-shadow, no-return-assign */
export default { export default {
namespaced: true, namespaced: true,
state, state,
@@ -42,10 +44,10 @@ export default {
} }
}, },
actions: { actions: {
updateEmoji({ commit }, emoji: String) { updateEmoji({ commit }, emoji: string) {
commit("SET_EMOJI", emoji); commit("SET_EMOJI", emoji);
}, },
updateTitle({ commit }, title: String) { updateTitle({ commit }, title: string) {
commit("SET_TITLE", title); commit("SET_TITLE", title);
} }
} }

View File

@@ -4,6 +4,7 @@ const state: IStateHamburger = {
open: false open: false
}; };
/* eslint-disable @typescript-eslint/no-shadow, no-return-assign */
export default { export default {
namespaced: true, namespaced: true,
state, state,

View File

@@ -1,6 +1,8 @@
import router from "../routes";
import { MediaTypes } from "../interfaces/IList"; 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) => { const removeIncludedQueryParams = (params, key) => {
if (params.has(key)) params.delete(key); if (params.has(key)) params.delete(key);
@@ -9,11 +11,9 @@ const removeIncludedQueryParams = (params, key) => {
function paramsToObject(entries) { function paramsToObject(entries) {
const result = {}; const result = {};
for (const [key, value] of entries) { return entries.forEach((key, value) => {
// each 'entry' is a [key, value] tupple
result[key] = value; result[key] = value;
} });
return result;
} }
const updateQueryParams = (id: number = null, type: MediaTypes = null) => { const updateQueryParams = (id: number = null, type: MediaTypes = null) => {
@@ -38,6 +38,7 @@ const state: IStatePopup = {
open: false open: false
}; };
/* eslint-disable @typescript-eslint/no-shadow */
export default { export default {
namespaced: true, namespaced: true,
state, state,
@@ -60,7 +61,10 @@ export default {
}, },
actions: { actions: {
open: ({ commit }, { id, type }: { id: number; type: MediaTypes }) => { 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 }); commit("SET_OPEN", { id, type });
updateQueryParams(id, type); updateQueryParams(id, type);
}, },
@@ -68,11 +72,11 @@ export default {
commit("SET_CLOSE"); commit("SET_CLOSE");
updateQueryParams(); // reset updateQueryParams(); // reset
}, },
resetStateFromUrlQuery: ({ commit }, query: any) => { resetStateFromUrlQuery: ({ commit }, query: IPopupQuery) => {
let { movie, show, person } = query; let { movie, show, person } = query;
movie = !isNaN(movie) ? Number(movie) : movie; movie = !Number.isNaN(movie) ? Number(movie) : movie;
show = !isNaN(show) ? Number(show) : show; show = !Number.isNaN(show) ? Number(show) : show;
person = !isNaN(person) ? Number(person) : person; person = !Number.isNaN(person) ? Number(person) : person;
if (movie) commit("SET_OPEN", { id: movie, type: "movie" }); if (movie) commit("SET_OPEN", { id: movie, type: "movie" });
else if (show) commit("SET_OPEN", { id: show, type: "show" }); else if (show) commit("SET_OPEN", { id: show, type: "show" });

View File

@@ -6,12 +6,10 @@ const state: IStateTorrent = {
resultCount: null resultCount: null
}; };
/* eslint-disable @typescript-eslint/no-shadow */
export default { export default {
namespaced: true, namespaced: true,
state: { state,
results: [],
resultCount: null
},
getters: { getters: {
results: (state: IStateTorrent) => { results: (state: IStateTorrent) => {
return state.results; return state.results;

View File

@@ -56,6 +56,7 @@ export default {
username: state => state.username, username: state => state.username,
settings: state => state.settings, settings: state => state.settings,
token: state => state.token, token: state => state.token,
// loggedIn: state => true,
loggedIn: state => state && state.username !== null, loggedIn: state => state && state.username !== null,
admin: state => state.admin, admin: state => state.admin,
plexId: state => { plexId: state => {

View File

@@ -1,13 +1,14 @@
<template> <template>
<div class="wrapper" v-if="plexId"> <div v-if="plexId" class="wrapper">
<h1>Your watch activity</h1> <h1>Your watch activity</h1>
<div style="display: flex; flex-direction: row"> <div style="display: flex; flex-direction: row">
<label class="filter"> <label class="filter" for="dayinput">
<span>Days:</span> <span>Days:</span>
<input <input
class="dayinput" id="dayinput"
v-model="days" v-model="days"
class="dayinput"
placeholder="days" placeholder="days"
type="number" type="number"
pattern="[0-9]*" pattern="[0-9]*"
@@ -15,15 +16,15 @@
/> />
</label> </label>
<label class="filter"> <div class="filter">
<span>Data sorted by:</span> <span>Data sorted by:</span>
<toggle-button <toggle-button
v-model:selected="graphViewMode"
class="filter-item" class="filter-item"
:options="[GraphTypes.Plays, GraphTypes.Duration]" :options="[GraphTypes.Plays, GraphTypes.Duration]"
v-model:selected="graphViewMode"
@change="fetchChartData" @change="fetchChartData"
/> />
</label> </div>
</div> </div>
<div class="chart-section"> <div class="chart-section">
@@ -34,9 +35,9 @@
:data="playsByDayData" :data="playsByDayData"
type="line" type="line"
:stacked="false" :stacked="false"
:datasetDescriptionSuffix="`watch last ${days} days`" :dataset-description-suffix="`watch last ${days} days`"
:tooltipDescriptionSuffix="selectedGraphViewMode.tooltipLabel" :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graphValueType="selectedGraphViewMode.valueType" :graph-value-type="selectedGraphViewMode.valueType"
/> />
</div> </div>
@@ -44,13 +45,12 @@
<div class="graph"> <div class="graph">
<Graph <Graph
v-if="playsByDayofweekData" v-if="playsByDayofweekData"
class="graph"
:data="playsByDayofweekData" :data="playsByDayofweekData"
type="bar" type="bar"
:stacked="true" :stacked="true"
:datasetDescriptionSuffix="`watch last ${days} days`" :dataset-description-suffix="`watch last ${days} days`"
:tooltipDescriptionSuffix="selectedGraphViewMode.tooltipLabel" :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graphValueType="selectedGraphViewMode.valueType" :graph-value-type="selectedGraphViewMode.valueType"
/> />
</div> </div>
</div> </div>
@@ -61,23 +61,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import Graph from "@/components/Graph.vue"; import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue"; import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue"; import IconStop from "@/icons/IconStop.vue";
import { fetchGraphData } from "../api";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { fetchGraphData } from "../api";
enum GraphTypes { import {
Plays = "plays", GraphTypes,
Duration = "duration" GraphValueTypes,
} IGraphData
} from "../interfaces/IGraph";
enum GraphValueTypes {
Number = "number",
Time = "time"
}
const store = useStore(); const store = useStore();
@@ -85,9 +80,6 @@
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays); const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
const plexId = computed(() => store.getters["user/plexId"]); const plexId = computed(() => store.getters["user/plexId"]);
const selectedGraphViewMode = computed(() =>
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
const graphValueViewMode = [ const graphValueViewMode = [
{ {
type: GraphTypes.Plays, type: GraphTypes.Plays,
@@ -101,13 +93,27 @@
} }
]; ];
const playsByDayData: Ref<any> = ref(null); const playsByDayData: Ref<IGraphData> = ref(null);
const playsByDayofweekData: Ref<any> = ref(null); const playsByDayofweekData: Ref<IGraphData> = ref(null);
fetchChartData();
function fetchChartData() { const selectedGraphViewMode = computed(() =>
fetchPlaysByDay(); graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
fetchPlaysByDayOfWeek(); );
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() { async function fetchPlaysByDay() {
@@ -126,21 +132,12 @@
).then(data => convertDateLabels(data?.data)); ).then(data => convertDateLabels(data?.data));
} }
function convertDateLabels(data) { function fetchChartData() {
return { fetchPlaysByDay();
labels: data.categories.map(convertDateStringToDayMonth), fetchPlaysByDayOfWeek();
series: data.series
};
} }
function convertDateStringToDayMonth(date: string): string { fetchChartData();
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date;
}
const [year, month, day] = date.split("-");
return `${day}.${month}`;
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ResultsSection :title="listName" :apiFunction="_getTmdbMovieListByName" /> <ResultsSection :title="listName" :api-function="_getTmdbMovieListByName" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,14 +1,14 @@
<template> <template>
<section class="profile"> <section class="profile">
<div class="profile__content" v-if="loggedIn"> <div v-if="loggedIn" class="profile__content">
<header class="profile__header"> <header class="profile__header">
<h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2> <h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2>
<div class="button--group"> <div class="button--group">
<seasoned-button @click="toggleSettings" :active="showSettings">{{ <seasoned-button :active="showSettings" @click="toggleSettings">{{
showSettings ? "hide settings" : "show settings" showSettings ? "hide settings" : "show settings"
}}</seasoned-button> }}</seasoned-button>
<seasoned-button @click="toggleActivity" :active="showActivity">{{ <seasoned-button :active="showActivity" @click="toggleActivity">{{
showActivity ? "hide activity" : "show activity" showActivity ? "hide activity" : "show activity"
}}</seasoned-button> }}</seasoned-button>
@@ -16,15 +16,15 @@
</div> </div>
</header> </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" /> <page-header title="Your requests" :info="resultCount" />
<results-list v-if="results" :results="results" /> <results-list v-if="results" :results="results" />
</div> </div>
<section class="not-found" v-if="!loggedIn"> <section v-if="!loggedIn" class="not-found">
<div class="not-found__content"> <div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2> <h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{ name: 'signin' }" exact title="Sign in here"> <router-link :to="{ name: 'signin' }" exact title="Sign in here">
@@ -40,11 +40,11 @@
import { useStore } from "vuex"; import { useStore } from "vuex";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import ResultsList from "@/components/ResultsList.vue"; import ResultsList from "@/components/ResultsList.vue";
import Settings from "@/pages/SettingsPage.vue"; import SettingsPage from "@/pages/SettingsPage.vue";
import Activity from "@/pages/ActivityPage.vue"; import ActivityPage from "@/pages/ActivityPage.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import { getEmoji, getUserRequests, getSettings, logout } from "../api";
import type { Ref, ComputedRef } from "vue"; import type { Ref, ComputedRef } from "vue";
import { getEmoji, getUserRequests } from "../api";
import type { ListResults } from "../interfaces/IList"; import type { ListResults } from "../interfaces/IList";
const emoji: Ref<string> = ref(""); const emoji: Ref<string> = ref("");
@@ -57,7 +57,6 @@
const loggedIn: Ref<boolean> = computed(() => store.getters["user/loggedIn"]); const loggedIn: Ref<boolean> = computed(() => store.getters["user/loggedIn"]);
const username: Ref<string> = computed(() => store.getters["user/username"]); 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 resultCount: ComputedRef<number | string> = computed(() => {
const currentCount = results?.value?.length || 0; const currentCount = results?.value?.length || 0;
@@ -65,6 +64,10 @@
return `${currentCount} of ${totalCount} results`; return `${currentCount} of ${totalCount} results`;
}); });
function setEmoji(_emoji: string) {
emoji.value = _emoji;
}
// Component loaded actions // Component loaded actions
getUserRequests().then(requestResults => { getUserRequests().then(requestResults => {
if (!requestResults?.results) return; if (!requestResults?.results) return;
@@ -72,26 +75,12 @@
totalResults.value = requestResults.total_results; 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"); showSettings.value = window.location.toString().includes("settings=true");
showActivity.value = window.location.toString().includes("activity=true"); showActivity.value = window.location.toString().includes("activity=true");
// Component loaded actions end // 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) { function updateQueryParams(key, value = false) {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.has(key)) { 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -2,27 +2,27 @@
<section> <section>
<h1>Register new user</h1> <h1>Register new user</h1>
<form class="form" ref="formElement"> <form ref="formElement" class="form">
<seasoned-input <seasoned-input
v-model="username"
placeholder="username" placeholder="username"
icon="Email" icon="Email"
type="email" type="email"
v-model="username"
@keydown.enter="focusOnNextElement" @keydown.enter="focusOnNextElement"
/> />
<seasoned-input <seasoned-input
v-model="password"
placeholder="password" placeholder="password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="password"
@keydown.enter="focusOnNextElement" @keydown.enter="focusOnNextElement"
/> />
<seasoned-input <seasoned-input
v-model="passwordRepeat"
placeholder="repeat password" placeholder="repeat password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="passwordRepeat"
@keydown.enter="submit" @keydown.enter="submit"
/> />
@@ -44,10 +44,10 @@
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedInput from "@/components/ui/SeasonedInput.vue"; import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref } from "vue";
import { register } from "../api"; import { register } from "../api";
import { focusFirstFormInput, focusOnNextElement } from "../utils"; import { focusFirstFormInput, focusOnNextElement } from "../utils";
import { ErrorMessageTypes } from "../interfaces/IErrorMessage"; import { ErrorMessageTypes } from "../interfaces/IErrorMessage";
import type { Ref } from "vue";
import type { IErrorMessage } from "../interfaces/IErrorMessage"; import type { IErrorMessage } from "../interfaces/IErrorMessage";
const username: Ref<string> = ref(""); const username: Ref<string> = ref("");
@@ -85,32 +85,27 @@
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!username.value || username?.value?.length === 0) { if (!username.value || username?.value?.length === 0) {
addWarningMessage("Missing username", "Validation error"); addWarningMessage("Missing username", "Validation error");
return reject(); reject();
} }
if (!password.value || password?.value?.length === 0) { if (!password.value || password?.value?.length === 0) {
addWarningMessage("Missing password", "Validation error"); 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"); addWarningMessage("Missing repeat password", "Validation error");
return reject(); reject();
} }
if (passwordRepeat != password) { if (passwordRepeat.value !== password.value) {
addWarningMessage("Passwords do not match", "Validation error"); addWarningMessage("Passwords do not match", "Validation error");
return reject(); reject();
} }
resolve(true); resolve(true);
}); });
} }
function submit() {
clearMessages();
validate().then(registerUser);
}
function registerUser() { function registerUser() {
register(username.value, password.value) register(username.value, password.value)
.then(data => { .then(data => {
@@ -120,15 +115,19 @@
}) })
.catch(error => { .catch(error => {
if (error?.status === 401) { if (error?.status === 401) {
return addErrorMessage( addErrorMessage("Incorrect username or password", "Access denied");
"Incorrect username or password", return null;
"Access denied"
);
} }
addErrorMessage(error?.message, "Unexpected error"); addErrorMessage(error?.message, "Unexpected error");
return null;
}); });
} }
function submit() {
clearMessages();
validate().then(registerUser);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ResultsSection title="Requests" :apiFunction="getRequests" /> <ResultsSection title="Requests" :api-function="getRequests" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,18 +1,18 @@
<template> <template>
<div> <div>
<div style="display: flex; flex-direction: row"> <div style="display: flex; flex-direction: row">
<label class="filter"> <div class="filter">
<span>Search filter:</span> <span>Search filter:</span>
<toggle-button <toggle-button
:options="toggleOptions"
v-model:selected="mediaType" v-model:selected="mediaType"
:options="toggleOptions"
@change="toggleChanged" @change="toggleChanged"
/> />
</label> </div>
</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> <h1 v-else class="no-results">No query found, please search above</h1>
</div> </div>
</template> </template>
@@ -20,12 +20,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { searchTmdb } from "../api";
import ResultsSection from "@/components/ResultsSection.vue"; import ResultsSection from "@/components/ResultsSection.vue";
import PageHeader from "@/components/PageHeader.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue"; import ToggleButton from "@/components/ui/ToggleButton.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { searchTmdb } from "../api";
import { MediaTypes } from "../interfaces/IList"; import { MediaTypes } from "../interfaces/IList";
// interface ISearchParams { // interface ISearchParams {
@@ -54,20 +53,14 @@
mediaType.value = (urlQuery?.media_type as MediaTypes) || mediaType.value; mediaType.value = (urlQuery?.media_type as MediaTypes) || mediaType.value;
} }
let search = ( const search = (
_page = page.value || 1, _page = page.value || 1,
_mediaType = mediaType.value || "all" _mediaType = mediaType.value || "all"
) => { ) => {
return searchTmdb(query.value, _page, adult.value, _mediaType); return searchTmdb(query.value, _page, adult.value, _mediaType);
}; };
function toggleChanged() {
updateQueryParams();
}
function updateQueryParams() { function updateQueryParams() {
const { query, page, adult, media_type } = route.query;
router.push({ router.push({
path: "search", path: "search",
query: { query: {
@@ -76,6 +69,10 @@
} }
}); });
} }
function toggleChanged() {
updateQueryParams();
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -11,12 +11,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useRoute } from "vue-router";
import ChangePassword from "@/components/profile/ChangePassword.vue"; import ChangePassword from "@/components/profile/ChangePassword.vue";
import LinkPlexAccount from "@/components/profile/LinkPlexAccount.vue"; import LinkPlexAccount from "@/components/profile/LinkPlexAccount.vue";
import { getSettings } from "../api"; import { getSettings } from "../api";
const store = useStore(); 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() { function reloadSettings() {
return getSettings().then(response => { return getSettings().then(response => {
@@ -26,6 +42,9 @@
store.dispatch("user/setSettings", settings); store.dispatch("user/setSettings", settings);
}); });
} }
// Functions called on component load
displayWarningIfMissingPlexAccount();
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -2,19 +2,19 @@
<section> <section>
<h1>Sign in</h1> <h1>Sign in</h1>
<form class="form" ref="formElement"> <form ref="formElement" class="form">
<seasoned-input <seasoned-input
v-model="username"
placeholder="username" placeholder="username"
icon="Email" icon="Email"
type="email" type="email"
v-model="username"
@keydown.enter="focusOnNextElement" @keydown.enter="focusOnNextElement"
/> />
<seasoned-input <seasoned-input
v-model="password"
placeholder="password" placeholder="password"
icon="Keyhole" icon="Keyhole"
type="password" type="password"
v-model="password"
@keydown.enter="submit" @keydown.enter="submit"
/> />
@@ -35,10 +35,10 @@
import SeasonedInput from "@/components/ui/SeasonedInput.vue"; import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref } from "vue";
import { login } from "../api"; import { login } from "../api";
import { focusFirstFormInput, focusOnNextElement } from "../utils"; import { focusFirstFormInput, focusOnNextElement } from "../utils";
import { ErrorMessageTypes } from "../interfaces/IErrorMessage"; import { ErrorMessageTypes } from "../interfaces/IErrorMessage";
import type { Ref } from "vue";
import type { IErrorMessage } from "../interfaces/IErrorMessage"; import type { IErrorMessage } from "../interfaces/IErrorMessage";
const username: Ref<string> = ref(""); const username: Ref<string> = ref("");
@@ -75,23 +75,18 @@
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!username.value || username?.value?.length === 0) { if (!username.value || username?.value?.length === 0) {
addWarningMessage("Missing username", "Validation error"); addWarningMessage("Missing username", "Validation error");
return reject(); reject();
} }
if (!password.value || password?.value?.length === 0) { if (!password.value || password?.value?.length === 0) {
addWarningMessage("Missing password", "Validation error"); addWarningMessage("Missing password", "Validation error");
return reject(); reject();
} }
resolve(true); resolve(true);
}); });
} }
function submit() {
clearMessages();
validate().then(signin);
}
function signin() { function signin() {
login(username.value, password.value, true) login(username.value, password.value, true)
.then(data => { .then(data => {
@@ -101,15 +96,19 @@
}) })
.catch(error => { .catch(error => {
if (error?.status === 401) { if (error?.status === 401) {
return addErrorMessage( addErrorMessage("Incorrect username or password", "Access denied");
"Incorrect username or password", return null;
"Access denied"
);
} }
addErrorMessage(error?.message, "Unexpected error"); addErrorMessage(error?.message, "Unexpected error");
return null;
}); });
} }
function submit() {
clearMessages();
validate().then(signin);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -7,8 +7,8 @@
<seasoned-input <seasoned-input
v-model="query" v-model="query"
type="torrents" type="torrents"
@keydown.enter="setTorrentQuery"
placeholder="Search torrents" placeholder="Search torrents"
@keydown.enter="setTorrentQuery"
/> />
<seasoned-button @click="setTorrentQuery">Search</seasoned-button> <seasoned-button @click="setTorrentQuery">Search</seasoned-button>
</div> </div>
@@ -27,8 +27,8 @@
import SeasonedButton from "@/components/ui/SeasonedButton.vue"; import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import TorrentList from "@/components/torrent/TorrentSearchResults.vue"; import TorrentList from "@/components/torrent/TorrentSearchResults.vue";
import ActiveTorrents from "@/components/torrent/ActiveTorrents.vue"; import ActiveTorrents from "@/components/torrent/ActiveTorrents.vue";
import { getValueFromUrlQuery, setUrlQueryParameter } from "../utils";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { getValueFromUrlQuery, setUrlQueryParameter } from "../utils";
const urlQuery = getValueFromUrlQuery("query"); const urlQuery = getValueFromUrlQuery("query");

View File

@@ -1,20 +1,24 @@
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events, vue/no-v-html -->
<template> <template>
<transition name="slide"> <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--content">
<div class="toast--icon"> <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>
<div class="toast--text" v-if="description"> <div v-if="description" class="toast--text">
<span class="toast--text__title">{{ title }}</span> <span class="toast--text__title">{{ title }}</span>
<br /><span <br /><span
class="toast--text__description" class="toast--text__description"
v-html="description" v-html="description"
></span> ></span>
</div> </div>
<div class="toast--text" v-else> <div v-else class="toast--text">
<span class="toast--text__title-large">{{ title }}</span> <span class="toast--text__title-large">{{ title }}</span>
</div> </div>
<div class="toast--dismiss" @click="dismiss"> <div class="toast--dismiss" @click="dismiss">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</div> </div>
@@ -37,14 +41,7 @@
timeout?: number; timeout?: number;
} }
const { const props = defineProps<Props>();
type = "info",
title,
description,
image,
link,
timeout = 2000
} = defineProps<Props>();
const router = useRouter(); const router = useRouter();
const show: Ref<boolean> = ref(false); const show: Ref<boolean> = ref(false);
@@ -53,16 +50,16 @@
show.value = true; show.value = true;
setTimeout(() => { setTimeout(() => {
console.log("Notification time is up 👋"); console.log("Notification time is up 👋"); // eslint-disable-line no-console
show.value = false; show.value = false;
}, timeout); }, props.timeout || 2000);
}); });
function clicked() { function clicked() {
show.value = false; show.value = false;
if (link) { if (props.link) {
router.push(link); router.push(props.link);
} }
} }
</script> </script>

View File

@@ -6,11 +6,7 @@ const optionsDefaults = {
data: { data: {
type: "info", type: "info",
show: true, show: true,
timeout: 3000, timeout: 3000
onCreate(created = null) {},
onEdit(editted = null) {},
onRemove(removed = null) {}
} }
}; };
@@ -29,8 +25,8 @@ function toast(options) {
} }
export default { export default {
install(app, options) { install(app) {
console.log("installing toast plugin!"); console.log("installing toast plugin!"); // eslint-disable-line no-console
function info(options) { function info(options) {
toast({ type: "info", ...options }); toast({ type: "info", ...options });
@@ -53,6 +49,8 @@ export default {
} }
const notifications = { info, success, warning, error, simple }; const notifications = { info, success, warning, error, simple };
/* eslint-disable-next-line no-param-reassign */
app.config.globalProperties.$notifications = notifications; app.config.globalProperties.$notifications = notifications;
app.provide("notifications", notifications); app.provide("notifications", notifications);

View File

@@ -1,11 +1,7 @@
import { defineAsyncComponent } from "vue"; import { createRouter, createWebHistory } from "vue-router";
import { import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
createRouter,
createWebHistory, /* eslint-disable-next-line import/no-cycle */
RouteRecordRaw,
NavigationGuardNext,
RouteLocationNormalized
} from "vue-router";
import store from "./store"; import store from "./store";
declare global { declare global {
@@ -14,16 +10,16 @@ declare global {
} }
} }
let routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
name: "home", name: "home",
path: "/", path: "/",
component: () => import("./pages/Home.vue") component: () => import("./pages/HomePage.vue")
}, },
{ {
name: "activity", name: "activity",
path: "/activity", path: "/activity",
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresPlexAccount: true },
component: () => import("./pages/ActivityPage.vue") component: () => import("./pages/ActivityPage.vue")
}, },
{ {
@@ -80,7 +76,7 @@ let routes: Array<RouteRecordRaw> = [
{ {
name: "404", name: "404",
path: "/404", path: "/404",
component: () => import("./pages/404.vue") component: () => import("./pages/404Page.vue")
} }
// { // {
// path: "*", // path: "*",
@@ -100,9 +96,10 @@ const router = createRouter({
}); });
const loggedIn = () => store.getters["user/loggedIn"]; 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"]; const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
/* eslint-disable @typescript-eslint/no-explicit-any */
router.beforeEach( router.beforeEach(
(to: RouteLocationNormalized, from: RouteLocationNormalized, next: any) => { (to: RouteLocationNormalized, from: RouteLocationNormalized, next: any) => {
store.dispatch("documentTitle/updateTitle", to.name); 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(); next();
} }
); );

View File

@@ -4,9 +4,11 @@ import darkmodeModule from "./modules/darkmodeModule";
import documentTitle from "./modules/documentTitle"; import documentTitle from "./modules/documentTitle";
import torrentModule from "./modules/torrentModule"; import torrentModule from "./modules/torrentModule";
import user from "./modules/user"; import user from "./modules/user";
import popup from "./modules/popup";
import hamburger from "./modules/hamburger"; import hamburger from "./modules/hamburger";
/* eslint-disable-next-line import/no-cycle */
import popup from "./modules/popup";
const store = createStore({ const store = createStore({
modules: { modules: {
darkmodeModule, darkmodeModule,

View File

@@ -5,17 +5,17 @@ export const sortableSize = (string: string): number => {
if (UNITS.indexOf(unit) === -1) return null; if (UNITS.indexOf(unit) === -1) return null;
const exponent = UNITS.indexOf(unit) * 3; const exponent = UNITS.indexOf(unit) * 3;
return Number(numStr) * Math.pow(10, exponent); return Number(numStr) * exponent ** 10;
}; };
export const parseJwt = (token: string) => { export const parseJwt = (token: string) => {
var base64Url = token.split(".")[1]; const base64Url = token.split(".")[1];
var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
var jsonPayload = decodeURIComponent( const jsonPayload = decodeURIComponent(
atob(base64) atob(base64)
.split("") .split("")
.map(function (c) { .map(c => {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`;
}) })
.join("") .join("")
); );
@@ -48,19 +48,15 @@ export function focusFirstFormInput(formElement: HTMLFormElement): void {
export function focusOnNextElement(elementEvent: KeyboardEvent): void { export function focusOnNextElement(elementEvent: KeyboardEvent): void {
const { target } = elementEvent; const { target } = elementEvent;
console.log("target:", target);
if (!target) return; if (!target) return;
const form = document.getElementsByTagName("form")[0]; const form = document.getElementsByTagName("form")[0];
console.log("form:", form);
if (!form) return; if (!form) return;
const inputElements = form.getElementsByTagName("input"); const inputElements = form.getElementsByTagName("input");
console.log("inputElements:", inputElements);
const targetIndex = Array.from(inputElements).findIndex( const targetIndex = Array.from(inputElements).findIndex(
element => element === target element => element === target
); );
console.log("targetIndex:", targetIndex);
if (targetIndex < inputElements.length) { if (targetIndex < inputElements.length) {
inputElements[targetIndex + 1].focus(); inputElements[targetIndex + 1].focus();
} }
@@ -68,15 +64,18 @@ export function focusOnNextElement(elementEvent: KeyboardEvent): void {
export function humanMinutes(minutes) { export function humanMinutes(minutes) {
if (minutes instanceof Array) { if (minutes instanceof Array) {
/* eslint-disable-next-line prefer-destructuring, no-param-reassign */
minutes = minutes[0]; minutes = minutes[0];
} }
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const minutesLeft = minutes - hours * 60; const minutesLeft = minutes - hours * 60;
if (minutesLeft == 0) { if (minutesLeft === 0) {
return hours > 1 ? `${hours} hours` : `${hours} hour`; return hours > 1 ? `${hours} hours` : `${hours} hour`;
} else if (hours == 0) { }
if (hours === 0) {
return `${minutesLeft} min`; return `${minutesLeft} min`;
} }
@@ -98,3 +97,38 @@ export function setUrlQueryParameter(parameter: string, value: string): void {
window.history.pushState({}, "search", url); 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
View File

@@ -1,5 +1,6 @@
declare module "*.vue" { declare module "*.vue" {
import { defineComponent } from "vue"; import { defineComponent } from "vue";
const Component: ReturnType<typeof defineComponent>; const Component: ReturnType<typeof defineComponent>;
export default Component; export default Component;
} }