Feat: Dynamic colors (#101)

* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Feat: vite & upgraded dependencies (#100)

* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings

* replace webpack w/ vite

* update all imports with alias @ and scss

* vite environment variables, also typed

* upgraded eslint, defined new rules & added ignore comments

* resolved linting issues

* moved index.html to project root

* updated dockerfile w/ build stage before runtime image definition

* sign drone config

* dynamic colors from poster for popup bg & text colors

* more torrents nav button now link elem & better for darker bg

* make list item title clickable

* removed extra no-shadow eslint rule definitions

* fixed movie import

* adhere to eslint rules & package.json clean command

* remove debounce autocomplete search, track & hault on failure
This commit is contained in:
2026-02-24 00:22:51 +01:00
committed by GitHub
parent 1238cf50cc
commit 426b376d05
19 changed files with 443 additions and 278 deletions

View File

@@ -10,9 +10,7 @@ import prettierPlugin from "eslint-plugin-prettier";
const CUSTOM_RULES = { const CUSTOM_RULES = {
"vue/no-v-model-argument": "off", "vue/no-v-model-argument": "off",
"no-underscore-dangle": "off", "no-underscore-dangle": "off",
"vue/multi-word-component-names": "off", "vue/multi-word-component-names": "off"
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"]
}; };
const gitignorePath = path.resolve(".", ".gitignore"); const gitignorePath = path.resolve(".", ".gitignore");
@@ -35,6 +33,7 @@ const nodeConfig = defineConfig([plugins.node, ...configs.node.recommended]);
const typescriptConfig = defineConfig([ const typescriptConfig = defineConfig([
plugins.typescriptEslint, plugins.typescriptEslint,
...configs.base.typescript ...configs.base.typescript
// rules.typescript.typescriptEslintStrict
]); ]);
// Prettier config // Prettier config

View File

@@ -7,9 +7,8 @@
"scripts": { "scripts": {
"dev": "NODE_ENV=development vite", "dev": "NODE_ENV=development vite",
"build": "yarn vite build", "build": "yarn vite build",
"clean": "rm -r dist 2> /dev/null; rm public/index.html 2> /dev/null; rm -r lib 2> /dev/null", "lint": "eslint src; prettier -c src",
"start": "echo 'Start using docker, consult README'", "clean": "rm -rf dist/ yarn-*.log 2>/dev/null",
"lint": "eslint src --ext .ts,.vue",
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md" "docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
}, },
"dependencies": { "dependencies": {

View File

@@ -11,12 +11,7 @@ const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// - - - TMDB - - - // - - - TMDB - - -
/** const getMovie = async (
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @returns {object} Tmdb response
*/
const getMovie = (
id, id,
{ {
checkExistance, checkExistance,
@@ -50,7 +45,7 @@ const getMovie = (
* @param {boolean} [credits=false] Include credits * @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getShow = ( const getShow = async (
id, id,
{ {
checkExistance, checkExistance,
@@ -84,7 +79,7 @@ const getShow = (
* @param {boolean} [credits=false] Include credits * @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getPerson = (id, credits = false) => { const getPerson = async (id, credits = false) => {
const url = new URL("/api/v2/person", API_HOSTNAME); const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`; url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) { if (credits) {
@@ -104,7 +99,7 @@ const getPerson = (id, credits = false) => {
* @param {number} id * @param {number} id
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getMovieCredits = (id: number): Promise<IMediaCredits> => { const getMovieCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/movie", API_HOSTNAME); const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`; url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -121,7 +116,7 @@ const getMovieCredits = (id: number): Promise<IMediaCredits> => {
* @param {number} id * @param {number} id
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getShowCredits = (id: number): Promise<IMediaCredits> => { const getShowCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/show", API_HOSTNAME); const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`; url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -138,7 +133,7 @@ const getShowCredits = (id: number): Promise<IMediaCredits> => {
* @param {number} id * @param {number} id
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const getPersonCredits = (id: number): Promise<IPersonCredits> => { const getPersonCredits = async (id: number): Promise<IPersonCredits> => {
const url = new URL("/api/v2/person", API_HOSTNAME); const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`; url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -156,7 +151,10 @@ 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 = (name: string, page = 1): Promise<IList> => { const getTmdbMovieListByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME); const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
@@ -169,7 +167,7 @@ const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => {
* @param {number} [page=1] * @param {number} [page=1]
* @returns {object} Request response * @returns {object} Request response
*/ */
const getRequests = (page = 1) => { const getRequests = async (page = 1) => {
const url = new URL("/api/v2/request", API_HOSTNAME); const url = new URL("/api/v2/request", API_HOSTNAME);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
@@ -177,7 +175,7 @@ const getRequests = (page = 1) => {
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
}; };
const getUserRequests = (page = 1) => { const getUserRequests = async (page = 1) => {
const url = new URL("/api/v1/user/requests", API_HOSTNAME); const url = new URL("/api/v1/user/requests", API_HOSTNAME);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
@@ -190,7 +188,7 @@ const getUserRequests = (page = 1) => {
* @param {number} [page=1] * @param {number} [page=1]
* @returns {object} Tmdb response * @returns {object} Tmdb response
*/ */
const searchTmdb = (query, page = 1, adult = false, mediaType = null) => { const searchTmdb = async (query, page = 1, adult = false, mediaType = null) => {
const url = new URL("/api/v2/search", API_HOSTNAME); const url = new URL("/api/v2/search", API_HOSTNAME);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) { if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`; url.pathname += `/${mediaType}`;
@@ -235,7 +233,11 @@ const searchTorrents = query => {
* @param {boolean} tmdbId * @param {boolean} tmdbId
* @returns {object} Success/Failure response * @returns {object} Success/Failure response
*/ */
const addMagnet = (magnet: string, name: string, tmdbId: number | null) => { const addMagnet = async (
magnet: string,
name: string,
tmdbId: number | null
) => {
const url = new URL("/api/v1/pirate/add", API_HOSTNAME); const url = new URL("/api/v1/pirate/add", API_HOSTNAME);
const options = { const options = {
@@ -265,7 +267,7 @@ const addMagnet = (magnet: string, name: string, tmdbId: number | null) => {
* @param {string} type Movie or show type * @param {string} type Movie or show type
* @returns {object} Success/Failure response * @returns {object} Success/Failure response
*/ */
const request = (id, type): Promise<IRequestSubmitResponse> => { const request = async (id, type): Promise<IRequestSubmitResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME); const url = new URL("/api/v2/request", API_HOSTNAME);
const options = { const options = {
@@ -288,7 +290,10 @@ const request = (id, type): Promise<IRequestSubmitResponse> => {
* @param {string} type * @param {string} type
* @returns {object} Success/Failure response * @returns {object} Success/Failure response
*/ */
const getRequestStatus = (id, type = null): Promise<IRequestStatusResponse> => { const getRequestStatus = async (
id,
type = null
): Promise<IRequestStatusResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME); const url = new URL("/api/v2/request", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`; url.pathname = `${url.pathname}/${id.toString()}`;
url.searchParams.append("type", type); url.searchParams.append("type", type);
@@ -298,7 +303,7 @@ const getRequestStatus = (id, type = null): Promise<IRequestStatusResponse> => {
.catch(err => Promise.reject(err)); .catch(err => Promise.reject(err));
}; };
const watchLink = (title, year) => { const watchLink = async (title, year) => {
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME); const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title); url.searchParams.append("title", title);
url.searchParams.append("year", year); url.searchParams.append("year", year);
@@ -316,7 +321,7 @@ const movieImages = id => {
// - - - Seasoned user endpoints - - - // - - - Seasoned user endpoints - - -
const register = (username, password) => { const register = async (username, password) => {
const url = new URL("/api/v1/user", API_HOSTNAME); const url = new URL("/api/v1/user", API_HOSTNAME);
const options = { const options = {
method: "POST", method: "POST",
@@ -351,7 +356,7 @@ const login = async (
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) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp); return Promise.reject(resp);
}); });
@@ -364,13 +369,13 @@ const logout = async (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) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp); return Promise.reject(resp);
}); });
}; };
const getSettings = () => { const getSettings = async () => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME); const url = new URL("/api/v1/user/settings", API_HOSTNAME);
return fetch(url.href) return fetch(url.href)
@@ -381,7 +386,7 @@ const getSettings = () => {
}); });
}; };
const updateSettings = settings => { const updateSettings = async (settings: any) => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME); const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options = { const options = {
@@ -400,7 +405,7 @@ const updateSettings = settings => {
// - - - Authenticate with plex - - - // - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => { const linkPlexAccount = async (username: string, password: string) => {
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME); const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
const body = { username, password }; const body = { username, password };
@@ -418,7 +423,7 @@ const linkPlexAccount = (username, password) => {
}); });
}; };
const unlinkPlexAccount = () => { const unlinkPlexAccount = async () => {
const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME); const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME);
const options = { const options = {
@@ -471,15 +476,30 @@ const getEmoji = async () => {
// - - - ELASTIC SEARCH - - - // - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search // This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete // used for autocomplete
interface TimeoutRequestInit extends RequestInit {
timeout: number;
}
async function fetchWithTimeout(url: string, options: TimeoutRequestInit) {
const { timeout = 2000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timer);
return response;
}
/** /**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and * Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports * Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/ */
const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
const elasticSearchMoviesAndShows = (query, count = 22) => {
const url = new URL(`${ELASTIC_URL}/_search`); const url = new URL(`${ELASTIC_URL}/_search`);
const body = { const body = {
@@ -531,10 +551,11 @@ const elasticSearchMoviesAndShows = (query, count = 22) => {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `ApiKey ${ELASTIC_API_KEY}` Authorization: `ApiKey ${ELASTIC_API_KEY}`
}, },
body: JSON.stringify(body) body: JSON.stringify(body),
timeout: 1000
}; };
return fetch(url.href, options) return fetchWithTimeout(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => { .catch(error => {
console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console

View File

@@ -136,7 +136,7 @@
left: 10px; left: 10px;
width: 20px; width: 20px;
height: 2px; height: 2px;
background: $white; background-color: white;
} }
&:before { &:before {
transform: rotate(45deg); transform: rotate(45deg);
@@ -145,7 +145,7 @@
transform: rotate(-45deg); transform: rotate(-45deg);
} }
&:hover { &:hover {
background: $green; background-color: var(--highlight-color);
} }
} }
} }

View File

@@ -22,7 +22,11 @@
</div> </div>
</figure> </figure>
<div class="movie-item__info"> <div
class="movie-item__info"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<p v-if="listItem.title || listItem.name" class="movie-item__title"> <p v-if="listItem.title || listItem.name" class="movie-item__title">
{{ listItem.title || listItem.name }} {{ listItem.title || listItem.name }}
</p> </p>

View File

@@ -10,7 +10,6 @@
> >
<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" />
<IconPerson v-if="result.type == 'person'" class="type-icon" />
<span class="title">{{ result.title }}</span> <span class="title">{{ result.title }}</span>
</li> </li>
@@ -24,10 +23,6 @@
</transition> </transition>
</template> </template>
<!--
Searches Elasticsearch for results based on changes to `query`.
-->
<script setup lang="ts"> <script setup lang="ts">
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ref, watch, defineProps } from "vue"; import { ref, watch, defineProps } from "vue";
@@ -38,10 +33,7 @@ Searches Elasticsearch for results based on changes to `query`.
import { MediaTypes } from "../../interfaces/IList"; import { MediaTypes } from "../../interfaces/IList";
import type { import type {
IAutocompleteResult, IAutocompleteResult,
IAutocompleteSearchResults, IAutocompleteSearchResults
Hit,
Option,
Source
} from "../../interfaces/IAutocompleteSearch"; } from "../../interfaces/IAutocompleteSearch";
interface Props { interface Props {
@@ -55,7 +47,6 @@ Searches Elasticsearch for results based on changes to `query`.
} }
const numberOfResults = 10; const numberOfResults = 10;
let timeoutId = null;
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
const store = useStore(); const store = useStore();
@@ -63,9 +54,25 @@ Searches Elasticsearch for results based on changes to `query`.
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]); const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0); const keyboardNavigationIndex: Ref<number> = ref(0);
let disableOnFailure = false;
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0 && !disableOnFailure)
fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
function removeDuplicates(_searchResults: Array<IAutocompleteResult>) { function removeDuplicates(_searchResults: Array<IAutocompleteResult>) {
const filteredResults = []; const filteredResults = [];
_searchResults.forEach((result: IAutocompleteResult) => { _searchResults.forEach(result => {
if (result === undefined) return; if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter( const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id filterItem => filterItem.id === result.id
@@ -80,83 +87,58 @@ Searches Elasticsearch for results based on changes to `query`.
return filteredResults; return filteredResults;
} }
function convertMediaType(type: string | null): MediaTypes | null { function elasticTypeToMediaType(type: string): MediaTypes {
if (type === "movie") return MediaTypes.Movie; if (type === "movie") return MediaTypes.Movie;
if (type === "tv_series") return MediaTypes.Show; if (type === "tv_series") return MediaTypes.Show;
if (type === "person") return MediaTypes.Person;
return null; return null;
} }
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) { function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const elasticResults = elasticResponse.hits.hits; const data = elasticResponse.hits.hits;
const suggestResults = elasticResponse.suggest["movie-suggest"][0].options;
let data: Array<Source> = elasticResults.map((el: Hit) => el._source);
data = data.concat(suggestResults.map((el: Option) => el._source));
// data = data.concat(elasticResponse['suggest']['person-suggest'][0]['options'])
// data = data.concat(elasticResponse['suggest']['show-suggest'][0]['options'])
data = data.sort((a, b) => (a.popularity < b.popularity ? 1 : -1));
const results: Array<IAutocompleteResult> = []; const results: Array<IAutocompleteResult> = [];
data.forEach(item => { data.forEach(item => {
if (!item._index) return;
results.push({ results.push({
title: item?.original_name || item?.original_title || item?.name, title: item._source?.original_name || item._source.original_title,
id: item.id, id: item._source.id,
adult: item.adult, adult: item._source.adult,
type: convertMediaType(item?.type) type: elasticTypeToMediaType(item._source.type)
}); });
}); });
return removeDuplicates(results) return removeDuplicates(results).map((el, index) => {
.map((el, index) => { return { ...el, index };
return { ...el, index }; });
})
.slice(0, 10);
} }
function fetchAutocompleteResults() { async function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0; keyboardNavigationIndex.value = 0;
searchResults.value = []; searchResults.value = [];
elasticSearchMoviesAndShows(props.query, numberOfResults) return elasticSearchMoviesAndShows(props.query, numberOfResults)
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
})
.then(elasticResponse => parseElasticResponse(elasticResponse)) .then(elasticResponse => parseElasticResponse(elasticResponse))
.then(_searchResults => { .then(_searchResults => {
console.log(_searchResults);
emit("update:results", _searchResults); emit("update:results", _searchResults);
searchResults.value = _searchResults; searchResults.value = _searchResults;
})
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
}); });
} }
const debounce = (callback: () => void, wait: number) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback();
}, wait);
};
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0) {
debounce(fetchAutocompleteResults, 150);
}
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
// on load functions // on load functions
fetchAutocompleteResults(); fetchAutocompleteResults();
// end on load functions
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -62,15 +62,7 @@ the `query`.
import AutocompleteDropdown from "./AutocompleteDropdown.vue"; import AutocompleteDropdown from "./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 type { MediaTypes } from "../../interfaces/IList"; import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
interface ISearchResult {
title: string;
id: number;
adult: boolean;
type: MediaTypes;
}
const store = useStore(); const store = useStore();
const router = useRouter(); const router = useRouter();

View File

@@ -65,7 +65,7 @@
&.active > div > svg, &.active > div > svg,
&.active > svg { &.active > svg {
fill: var(--color-green); fill: var(--highlight-color);
} }
&.disabled { &.disabled {

View File

@@ -92,6 +92,7 @@
border: none; border: none;
background: none; background: none;
width: 100%; width: 100%;
height: 30px;
display: flex; display: flex;
align-items: center; align-items: center;
text-align: center; text-align: center;

View File

@@ -35,7 +35,7 @@
font-weight: 400; font-weight: 400;
text-transform: uppercase; text-transform: uppercase;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-green); color: var(--highlight-color);
@include mobile { @include mobile {
font-size: 1.1rem; font-size: 1.1rem;

View File

@@ -167,6 +167,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import type { Ref } from "vue";
// import img from "@/directives/v-image"; // import img from "@/directives/v-image";
import IconProfile from "../../icons/IconProfile.vue"; import IconProfile from "../../icons/IconProfile.vue";
@@ -183,6 +184,7 @@
import ActionButton from "./ActionButton.vue"; import ActionButton from "./ActionButton.vue";
import Description from "./Description.vue"; import Description from "./Description.vue";
import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue"; import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue";
import type { IColors } from "../../interfaces/IColors.ts";
import type { import type {
IMovie, IMovie,
IShow, IShow,
@@ -213,6 +215,7 @@
const props = defineProps<Props>(); const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/"; const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_URL = "https://colors.schleppe.cloud/colors";
const ASSET_SIZES = ["w500", "w780", "original"]; const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref(); const media: Ref<IMovie | IShow> = ref();
@@ -233,6 +236,8 @@
if (!media.value) return "/assets/placeholder.png"; if (!media.value) return "/assets/placeholder.png";
if (!media.value?.poster) return "/assets/no-image.svg"; if (!media.value?.poster) return "/assets/no-image.svg";
// compute & update highlight colors from poster image
colorsFromPoster(media.value.poster); // eslint-disable-line
return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`; return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`;
}); });
@@ -331,6 +336,34 @@
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;
} }
function colorMain(colors: IColors) {
const parent = document.getElementsByClassName(
"movie-popup"
)[0] as HTMLElement;
parent.style.setProperty("--highlight-color", colors.s ?? colors.p);
parent.style.setProperty("--highlight-bg", colors.bg);
parent.style.setProperty("--highlight-secondary", colors.p);
parent.style.setProperty("--text-color", "#ffffff");
parent.style.setProperty("--text-color-90", "rgba(255, 255, 255, 0.9)");
parent.style.setProperty("--text-color-70", "rgba(255, 255, 255, 0.7)");
parent.style.setProperty("--text-color-50", "rgba(255, 255, 255, 0.5)");
parent.style.setProperty("--text-color-10", "rgba(255, 255, 255, 0.1)");
parent.style.setProperty("--text-color-5", "rgba(255, 255, 255, 0.05)");
}
async function colorsFromPoster(posterPath: string) {
const url = new URL(COLORS_URL);
url.searchParams.append("id", posterPath.replace("/", ""));
url.searchParams.append("size", "w342");
fetch(url.href)
.then(resp => {
if (resp.ok) return resp.json();
throw new Error(`invalid status: '${resp.status}' from server.`);
})
.then(colorMain)
.catch(error => console.log("unable to get colors, error:", error)); // eslint-disable-line no-console
}
// On created functions // On created functions
fetchMedia(); fetchMedia();
@@ -391,6 +424,7 @@
.movie__poster { .movie__poster {
display: none; display: none;
border-radius: 1.6rem;
@include desktop { @include desktop {
background: var(--background-color); background: var(--background-color);
@@ -401,7 +435,7 @@
> img { > img {
width: 100%; width: 100%;
border-radius: 10px; border-radius: inherit;
} }
} }
} }
@@ -420,8 +454,8 @@
flex-direction: row; flex-direction: row;
} }
background-color: $background-color; background-color: var(--highlight-bg, var(--background-color));
color: $text-color; color: var(--text-color);
} }
} }
@@ -430,7 +464,9 @@
width: 100%; width: 100%;
opacity: 0; opacity: 0;
transform: scale(0.97) translateZ(0); transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease; transition:
opacity 0.5s ease,
transform 0.5s ease;
&.is-loaded { &.is-loaded {
opacity: 1; opacity: 1;
@@ -449,21 +485,26 @@
text-align: left; text-align: left;
padding: 140px 30px 0 40px; padding: 140px 30px 0 40px;
} }
h1 { h1 {
color: var(--color-green); color: var(--highlight-color);
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.2;
font-size: 24px; font-size: 2.2rem;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 0; margin-bottom: 0;
@include tablet-min { @include tablet-min {
font-size: 30px; font-size: 30px;
font-size: 2.2rem;
} }
} }
i { i {
display: block; display: block;
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
color: var(--highlight-secondary);
margin-top: 1rem; margin-top: 1rem;
} }
} }
@@ -473,7 +514,7 @@
width: 100%; width: 100%;
order: 2; order: 2;
padding: 20px; padding: 20px;
border-top: 1px solid $text-color-5; border-top: 1px solid var(--text-color-50);
@include tablet-min { @include tablet-min {
order: 1; order: 1;
width: 45%; width: 45%;
@@ -532,7 +573,7 @@
} }
.torrents { .torrents {
background-color: var(--background-color); background-color: var(--highlight-bg, var(--background-color));
padding: 0 1rem; padding: 0 1rem;
@include mobile { @include mobile {

View File

@@ -185,6 +185,7 @@
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
background-color: var(--table-background-color); background-color: var(--table-background-color);
background-color: var(--highlight-color);
// background-color: black; // background-color: black;
// color: var(--color-green); // color: var(--color-green);
letter-spacing: 0.8px; letter-spacing: 0.8px;
@@ -232,6 +233,9 @@
} }
// alternate background color per row // alternate background color per row
tr {
background-color: var(--background-color);
}
tr:nth-child(even) { tr:nth-child(even) {
background-color: var(--background-70); background-color: var(--background-70);
} }

View File

@@ -1,36 +1,40 @@
<template> <template>
<div> <div>
<torrent-search-results <div class="search-results">
:query="query" <torrent-search-results
:tmdb-id="tmdbId" :query="query"
:class="{ truncated: truncated }" :tmdb-id="tmdbId"
><div :class="{ truncated: _truncated }"
v-if="truncated" ><div
class="load-more" v-if="_truncated"
tabindex="0" class="load-more"
role="button" tabindex="0"
@click="truncated = false" role="button"
@keydown.enter="truncated = false" @click="truncated = false"
> @keydown.enter="truncated = false"
<icon-arrow-down /> >
</div> <icon-arrow-down />
</torrent-search-results> </div>
</torrent-search-results>
</div>
<div class="edit-query-btn-container"> <div class="edit-query-btn-container">
<seasonedButton @click="openInTorrentPage" <a :href="`/torrents?query=${encodeURIComponent(props.query)}`">
>View on torrent page</seasonedButton <button>
> <span class="text">View on torrent page</span
><span class="icon"><icon-arrow-down /></span>
</button>
</a>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router";
import { ref, defineProps, computed } from "vue"; import { ref, defineProps, computed } from "vue";
import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue"; import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconArrowDown from "@/icons/IconArrowDown.vue"; import IconArrowDown from "@/icons/IconArrowDown.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import store from "../../store";
interface Props { interface Props {
query: string; query: string;
@@ -38,18 +42,13 @@
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const router = useRouter();
const truncated: Ref<boolean> = ref(true); const truncated: Ref<boolean> = ref(true);
function openInTorrentPage() { const _truncated = computed(() => {
if (!props.query?.length) { const val = store.getters["torrentModule/resultCount"];
router.push("/torrents"); if (val > 10 && truncated.value) return true;
return; return false;
} });
router.push({ path: "/torrents", query: { query: props.query } });
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -75,14 +74,68 @@
); );
} }
svg { .search-results {
height: 30px; svg {
fill: var(--text-color); height: 30px;
fill: var(--text-color);
}
} }
.edit-query-btn-container { .edit-query-btn-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
padding-bottom: 2rem;
a button {
--height: 45px;
transition: all 0.8s ease !important;
position: relative;
font-size: 1rem;
line-height: 1.5;
letter-spacing: 0.2px;
font-family: Arial, Helvetica, sans-serif;
font-weight: 600;
color: var(--highlight-bg, var(--background-color));
background-color: var(--text-color);
min-height: var(--height);
padding: 0rem 1.5rem;
margin: 0;
border: 2px solid var(--text-color);
border-radius: calc(var(--height) / 2);
cursor: pointer;
outline: none;
overflow-x: hidden;
&:hover {
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
padding: 0 2rem;
span.text {
margin-left: -0.5rem;
margin-right: 0.5rem;
}
span.icon {
right: 1rem;
}
}
span.icon {
--size: 1rem;
display: block;
transform: rotate(-90deg);
transform-origin: top left;
stroke: var(--text-color);
fill: var(--text-color);
height: var(--size);
width: var(--size);
margin-top: -4px;
position: absolute;
right: 1rem;
right: -1rem;
}
}
} }
</style> </style>

View File

@@ -1,5 +1,11 @@
<template> <template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg
version="1.1"
height="100%"
width="100%"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
>
<path <path
d="M28.725 8.058l-12.725 12.721-12.725-12.721-1.887 1.887 13.667 13.667c0.258 0.258 0.6 0.392 0.942 0.392s0.683-0.129 0.942-0.392l13.667-13.667-1.879-1.887z" d="M28.725 8.058l-12.725 12.721-12.725-12.721-1.887 1.887 13.667 13.667c0.258 0.258 0.6 0.392 0.942 0.392s0.683-0.129 0.942-0.392l13.667-13.667-1.879-1.887z"
/> />

View File

@@ -29,7 +29,7 @@ export interface Hits {
} }
export interface Hit { export interface Hit {
_index: Index; _index: string;
_type: Type; _type: Type;
_id: string; _id: string;
_score: number; _score: number;
@@ -58,11 +58,6 @@ export interface Option {
_source: Source; _source: Source;
} }
export enum Index {
Movies = "movies",
Shows = "shows"
}
export interface Source { export interface Source {
tags: Tag[]; tags: Tag[];
ecs: Ecs; ecs: Ecs;
@@ -79,7 +74,7 @@ export interface Source {
original_title: string; original_title: string;
original_name?: string; original_name?: string;
name?: string; name?: string;
type?: MediaTypes; type: string;
} }
export interface Agent { export interface Agent {

View File

@@ -0,0 +1,5 @@
export interface IColors {
bg: string;
p: string;
s?: string;
}

View File

@@ -1,117 +0,0 @@
import { refreshToken } from "@/api";
import { parseJwt } from "@/utils";
function getCookie(name) {
var arrayb = document.cookie.split(";");
for (const item of arrayb) {
const query = `${name}=`;
if (!item.startsWith(query)) continue;
return item.substr(query.length);
}
return null;
}
function setCookie(name, value, options = {}) {
options = {
path: "/",
// add other defaults here if necessary
...options
};
if (options.expires instanceof Date) {
options.expires = options.expires.toUTCString();
}
let updatedCookie =
encodeURIComponent(name) + "=" + encodeURIComponent(value);
for (let optionKey in options) {
updatedCookie += "; " + optionKey;
let optionValue = options[optionKey];
if (optionValue !== true) {
updatedCookie += "=" + optionValue;
}
}
document.cookie = updatedCookie;
}
function deleteCookie(name) {
setCookie(name, "", {
"max-age": Date.now()
});
}
export default {
namespaced: true,
state: {
token: null,
admin: false,
settings: null,
username: null
},
getters: {
username: state => state.username,
settings: state => state.settings,
token: state => state.token,
// loggedIn: state => true,
loggedIn: state => state && state.username !== null,
admin: state => state.admin,
plexUserId: state => {
if (state && state.settings && state.settings.plexUserId)
return state.settings.plexUserId;
return null;
}
},
mutations: {
SET_TOKEN: (state, token) => (state.token = token),
SET_USERNAME: (state, username) => (state.username = username),
SET_SETTINGS: (state, settings) => (state.settings = settings),
SET_ADMIN: (state, admin) => (state.admin = admin),
LOGOUT: state => {
state.token = null;
state.username = null;
state.settings = null;
state.admin = false;
// deleteCookie('authorization');
}
},
actions: {
initUserFromCookie: async ({ dispatch }) => {
const jwtToken = getCookie("authorization");
if (!jwtToken) return null;
const token = parseJwt(jwtToken);
return await dispatch("setupStateFromToken", token);
},
setupStateFromToken: ({ commit }, token) => {
try {
const { username, admin, settings } = token;
if (!username) {
return false;
}
commit("SET_TOKEN", token);
commit("SET_USERNAME", username);
commit("SET_SETTINGS", settings);
commit("SET_ADMIN", admin != undefined);
return true;
} catch (error) {
console.error("Unable to parse JWT, failed with error:", error);
return false;
}
},
setSettings: ({ commit }, settings) => {
if (!(settings instanceof Object)) {
throw "Parameter is not a object.";
}
commit("SET_SETTINGS", settings);
},
logout: ({ commit }) => commit("LOGOUT"),
login: async ({ dispatch }) => await dispatch("initUserFromCookie")
}
};

178
src/modules/user.ts Normal file
View File

@@ -0,0 +1,178 @@
/* eslint-disable no-param-reassign */
import { Module } from "vuex";
import { parseJwt } from "../utils";
/* --------------------------------------------------------------------------- */
/* ── Utility helpers (cookie handling) ────────────────────────────────── */
/* --------------------------------------------------------------------------- */
export interface CookieOptions {
path?: string;
expires?: number | string | boolean;
[option: string]: string | number | boolean | undefined;
}
/**
* Read a cookie value.
*/
export function getCookie(name: string): string | null {
const array = document.cookie.split(";");
let match = null;
array.forEach((item: string) => {
const query = `${name}=`;
if (!item.trim().startsWith(query)) return;
match = item.trim().substring(query.length);
});
return match;
}
/**
* Write a cookie.
*/
export function setCookie(
name: string,
value: string,
options: CookieOptions = {}
): void {
const opts: CookieOptions = {
path: "/",
...options
};
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
/* eslint-disable-next-line no-restricted-syntax */
for (const [key, val] of Object.entries(opts)) {
cookie += `; ${key}`;
if (val !== true) cookie += `=${val}`;
}
document.cookie = cookie;
}
/**
* Delete a cookie.
*/
export function deleteCookie(name: string): void {
setCookie(name, "", { "max-age": 0 });
}
/* --------------------------------------------------------------------------- */
/* ── State / Types ─────────────────────────────────────────────────── */
/* --------------------------------------------------------------------------- */
export interface Settings {
/** Example property replace with your real shape. */
plexUserId?: string | null;
// add the rest of your settings fields here
}
export interface UserState {
token: string | null;
admin: boolean;
settings: Settings | null;
username: string | null;
}
export interface RootState {
val?: string;
// your root state interface leave empty if you don't use it
}
/* --------------------------------------------------------------------------- */
/* ── Vuex module ──────────────────────────────────────────────────────── */
/* --------------------------------------------------------------------------- */
const userModule: Module<UserState, RootState> = {
namespaced: true,
/* ── State ───────────────────────────────────────────────────── */
state: {
token: null,
admin: false,
settings: null,
username: null
},
/* ── Getters ─────────────────────────────────────────────────── */
getters: {
username: (state): string | null => state.username,
settings: (state): Settings | null => state.settings,
token: (state): string | null => state.token,
loggedIn: (state): boolean => !!state && state.username !== null,
admin: (state): boolean => state.admin,
plexUserId: (state): string | null => state?.settings?.plexUserId ?? null
},
/* ── Mutations ─────────────────────────────────────────────────── */
mutations: {
SET_TOKEN(state, token: string | null): void {
state.token = token;
},
SET_USERNAME(state, username: string | null): void {
state.username = username;
},
SET_SETTINGS(state, settings: Settings | null): void {
state.settings = settings;
},
SET_ADMIN(state, admin: boolean): void {
state.admin = admin;
},
LOGOUT(state): void {
state.token = null;
state.username = null;
state.settings = null;
state.admin = false;
// deleteCookie('authorization');
}
},
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
const jwtToken = getCookie("authorization");
if (!jwtToken) return null;
const token = parseJwt(jwtToken);
return dispatch("setupStateFromToken", token);
},
setupStateFromToken({ commit }, token: any): boolean {
try {
const { username, admin, settings } = token;
if (!username) return false;
commit("SET_TOKEN", token);
commit("SET_USERNAME", username);
commit("SET_SETTINGS", settings);
commit("SET_ADMIN", admin !== undefined);
return true;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Unable to parse JWT, failed with error:", e);
return false;
}
},
setSettings({ commit }, settings: Settings): void {
if (!(settings instanceof Object)) {
throw new Error("Parameter is not an object.");
}
commit("SET_SETTINGS", settings);
},
logout({ commit }): void {
commit("LOGOUT");
},
login({ dispatch }): Promise<boolean | null> {
return dispatch("initUserFromCookie");
}
}
};
const user = userModule;
export default user;

View File

@@ -21,6 +21,8 @@
--background-40: rgba(255, 255, 255, 0.4); --background-40: rgba(255, 255, 255, 0.4);
--background-0: rgba(255, 255, 255, 0); --background-0: rgba(255, 255, 255, 0);
--highlight-color: #01d277;
--background-nav-logo: #081c24; --background-nav-logo: #081c24;
--color-green: #01d277; --color-green: #01d277;
--color-green-90: rgba(1, 210, 119, 0.9); --color-green-90: rgba(1, 210, 119, 0.9);