Added basic actor page with scenes and co-star filtering.

This commit is contained in:
DebaucheryLibrarian 2024-01-07 06:13:40 +01:00
parent ffcb77ab45
commit e32a366fff
14 changed files with 476 additions and 144 deletions

View File

@ -1,20 +1,53 @@
<template> <template>
<div class="page"> <div class="page">
<Filters v-if="showFilters">
<ul class="filter-actors selected nolist">
<li
v-for="aggActor in selectedActors"
:key="`filter-actor-${aggActor.id}`"
class="filter-actor"
@click="updateFilter('actors', [...filters.actors, aggActor.id])"
>
{{ aggActor.name }}
<button
v-if="filters.actors.includes(aggActor.id)"
class="filter-actor-remove"
@click.stop="updateFilter('actors', filters.actors.filter((actorId) => actorId !== aggActor.id))"
><Icon icon="cross2" /></button>
</li>
</ul>
<ul class="filter-actors available nolist">
<li
v-for="aggActor in actors"
:key="`filter-actor-${aggActor.id}`"
class="filter-actor"
@click="updateFilter('actors', [...filters.actors, aggActor.id])"
>{{ aggActor.name }}</li>
</ul>
</Filters>
<div class="scenes-container">
<div class="scenes-header">
<div class="meta">{{ total }} results</div>
</div>
<nav class="scopes"> <nav class="scopes">
<Link <Link
href="/updates/latest" :href="getPath('latest')"
class="scope nolink" class="scope nolink"
:active="scope === 'latest'" :active="scope === 'latest'"
>Latest</Link> >Latest</Link>
<Link <Link
href="/updates/upcoming" :href="getPath('upcoming')"
class="scope nolink" class="scope nolink"
:active="scope === 'upcoming'" :active="scope === 'upcoming'"
>Upcoming</Link> >Upcoming</Link>
<Link <Link
href="/updates/new" :href="getPath('new')"
class="scope nolink" class="scope nolink"
:active="scope === 'new'" :active="scope === 'new'"
>New</Link> >New</Link>
@ -31,30 +64,150 @@
<Pagination /> <Pagination />
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue'; import { ref, computed, 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 Filters from '#/components/filters/filters.vue';
import Scene from './tile.vue'; import Scene from './tile.vue';
import Pagination from '../pagination/pagination.vue'; import Pagination from '../pagination/pagination.vue';
const { routeParams } = inject('pageContext');
const { scope } = routeParams;
defineProps({ defineProps({
scenes: { showFilters: {
type: Array, type: Boolean,
default: () => [], default: true,
}, },
}); });
const { pageProps, routeParams, urlParsed } = inject('pageContext');
const { scope } = routeParams;
console.log(scope);
const {
actor,
} = pageProps;
const scenes = ref(pageProps.scenes);
const actors = ref(pageProps.actors);
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.total));
const filters = ref({
actors: urlParsed.search.actors?.split(',').filter(Boolean).map((actorId) => Number(actorId)) || [],
});
/*
const sortedActors = computed(() => Array.from(actors.value).sort((actorA, actorB) => {
if (filters.value.actors.includes(actorA.id) && !filters.value.actors.includes(actorB.id)) {
return -1;
}
if (filters.value.actors.includes(actorB.id) && !filters.value.actors.includes(actorA.id)) {
return 1;
}
return 0;
}));
*/
const selectedActors = computed(() => actors.value.filter((filterActor) => filters.value.actors.includes(filterActor.id)));
console.log(filters.value.actors);
function getPath(targetScope, preserveQuery) {
const path = parse(routeParams.path)
.map((segment) => {
if (segment.name === 'scope') {
return targetScope;
}
if (segment.name === 'page') {
return 1;
}
return routeParams[segment.name] || segment;
})
.join('/');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(resetPage = true) {
if (resetPage) {
currentPage.value = 1;
}
const query = {};
console.log(query);
const res = await get('/scenes', {
...query,
actors: [actor?.id, filters.value.actors].filter(Boolean).join(','), // if we're on an actor page, actor ID needs to be included
scope,
page: currentPage.value, // client uses param rather than query pagination
});
scenes.value = res.scenes;
actors.value = res.actors;
total.value = res.total;
console.log(scenes.value);
events.emit('scrollUp');
navigate(getPath(scope, false), {
...query,
actors: filters.value.actors.join(',') || undefined, // don't include actor page ID in query, already a parameter
}, { redirect: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script> </script>
<style scoped> <style scoped>
.page { .page {
display: flex;
background: var(--background-base-10); background: var(--background-base-10);
} }
.scenes-header {
display: flex;
align-items: center;
padding: .5rem 0 .25rem 2rem;
}
.scenes-container {
display: flex;
flex-direction: column;
}
.meta {
display: flex;
flex-grow: 1;
justify-content: space-between;
align-items: center;
}
.scenes { .scenes {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
@ -78,4 +231,39 @@ defineProps({
font-weight: bold; font-weight: bold;
} }
} }
.filter-actors {
max-height: 15rem;
&.available {
overflow-y: auto;
}
}
.filter-actor {
display: flex;
justify-content: space-between;
&:hover {
color: var(--primary);
cursor: pointer;
}
}
.filter-actor-remove {
background: none;
border: none;
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--error);
}
}
}
</style> </style>

View File

@ -141,6 +141,7 @@ defineProps({
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
margin-right: .5rem; margin-right: .5rem;
object-fit: contain;
} }
.row { .row {

1
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"version": "0.4.2", "version": "0.4.2",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",

View File

@ -7,6 +7,7 @@
"server:prod": "cross-env NODE_ENV=production node ./src/web/server" "server:prod": "cross-env NODE_ENV=production node ./src/web/server"
}, },
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",

View File

@ -1,11 +1,45 @@
<template> <template>
<div class="page"> <div class="page">
<h2>Actor</h2> <div class="bio">
<img
v-if="actor.avatar"
:src="actor.avatar.isS3 ? `https://cdndev.traxxx.me/${actor.avatar.thumbnail}` : `/media/${actor.avatar.thumbnail}`"
:style="{ 'background-image': actor.avatar.isS3 ? `url(https://cdndev.traxxx.me/${actor.avatar.lazy})` : `url(/media/${actor.avatar.lazy})` }"
class="avatar"
>
<h2 class="name">{{ actor.name }}</h2>
</div>
<Scenes />
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
const { routeParams } = inject('pageContext'); import Scenes from '#/components/scenes/scenes.vue';
const pageContext = inject('pageContext');
const { pageProps } = pageContext;
const { actor } = pageProps;
</script> </script>
<style scoped>
.bio {
display: flex;
padding: 1rem;
background: var(--grey-dark-40);
color: var(--text-light);
}
.name {
margin: 0;
}
.avatar {
height: 10rem;
margin-right: 1rem;
}
</style>

View File

@ -0,0 +1,35 @@
import { fetchActorsById } from '#/src/actors.js';
import { fetchScenes } from '#/src/scenes.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
export async function onBeforeRender(pageContext) {
const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)]);
const {
scenes,
actors,
total,
limit,
} = await fetchScenes(curateScenesQuery({
...pageContext.urlQuery,
scope: pageContext.routeParams.scope || 'latest',
actorId: Number(pageContext.routeParams.actorId),
}), {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true,
});
return {
pageContext: {
title: actor.name,
pageProps: {
actor,
actors,
scenes,
total,
limit,
},
},
};
}

