<template> <div class="page"> <Filters v-if="showFilters" :class="{ loading }" > <div class="filter"> <input v-model="filters.search" type="search" placeholder="Search movies" class="search input" @search="search" > </div> <TagsFilter :filters="filters" :tags="aggTags" @update="updateFilter" /> <ChannelsFilter :filters="filters" :channels="aggChannels" @update="updateFilter" /> <ActorsFilter :filters="filters" :actors="aggActors" @update="updateFilter" /> </Filters> <div class="movies-container"> <div class="movies-header"> <div class="meta">{{ total }} results</div> <select v-model="scope" class="input" @change="search({ autoScope: false })" > <option value="likes">Likes</option> <option value="latest">Latest</option> <option value="upcoming">Upcoming</option> <option value="new">New</option> <option value="results" :disabled="!filters.search" >Relevance</option> </select> </div> <ul class="movies nolist"> <li v-for="movie in movies" :key="`movie-${movie.id}`" class="movie" > <a :href="`/movie/${movie.id}/${movie.slug}`" :title="movie.title" class="cover-container" > <img v-if="movie.covers[0]" :src="movie.covers[0].isS3 ? `https://cdndev.traxxx.me/${movie.covers[0].thumbnail}` : `/media/${movie.covers[0].thumbnail}`" :style="{ 'background-image': movie.covers[0].isS3 ? `url(https://cdndev.traxxx.me/${movie.covers[0].lazy})` : `url(/media/${movie.covers[0].lazy})` }" class="cover" loading="lazy" > <img v-else src="/public/img/icons/movie.svg" class="nocover" > </a> <div class="tile-meta"> <div class="channel"> <Link :href="movie.channel.isIndependent || !movie.network ? `/${movie.channel.type}/${movie.channel.slug}` : `/${movie.network.type}/${movie.network.slug}`" class="favicon-link" > <img :src="movie.channel.isIndependent || !movie.network ? `/logos/${movie.channel.slug}/favicon.png` : `/logos/${movie.network.slug}/favicon.png`" class="favicon" > </Link> <Link :href="`/${movie.channel.type}/${movie.channel.slug}`" class="nolink channel-link" >{{ movie.channel.name }}</Link> </div> <time :datetime="movie.effectiveDate.toISOString()" class="date" :class="{ nodate: !movie.date }" >{{ format(movie.effectiveDate, movie.effectiveDate.getFullYear() === currentYear ? 'MMM d' : 'MMM d, y') }}</time> </div> <a :href="`/movie/${movie.id}/${movie.slug}`" :title="movie.title" class="title nolink" >{{ movie.title }}</a> <ul :title="movie.actors.map((actor) => actor.name).join(', ')" class="actors nolist" > <li v-for="actor in movie.actors" :key="`actor-${movie.id}-${actor.slug}`" class="actor-item" > <a :href="`/actor/${actor.id}/${actor.slug}`" class="actor nolink" >{{ actor.name }}</a> </li> </ul> <ul :title="movie.tags.map((tag) => tag.name).join(', ')" class="tags nolist" > <li v-for="tag in movie.tags" :key="`tag-${movie.id}-${tag.slug}`" > <a :href="`/tag/${tag.slug}`" class="tag nolink" >{{ tag.name }}</a> </li> </ul> </li> </ul> </div> </div> </template> <script setup> import { ref, inject } from 'vue'; import { format } from 'date-fns'; import { parse } from 'path-to-regexp'; import navigate from '#/src/navigate.js'; import { get } from '#/src/api.js'; import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js'; import events from '#/src/events.js'; import Filters from '#/components/filters/filters.vue'; import ActorsFilter from '#/components/filters/actors.vue'; import TagsFilter from '#/components/filters/tags.vue'; import ChannelsFilter from '#/components/filters/channels.vue'; const pageContext = inject('pageContext'); const { pageProps, routeParams, urlParsed } = pageContext; const { actor: pageActor, tag: pageTag, entity: pageEntity, } = pageProps; const movies = ref(pageProps.movies); const aggActors = ref(pageProps.aggActors || []); const aggTags = ref(pageProps.aggTags || []); const aggChannels = ref(pageProps.aggChannels || []); const currentPage = ref(Number(routeParams.page)); const scope = ref(routeParams.scope); const total = ref(Number(pageProps.total)); const loading = ref(false); const showFilters = ref(true); const currentYear = new Date().getFullYear(); const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || []; const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean); const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent])); const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel])); const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e]; const filters = ref({ search: urlParsed.search.q, tags: urlParsed.search.tags?.split(',').filter(Boolean) || [], entity: queryEntity, actors: queryActors, }); function getPath(targetScope, preserveQuery) { const path = parse(routeParams.path).map((segment) => { if (segment.name === 'scope') { return `${segment.prefix}${targetScope}`; } if (segment.name === 'page') { return `${segment.prefix}${1}`; } return `${segment.prefix || ''}${routeParams[segment.name] || segment}`; }).join(''); if (preserveQuery && urlParsed.searchOriginal) { return `${path}${urlParsed.searchOriginal}`; } return path; } async function search(options = {}) { if (options.resetPage !== false) { currentPage.value = 1; } if (options.autoScope !== false) { if (filters.value.search) { scope.value = 'results'; } if (!filters.value.search && scope.value === 'results') { scope.value = 'latest'; } } const query = { q: filters.value.search || undefined, }; const entity = filters.value.entity || pageEntity; const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug; loading.value = true; navigate(getPath(scope.value, false), { ...query, actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter tags: filters.value.tags.join(',') || undefined, e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined), }, { redirect: false }); const res = await get('/movies', { ...query, actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','), e: entitySlug, scope: scope.value, page: currentPage.value, // client uses param rather than query pagination }); movies.value = res.movies; aggActors.value = res.aggActors; aggTags.value = res.aggTags; aggChannels.value = res.aggChannels; total.value = res.total; loading.value = false; events.emit('scrollUp'); } function updateFilter(prop, value, reload = true) { filters.value[prop] = value; if (reload) { search(); } } </script> <style scoped> .page { display: flex; background: var(--background-base-10); } .movies-container { display: flex; flex-direction: column; flex-grow: 1; } .movies-header { display: flex; align-items: center; padding: .5rem 1rem .25rem 3rem; } .meta { display: flex; flex-grow: 1; justify-content: space-between; align-items: center; } .movies { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 1rem; padding: .5rem 1rem 1rem 1rem; } .movie { max-height: 30rem; display: flex; flex-direction: column; box-shadow: 0 0 3px var(--shadow-weak-30); border-radius: .25rem; overflow: hidden; background: var(--background-base); &:hover { box-shadow: 0 0 3px var(--shadow-weak-20); } } .cover-container { display: flex; align-items: center; justify-content: center; flex-grow: 1; background: var(--shadow-weak-30); aspect-ratio: 5/7; } .cover { width: 100%; height: 100%; object-fit: cover; background-size: cover; background-position: center; } .nocover { width: 25%; opacity: .1; } .tile-meta { display: flex; justify-content: space-between; align-items: center; padding: .4rem .5rem; border-radius: 0 0 .25rem .25rem; margin-bottom: .5rem; background: var(--shadow-strong-30); color: var(--text-light); font-size: .8rem; } .channel { display: inline-flex; align-items: center; margin-right: .5rem; white-space: nowrap; overflow: hidden; } .channel-link { overflow: hidden; text-overflow: ellipsis; font-weight: bold; } .favicon-link { display: inline-flex; } .favicon { width: 1rem; height: 1rem; margin-right: .5rem; object-fit: contain; } .date { flex-shrink: 0; } .nodate { color: var(--highlight-strong-10); } .title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; padding: 0 .5rem; margin-bottom: .25rem; } .actors { height: 2.4rem; display: flex; flex-wrap: wrap; overflow: hidden; font-size: .9rem; padding: 0 .5rem; margin-bottom: .35rem; line-height: 1.35; } .actor-item:not(:last-child):after { content: ',\00a0'; } .actor { &:hover { color: var(--primary); } } .tags { height: 1rem; display: flex; flex-wrap: wrap; gap: .5rem; overflow: hidden; padding: 0 .5rem; margin-bottom: .25rem; color: var(--shadow-strong-10); font-size: .75rem; } .tag { flex-shrink: 0; &:hover { color: var(--primary); } } </style>