<template> <div class="scenes-page" > <transition name="sidebar"> <Filters v-if="showFilters" :loading="loading" > <div class="filter"> <input v-model="filters.search" type="search" placeholder="Search scenes" 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> </transition> <div class="scenes-container" :class="{ loading }" > <div v-if="showMeta" class="scenes-header" > <div class="meta">{{ total }} results</div> <select v-model="scope" class="input" @change="search({ autoScope: false })" > <!-- not selected in SSR without prop --> <option v-if="pageStash" :selected="scope === 'stashed'" value="stashed" >Added</option> <option v-if="filters.search" :selected="scope === 'results'" value="results" >Relevance</option> <option value="latest">Latest</option> <option value="upcoming">Upcoming</option> <option value="new">New</option> <option value="likes">Popular</option> </select> </div> <nav v-if="showScopeTabs" class="scopes" > <Link :href="getPath('latest')" class="scope nolink" :active="scope === 'latest'" >Latest</Link> <Link :href="getPath('upcoming')" class="scope nolink" :active="scope === 'upcoming'" >Upcoming</Link> <Link :href="getPath('new')" class="scope nolink" :active="scope === 'new'" >New</Link> </nav> <ul class="scenes nolist" > <li v-for="scene in scenes" :key="scene.id" > <Scene :scene="scene" /> </li> </ul> <Pagination :total="total" :page="currentPage" /> </div> <Ellipsis class="ellipsis" :class="{ loading }" /> </div> </template> <script setup> import { ref, inject } from 'vue'; import { parse } from 'path-to-regexp'; import navigate from '#/src/navigate.js'; import { get } from '#/src/api.js'; import events from '#/src/events.js'; import { getActorIdentifier, parseActorIdentifier } from '#/src/query.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'; import Scene from '#/components/scenes/tile.vue'; import Pagination from '#/components/pagination/pagination.vue'; import Ellipsis from '#/components/loading/ellipsis.vue'; const props = defineProps({ showFilters: { type: Boolean, default: true, }, showMeta: { type: Boolean, default: true, }, showScopeTabs: { type: Boolean, default: false, }, defaultScope: { type: String, default: 'latest', }, }); const { pageProps, routeParams, urlParsed } = inject('pageContext'); const { actor: pageActor, tag: pageTag, entity: pageEntity, stash: pageStash, } = pageProps; const scenes = ref(pageProps.scenes); 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 || props.defaultScope); const total = ref(Number(pageProps.total)); const loading = ref(false); 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('/scenes', { ...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(','), stashId: pageStash?.id, e: entitySlug, scope: scope.value, page: currentPage.value, // client uses param rather than query pagination }); scenes.value = res.scenes; 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> .scenes-page { display: flex; background: var(--background-base-10); position: relative; } .scenes-header { display: flex; align-items: center; padding: .5rem 1rem .25rem 3rem; } .scenes-container { display: flex; flex-direction: column; flex-grow: 1; } .meta { display: flex; flex-grow: 1; justify-content: space-between; align-items: center; } .scenes { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); gap: .5rem; padding: .5rem 1rem 1rem 1rem; } .scopes { display: flex; gap: .5rem; padding: .75rem 0 .25rem 1rem; } .scope { box-sizing: border-box; padding: .5rem 1rem; background: var(--background-dark-20); border-radius: 1rem; color: var(--shadow); font-size: .9rem; font-weight: bold; &.active { background: var(--primary); color: var(--text-light); } } .scenes-container.loading:not(.ellipsis) { opacity: .3; pointer-events: none; } .ellipsis { display: none; position: absolute; top: 1rem; left: 50%; &.loading { display: flex; } } @media(--small-10) { .scenes { grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); } .scopes { justify-content: center; } } @media(--small-20) { .scenes { grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); padding: .5rem .5rem 1rem .5rem; gap: .5rem .25rem; } } </style>