<template> <div class="scenes-page" :class="{ [view]: true }" > <transition name="sidebar"> <Filters v-if="showFilters" :loading="loading" > <div class="filter search-container"> <!-- onsearch not compatible with FF and Safari --> <input v-model="filters.search" type="search" placeholder="Search scenes" class="search input" @keydown.enter="search" > <Icon icon="search" class="search-button" @click="search" /> </div> <YearsFilter :filters="filters" :years="aggYears" @update="updateFilter" /> <TagsFilter :filters="filters" :tags="aggTags" @update="updateFilter" /> <ChannelsFilter :filters="filters" :channels="aggChannels" @update="updateFilter" /> <ActorsFilter :filters="filters" :actors="aggActors" @update="updateFilter" /> </Filters> </transition> <div ref="scenesContainer" class="scenes-container" :class="{ loading }" > <div v-if="showMeta" class="scenes-header" > <div class="meta">{{ total }} results</div> <Campaign v-if="campaigns?.meta" :campaign="campaigns.meta" /> <div class="views"> <div class="view-toggles noselect"> <Icon v-show="view === 'grid'" icon="menu3" class="view-toggle" @click="setView('list')" /> <Icon v-show="view === 'list'" icon="grid6" class="view-toggle" @click="setView('grid')" /> </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> </div> <nav v-if="showScopeTabs" class="scopes" > <div class="views"> <div class="scopes-pills"> <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> </div> <div class="view-toggles noselect"> <Icon v-show="view === 'grid'" icon="menu3" class="view-toggle" @click="setView('list')" /> <Icon v-show="view === 'list'" icon="grid6" class="view-toggle" @click="setView('grid')" /> </div> </div> <Campaign v-if="campaigns?.scope" :campaign="campaigns.scope" /> </nav> <div class="scenes-items"> <ul class="scenes nolist"> <template v-for="item in campaignScenes"> <li v-if="item === 'campaign' && sceneCampaign" :key="`campaign-${item.id}`" > <Campaign :campaign="sceneCampaign" /> </li> <li v-else :key="`scene-${item.id}`" > <SceneTile :scene="item" :class="{ [view]: true }" /> </li> </template> </ul> </div> <Pagination :total="total" :page="currentPage" /> </div> <Ellipsis class="ellipsis" :class="{ loading }" /> </div> </template> <script setup> import { ref, computed, inject, } from 'vue'; import Cookies from 'js-cookie'; 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 entityPrefixes from '#/src/entities-prefixes.js'; import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js'; import Filters from '#/components/filters/filters.vue'; import YearsFilter from '#/components/filters/years.vue'; import ActorsFilter from '#/components/filters/actors.vue'; import TagsFilter from '#/components/filters/tags.vue'; import ChannelsFilter from '#/components/filters/channels.vue'; import SceneTile from '#/components/scenes/tile.vue'; import Campaign from '#/components/campaigns/campaign.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, campaigns, env, } = inject('pageContext'); const { actor: pageActor, tag: pageTag, entity: pageEntity, stash: pageStash, } = pageProps; const scenes = ref(pageProps.scenes); const aggYears = ref(pageProps.aggYears || []); 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.sceneTotal || pageProps.total)); const loading = ref(false); const scenesContainer = ref(null); const view = ref(env.scenesView || 'grid'); 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, years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [], tags: urlParsed.search.tags?.split(',').filter(Boolean) || [], entity: queryEntity, actors: queryActors, }); const sceneCampaign = campaigns?.scenes; const campaignIndex = campaigns?.index; const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene))); 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; const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`; loading.value = true; navigate(getPath(scope.value, false), { ...query, years: filters.value.years.join(',') || undefined, 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), e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined, }, { redirect: false }); const res = await get('/scenes', { ...query, years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included 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; aggYears.value = res.aggYears; aggActors.value = res.aggActors; aggTags.value = res.aggTags; aggChannels.value = res.aggChannels; total.value = res.total; loading.value = false; // events.emit('scrollUp'); scenesContainer.value?.scrollIntoView(true); } function updateFilter(prop, value, reload = true) { filters.value[prop] = value; if (reload) { search(); } } function setView(newView) { view.value = newView; Cookies.set('scenesView', newView); } </script> <style scoped> .scenes-page { display: flex; background: var(--background-base-10); position: relative; flex-grow: 1; } .scenes-header { display: flex; align-items: flex-end; justify-content: space-between; padding: .5rem 1rem .25rem 3rem; .campaign { max-height: 6rem; justify-content: center; margin: .5rem 1rem 0 1rem; /* makes empty area link to ad, bit too annoying width: 0; flex-grow: 1; */ } } .scenes-container { display: flex; flex-direction: column; flex-grow: 1; scroll-margin-top: 3rem; /* ensure scroll into view includes meta bar */ } .meta { display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; margin-bottom: .5rem; } .scenes-items { /* grow additional container to prevent gaps between grid tiles */ flex-grow: 1; } .scenes { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); gap: .5rem; padding: .5rem 1rem 1rem 1rem; :deep(.campaign) .campaign-banner { border-radius: .25rem; box-shadow: 0 0 3px var(--shadow-weak-20); } } .scopes { display: flex; align-items: flex-end; justify-content: space-between; padding: .75rem 1rem .25rem 1rem; .campaign { max-height: 6rem; justify-content: flex-end; margin-left: 1rem; } } .views { display: flex; align-items: center; justify-content: flex-end; } .view-toggles { display: none; margin-left: .5rem; } .view-toggle { padding: .5rem 1rem; fill: var(--glass); &:hover { cursor: pointer; fill: var(--primary); } } .scopes-pills { display: flex; align-items: center; gap: .5rem; } .scope { box-sizing: border-box; padding: .5rem 1rem; background: var(--background-dark-20); border-radius: 1rem; color: var(--glass); 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; flex-direction: column-reverse; padding: 0 0 .25rem 0; .campaign { width: 100%; justify-content: center; margin-left: 0; margin-bottom: 1rem; } } .scopes-pills { width: 100%; justify-content: center; padding: 0 1rem 0 1rem; } .scopes .views { width: 100%; } } @media(--small-20) { .list { .scenes { grid-template-columns: 1fr; } } .scenes { padding: .5rem .5rem 1rem .5rem; } .view-toggles { display: flex; } .scopes-pills { justify-content: flex-start; } } </style>