View File

@ -1 +1,23 @@
export default '/actor/@actorId/*'; import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/actor/:actorId/:actorSlug?/:scope?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
actorId: matched.params.actorId,
actorSlug: matched.params.actorSlug,
scope: matched.params.scope || 'latest',
page: matched.params.page || '1',
path,
},
};
}
return false;
};

View File

@ -2,6 +2,7 @@
<div> <div>
<Scenes <Scenes
:scenes="scenes" :scenes="scenes"
:show-filters="false"
/> />
</div> </div>
</template> </template>
@ -9,7 +10,7 @@
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
import Scenes from '../../components/scenes/scenes.vue'; import Scenes from '#/components/scenes/scenes.vue';
const { pageProps } = inject('pageContext'); const { pageProps } = inject('pageContext');
const { scenes } = pageProps; const { scenes } = pageProps;

View File

@ -1,21 +1,13 @@
import { fetchLatest, fetchUpcoming, fetchNew } from '#/src/scenes.js'; import { fetchScenes } from '#/src/scenes.js';
async function fetchScenes(scope, page, limit) {
if (scope === 'new') {
return fetchNew(page, { limit });
}
if (scope === 'upcoming') {
return fetchUpcoming(page, { limit });
}
return fetchLatest(page, { limit });
}
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const { scenes, limit, total } = await fetchScenes(pageContext.routeParams.scope, Number(pageContext.routeParams.page) || 1, Number(pageContext.urlParsed.search.limit) || 30); const { scenes, limit, total } = await fetchScenes({
scope: pageContext.routeParams.scope || 'latest',
// console.log(scenes); }, {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: false,
});
return { return {
pageContext: { pageContext: {

View File

@ -45,13 +45,22 @@ export function sortActorsByGender(actors) {
return genderActors; return genderActors;
} }
export async function fetchActorsById(actorIds) { export async function fetchActorsById(actorIds, options = {}) {
const [actors] = await Promise.all([ const [actors] = await Promise.all([
knex('actors_meta') knex('actors_meta')
.select('actors_meta.*') .select('actors_meta.*')
.whereIn('actors_meta.id', actorIds), .whereIn('actors_meta.id', actorIds)
.modify((builder) => {
if (options.order) {
builder.orderBy(...options.order);
}
}),
]); ]);
if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry));
}
const curatedActors = actorIds.map((actorId) => { const curatedActors = actorIds.map((actorId) => {
const actor = actors.find((actorEntry) => actorEntry.id === actorId); const actor = actors.find((actorEntry) => actorEntry.id === actorId);

View File

@ -1,3 +1,5 @@
import { parse } from '@brillout/json-serializer/parse';
const postHeaders = { const postHeaders = {
mode: 'cors', mode: 'cors',
credentials: 'same-origin', credentials: 'same-origin',
@ -18,7 +20,7 @@ function getQuery(data) {
export async function get(path, query = {}) { export async function get(path, query = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`); const res = await fetch(`/api${path}${getQuery(query)}`);
const body = await res.json(); const body = parse(await res.text());
if (res.ok) { if (res.ok) {
return body; return body;
@ -38,7 +40,7 @@ export async function post(path, data, { query } = {}) {
return null; return null;
} }
const body = await res.json(); const body = parse(await res.text());
if (res.ok) { if (res.ok) {
return body; return body;
@ -58,7 +60,7 @@ export async function patch(path, data, { query } = {}) {
return null; return null;
} }
const body = await res.json(); const body = parse(await res.text());
if (res.ok) { if (res.ok) {
return body; return body;
@ -78,7 +80,7 @@ export async function del(path, { data, query } = {}) {
return null; return null;
} }
const body = await res.json(); const body = parse(await res.text());
if (res.ok) { if (res.ok) {
return body; return body;

View File

@ -1,7 +1,7 @@
import knex from './knex.js'; import knex from './knex.js';
import { searchApi } from './manticore.js'; import { searchApi } from './manticore.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { curateActor, sortActorsByGender } from './actors.js'; import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
function curateMedia(media) { function curateMedia(media) {
if (!media) { if (!media) {
@ -67,16 +67,6 @@ function curateScene(rawScene, assets) {
}; };
} }
function curateOptions(options) {
if (options?.limit > 100) {
throw new HttpError('Limit must be <= 100', 400);
}
return {
limit: options.limit || 30,
};
}
export async function fetchScenesById(sceneIds) { export async function fetchScenesById(sceneIds) {
const [scenes, channels, actors, directors, tags, posters, photos] = await Promise.all([ const [scenes, channels, actors, directors, tags, posters, photos] = await Promise.all([
knex('releases').whereIn('id', sceneIds), knex('releases').whereIn('id', sceneIds),
@ -114,7 +104,8 @@ export async function fetchScenesById(sceneIds) {
.select('id', 'slug', 'name', 'release_id') .select('id', 'slug', 'name', 'release_id')
.whereNotNull('tags.id') .whereNotNull('tags.id')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id'), .leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.orderBy('priority', 'desc'),
knex('releases_posters') knex('releases_posters')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_posters.media_id'), .leftJoin('media', 'media.id', 'releases_posters.media_id'),
@ -148,23 +139,66 @@ export async function fetchScenesById(sceneIds) {
}).filter(Boolean); }).filter(Boolean);
} }
export async function fetchLatest(page, rawOptions) { function curateOptions(options) {
const { limit } = curateOptions(rawOptions); if (options?.limit > 100) {
throw new HttpError('Limit must be <= 100', 400);
}
const result = await searchApi.search({ return {
index: 'scenes', limit: options?.limit || 30,
query: { page: Number(options?.page) || 1,
aggregate: options.aggregate ?? true,
};
}
function buildQuery(filters = {}) {
const query = {
bool: { bool: {
must: [ must: [],
{ },
};
let sort = [{ effective_date: 'desc' }];
if (!filters.scope || filters.scope === 'latest') {
query.bool.must.push({
range: { range: {
effective_date: { effective_date: {
lte: Math.round(Date.now() / 1000), lte: Math.round(Date.now() / 1000),
}, },
}, },
});
}
if (filters.scope === 'upcoming') {
query.bool.must.push({
range: {
effective_date: {
gt: Math.round(Date.now() / 1000),
}, },
], },
/* });
sort = [{ effective_date: 'asc' }];
}
if (filters.scope === 'new') {
sort = [{ created_at: 'desc' }, { effective_date: 'asc' }];
}
if (filters.actorIds) {
console.log('actor ids', filters.actorIds);
filters.actorIds.forEach((actorId) => {
query.bool.must.push({
equals: {
'any(actor_ids)': actorId,
},
});
});
}
/* tag filter
must_not: [ must_not: [
{ {
in: { in: {
@ -173,72 +207,48 @@ export async function fetchLatest(page, rawOptions) {
}, },
], ],
*/ */
},
},
limit,
offset: (page - 1) * limit,
sort: [{ effective_date: 'desc' }],
});
const sceneIds = result.hits.hits.map((hit) => Number(hit._id)); console.log(query.bool.must);
const scenes = await fetchScenesById(sceneIds);
return { return { query, sort };
scenes,
total: result.hits.total,
limit,
};
} }
export async function fetchUpcoming(page, rawOptions) { export async function fetchScenes(filters, rawOptions) {
const { limit } = curateOptions(rawOptions); const options = curateOptions(rawOptions);
const { query, sort } = buildQuery(filters);
console.log(filters);
const result = await searchApi.search({ const result = await searchApi.search({
index: 'scenes', index: 'scenes',
query: { query,
bool: { limit: options.limit,
must: [ offset: (options.page - 1) * options.limit,
{ sort,
range: { ...(options.aggregate && {
effective_date: { aggs: {
gt: Math.round(Date.now() / 1000), actorIds: {
terms: {
field: 'actor_ids',
size: 5000,
},
// sort: [{ doc_count: { order: 'asc' } }],
}, },
}, },
}, }),
],
},
},
limit,
offset: (page - 1) * limit,
sort: [{ effective_date: 'asc' }],
}); });
const [actors] = await Promise.all([
options.aggregate ? fetchActorsById(result.aggregations.actorIds.buckets.map((bucket) => bucket.key), { order: ['name', 'asc'] }) : [],
]);
const sceneIds = result.hits.hits.map((hit) => Number(hit._id)); const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds); const scenes = await fetchScenesById(sceneIds);
return { return {
scenes, scenes,
actors,
total: result.hits.total, total: result.hits.total,
limit, limit: options.limit,
};
}
export async function fetchNew(page, rawOptions) {
const { limit } = curateOptions(rawOptions);
const result = await searchApi.search({
index: 'scenes',
limit,
offset: (page - 1) * limit,
sort: [{ created_at: 'desc' }, { effective_date: 'asc' }],
});
const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds);
return {
scenes,
total: result.hits.total,
limit,
}; };
} }

29
src/web/scenes.js Normal file
View File

@ -0,0 +1,29 @@
import { stringify } from '@brillout/json-serializer/stringify';
import { fetchScenes } from '../scenes.js';
export function curateScenesQuery(query) {
return {
scope: query.scope || 'latest',
actorIds: [query.actorId, ...(query.actors?.split(',') || [])].filter(Boolean).map((actorId) => Number(actorId)),
};
}
export async function fetchScenesApi(req, res) {
const {
scenes,
actors,
limit,
total,
} = await fetchScenes(curateScenesQuery(req.query), {
page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 30,
});
res.send(stringify({
scenes,
actors,
limit,
total,
}));
}

View File

@ -20,7 +20,8 @@ import { renderPage } from 'vike/server'; // eslint-disable-line import/extensio
// import root from './root.js'; // import root from './root.js';
import { fetchActorsApi } from './actors.js'; // eslint-disable-line import/extensions import { fetchScenesApi } from './scenes.js';
import { fetchActorsApi } from './actors.js';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@ -60,6 +61,8 @@ async function startServer() {
router.get('/api/actors', fetchActorsApi); router.get('/api/actors', fetchActorsApi);
router.get('/api/scenes', fetchScenesApi);
// ... // ...
// Other middlewares (e.g. some RPC middleware such as Telefunc) // Other middlewares (e.g. some RPC middleware such as Telefunc)
// ... // ...
@ -75,6 +78,10 @@ async function startServer() {
const pageContext = await renderPage(pageContextInit); const pageContext = await renderPage(pageContextInit);
const { httpResponse } = pageContext; const { httpResponse } = pageContext;
if (pageContext.errorWhileRendering) {
console.error(pageContext.errorWhileRendering);
}
if (!httpResponse) { if (!httpResponse) {
next(); next();
return; return;