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

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

View File

@@ -22,7 +22,11 @@
</div>
</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">
{{ listItem.title || listItem.name }}
</p>

View File

@@ -10,7 +10,6 @@
>
<IconMovie v-if="result.type == 'movie'" 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>
</li>
@@ -24,10 +23,6 @@
</transition>
</template>
<!--
Searches Elasticsearch for results based on changes to `query`.
-->
<script setup lang="ts">
import type { Ref } 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 type {
IAutocompleteResult,
IAutocompleteSearchResults,
Hit,
Option,
Source
IAutocompleteSearchResults
} from "../../interfaces/IAutocompleteSearch";
interface Props {
@@ -55,7 +47,6 @@ Searches Elasticsearch for results based on changes to `query`.
}
const numberOfResults = 10;
let timeoutId = null;
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const store = useStore();
@@ -63,9 +54,25 @@ Searches Elasticsearch for results based on changes to `query`.
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
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>) {
const filteredResults = [];
_searchResults.forEach((result: IAutocompleteResult) => {
_searchResults.forEach(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id
@@ -80,83 +87,58 @@ Searches Elasticsearch for results based on changes to `query`.
return filteredResults;
}
function convertMediaType(type: string | null): MediaTypes | null {
function elasticTypeToMediaType(type: string): MediaTypes {
if (type === "movie") return MediaTypes.Movie;
if (type === "tv_series") return MediaTypes.Show;
if (type === "person") return MediaTypes.Person;
return null;
}
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const elasticResults = 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 data = elasticResponse.hits.hits;
const results: Array<IAutocompleteResult> = [];
data.forEach(item => {
if (!item._index) return;
results.push({
title: item?.original_name || item?.original_title || item?.name,
id: item.id,
adult: item.adult,
type: convertMediaType(item?.type)
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: elasticTypeToMediaType(item._source.type)
});
});
return removeDuplicates(results)
.map((el, index) => {
return { ...el, index };
})
.slice(0, 10);
return removeDuplicates(results).map((el, index) => {
return { ...el, index };
});
}
function fetchAutocompleteResults() {
async function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0;
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(_searchResults => {
console.log(_searchResults);
emit("update:results", _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
fetchAutocompleteResults();
// end on load functions
</script>
<style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,6 +167,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
// import img from "@/directives/v-image";
import IconProfile from "../../icons/IconProfile.vue";
@@ -183,6 +184,7 @@
import ActionButton from "./ActionButton.vue";
import Description from "./Description.vue";
import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue";
import type { IColors } from "../../interfaces/IColors.ts";
import type {
IMovie,
IShow,
@@ -213,6 +215,7 @@
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_URL = "https://colors.schleppe.cloud/colors";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
@@ -233,6 +236,8 @@
if (!media.value) return "/assets/placeholder.png";
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}`;
});
@@ -331,6 +336,34 @@
const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`;
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
fetchMedia();
@@ -391,6 +424,7 @@
.movie__poster {
display: none;
border-radius: 1.6rem;
@include desktop {
background: var(--background-color);
@@ -401,7 +435,7 @@
> img {
width: 100%;
border-radius: 10px;
border-radius: inherit;
}
}
}
@@ -420,8 +454,8 @@
flex-direction: row;
}
background-color: $background-color;
color: $text-color;
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
}
}
@@ -430,7 +464,9 @@
width: 100%;
opacity: 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 {
opacity: 1;
@@ -449,21 +485,26 @@
text-align: left;
padding: 140px 30px 0 40px;
}
h1 {
color: var(--color-green);
color: var(--highlight-color);
font-weight: 500;
line-height: 1.4;
font-size: 24px;
line-height: 1.2;
font-size: 2.2rem;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 0;
@include tablet-min {
font-size: 30px;
font-size: 2.2rem;
}
}
i {
display: block;
color: rgba(255, 255, 255, 0.8);
color: var(--highlight-secondary);
margin-top: 1rem;
}
}
@@ -473,7 +514,7 @@
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid $text-color-5;
border-top: 1px solid var(--text-color-50);
@include tablet-min {
order: 1;
width: 45%;
@@ -532,7 +573,7 @@
}
.torrents {
background-color: var(--background-color);
background-color: var(--highlight-bg, var(--background-color));
padding: 0 1rem;
@include mobile {

View File

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

View File

@@ -1,36 +1,40 @@
<template>
<div>
<torrent-search-results
:query="query"
:tmdb-id="tmdbId"
:class="{ truncated: truncated }"
><div
v-if="truncated"
class="load-more"
tabindex="0"
role="button"
@click="truncated = false"
@keydown.enter="truncated = false"
>
<icon-arrow-down />
</div>
</torrent-search-results>
<div class="search-results">
<torrent-search-results
:query="query"
:tmdb-id="tmdbId"
:class="{ truncated: _truncated }"
><div
v-if="_truncated"
class="load-more"
tabindex="0"
role="button"
@click="truncated = false"
@keydown.enter="truncated = false"
>
<icon-arrow-down />
</div>
</torrent-search-results>
</div>
<div class="edit-query-btn-container">
<seasonedButton @click="openInTorrentPage"
>View on torrent page</seasonedButton
>
<a :href="`/torrents?query=${encodeURIComponent(props.query)}`">
<button>
<span class="text">View on torrent page</span
><span class="icon"><icon-arrow-down /></span>
</button>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ref, defineProps, computed } from "vue";
import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconArrowDown from "@/icons/IconArrowDown.vue";
import type { Ref } from "vue";
import store from "../../store";
interface Props {
query: string;
@@ -38,18 +42,13 @@
}
const props = defineProps<Props>();
const router = useRouter();
const truncated: Ref<boolean> = ref(true);
function openInTorrentPage() {
if (!props.query?.length) {
router.push("/torrents");
return;
}
router.push({ path: "/torrents", query: { query: props.query } });
}
const _truncated = computed(() => {
const val = store.getters["torrentModule/resultCount"];
if (val > 10 && truncated.value) return true;
return false;
});
</script>
<style lang="scss" scoped>
@@ -75,14 +74,68 @@
);
}
svg {
height: 30px;
fill: var(--text-color);
.search-results {
svg {
height: 30px;
fill: var(--text-color);
}
}
.edit-query-btn-container {
display: flex;
justify-content: center;
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>