Added actor stash.

This commit is contained in:
DebaucheryLibrarian 2024-03-21 02:54:05 +01:00
parent 9b50b53df6
commit a8aab600c7
37 changed files with 1292 additions and 490 deletions

View File

@ -192,3 +192,9 @@
.v-popper--theme-dropdown .v-popper__arrow-outer { .v-popper--theme-dropdown .v-popper__arrow-outer {
border-color: #ddd; border-color: #ddd;
} }
.resize-observer {
width: 0;
height: 0;
overflow: hidden;
}

View File

@ -1,30 +1,297 @@
<template> <template>
<ul class="actors nolist"> <div class="page">
<li <Filters :results="total">
v-for="actor in actors" <div class="filter">
:key="`actor-${actor.id}`" <input
class="actor" v-model="q"
> type="search"
<ActorTile :actor="actor" /> placeholder="Search actors"
</li> class="input search"
</ul> @search="search"
>
</div>
<GenderFilter
:filters="filters"
@update="updateFilter"
/>
<BirthdateFilter
:filters="filters"
@update="updateFilter"
/>
<BoobsFilter
:filters="filters"
:cup-range="cupRange"
@update="updateFilter"
/>
<PhysiqueFilter
:filters="filters"
@update="updateFilter"
/>
<CountryFilter
:filters="filters"
:countries="countries"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.avatarRequired"
label="Require photo"
@change="(checked) => updateFilter('avatarRequired', checked, true)"
/>
</div>
</Filters>
<div class="actors-container">
<div class="actors-header">
<div class="meta">
<span class="count">{{ total }} results</span>
<select
v-model="order"
class="input"
@change="search({ autoScope: false })"
>
<option
v-if="pageStash"
:selected="order === 'stashed.desc'"
value="stashed.desc"
>Added</option>
<option
v-if="q"
:selected="order === 'relevance.desc'"
value="relevance.desc"
>Relevance</option>
<option value="name.asc">Name</option>
<option value="likes.desc">Likes</option>
<option value="scenes.desc">Scenes</option>
</select>
</div>
</div>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
>
<ActorTile
:actor="actor"
/>
</li>
</ul>
<Pagination
:page="currentPage"
:total="total"
:redirect="false"
@navigation="paginate"
/>
</div>
</div>
</template> </template>
<script setup> <script setup>
import ActorTile from './tile.vue'; import { ref, inject } from 'vue';
import { format, subYears } from 'date-fns';
import { parse } from 'path-to-regexp';
defineProps({ import navigate from '#/src/navigate.js';
actors: { import { get } from '#/src/api.js';
type: Array, import events from '#/src/events.js';
default: () => [],
}, import ActorTile from '#/components/actors/tile.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import CountryFilter from '#/components/filters/country.vue';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const pageStash = pageProps.stash;
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.total));
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
}); });
function getPath(preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
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 (q.value) {
order.value = 'relevance.desc';
}
if (!q.value && order.value.includes('relevance')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
stashId: pageStash?.id,
};
navigate(getPath(false), query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script> </script>
<style scoped> <style>
.actors { .gender-button {
display: grid; &.selected .gender .icon {
grid-template-columns: repeat(auto-fill, 10rem); fill: var(--text-light);
gap: .5rem; filter: none;
}
&:hover:not(.selected) {
.gender .icon {
fill: var(--text-light);
}
.male .icon {
filter: drop-shadow(0 0 1px var(--male));
}
.female .icon {
filter: drop-shadow(0 0 1px var(--female));
}
}
&:hover:not(.selected) .transsexual .icon {
fill: var(--female);
filter: drop-shadow(1px 0 0 var(--text-light)) drop-shadow(-1px 0 0 var(--text-light)) drop-shadow(0 1px 0 var(--text-light)) drop-shadow(0 -1px 0 var(--text-light)) drop-shadow(1px 0 0 var(--male)) drop-shadow(-1px 0 0 var(--male)) drop-shadow(0 1px 0 var(--male)) drop-shadow(0 -1px 0 var(--male)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
}
}
</style>
<style scoped>
.page {
min-height: 100%;
display: flex;
align-items: stretch;
}
.actors-header {
display: flex;
align-items: center;
padding: .5rem 0 .25rem 2rem;
}
.meta {
display: flex;
flex-grow: 1;
justify-content: space-between;
align-items: center;
}
.actors-container {
display: flex;
flex-grow: 1;
flex-direction: column;
box-sizing: border-box;
padding: 0 1rem 1rem 1rem;
}
.actors {
display: grid;
flex-grow: 1;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: .25rem;
}
@media(--small-40) {
.actors {
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
}
} }
</style> </style>

View File

@ -1,19 +1,40 @@
<template> <template>
<div class="actor"> <div
class="tile"
:class="{
unstashed: !favorited && pageStash && user && pageStash.id === user.primaryStash?.id
}"
>
<span class="name">{{ actor.name }}</span> <span class="name">{{ actor.name }}</span>
<Link <div class="avatar-container">
:href="`/actor/${actor.id}/${actor.slug}`" <Link
class="avatar-link no-link" :href="`/actor/${actor.id}/${actor.slug}`"
> class="avatar-link no-link"
<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})` }"
loading="lazy"
class="avatar"
> >
</Link> <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})` }"
loading="lazy"
class="avatar"
>
</Link>
<Icon
v-show="favorited"
icon="heart7"
class="heart favorited"
@click.native.stop="unstash"
/>
<Icon
v-show="!favorited && user"
icon="heart8"
class="heart"
@click.native.stop="stash"
/>
</div>
<div class="details"> <div class="details">
<span class="birth"> <span class="birth">
@ -48,20 +69,72 @@
</template> </template>
<script setup> <script setup>
import { ref, inject } from 'vue';
import { formatDate } from '#/utils/format.js'; import { formatDate } from '#/utils/format.js';
import { post, del } from '#/src/api.js';
import events from '#/src/events.js';
import Gender from './gender.vue'; import Gender from './gender.vue';
defineProps({ const props = defineProps({
actor: { actor: {
type: Object, type: Object,
default: null, default: null,
}, },
}); });
const pageContext = inject('pageContext');
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
// console.log(props.actor);
const favorited = ref(props.actor.stashes?.some((sceneStash) => sceneStash.primary) || false);
async function stash() {
try {
favorited.value = true;
await post(`/stashes/${user.primaryStash.id}/actors`, { actorId: props.actor.id });
events.emit('feedback', {
type: 'success',
message: `${props.actor.name} stashed to ${user.primaryStash.name}`,
});
} catch (error) {
favorited.value = false;
events.emit('feedback', {
type: 'error',
message: `Failed to stash ${props.actor.name} to ${user.primaryStash.name}`,
});
}
}
async function unstash() {
try {
favorited.value = false;
await del(`/stashes/${user.primaryStash.id}/actors/${props.actor.id}`);
events.emit('feedback', {
type: 'remove',
message: `${props.actor.name} unstashed from ${user.primaryStash.name}`,
});
} catch (error) {
favorited.value = true;
console.error(error);
events.emit('feedback', {
type: 'error',
message: `Failed to unstash ${props.actor.name} from ${user.primaryStash.name}`,
});
}
}
</script> </script>
<style scoped> <style scoped>
.actor { .tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
@ -79,6 +152,10 @@ defineProps({
color: var(--primary); color: var(--primary);
} }
} }
&.unstashed {
opacity: .5;
}
} }
.name { .name {
@ -92,9 +169,14 @@ defineProps({
user-select: all; user-select: all;
} }
.avatar-link { .avatar-container {
display: block; position: relative;
flex-grow: 1; flex-grow: 1;
}
.avatar-link {
height: 100%;
display: block;
overflow: hidden; overflow: hidden;
} }
@ -107,6 +189,26 @@ defineProps({
background-position: center 0; background-position: center 0;
} }
.icon.heart {
width: 2rem;
height: 1.5rem;
position: absolute;
top: 0;
right: 0;
padding: .5rem .5rem 1rem 1rem;
fill: var(--highlight-strong-10);
filter: drop-shadow(0 0 3px var(--shadow));
&:hover {
cursor: pointer;
fill: var(--primary);
}
&.favorited {
fill: var(--primary);
}
}
.details { .details {
width: 100%; width: 100%;
height: 1.5rem; height: 1.5rem;

View File

@ -103,7 +103,7 @@
:href="`/user/${user.username}`" :href="`/user/${user.username}`"
class="menu-button nolink" class="menu-button nolink"
> >
<Icon icon="vcard" /> <Icon icon="user7" />
View profile View profile
</a> </a>
</li> </li>
@ -332,7 +332,7 @@ async function logout() {
.menu-button { .menu-button {
display: flex; display: flex;
align-items: center; align-items: center;
padding: .5rem .5rem .5rem .75rem; padding: .75rem .5rem .75rem .75rem;
font-size: 1.1rem; font-size: 1.1rem;
.icon { .icon {
@ -342,8 +342,16 @@ async function logout() {
} }
&:hover { &:hover {
background: var(--shadow-weak-30); color: var(--primary);
cursor: pointer; cursor: pointer;
&:not(.logout) .icon {
fill: var(--primary);
}
&.logout {
color: var(--error);
}
} }
} }

View File

@ -68,7 +68,7 @@
<option value="latest">Latest</option> <option value="latest">Latest</option>
<option value="upcoming">Upcoming</option> <option value="upcoming">Upcoming</option>
<option value="new">New</option> <option value="new">New</option>
<option value="likes">Likes</option> <option value="likes">Popular</option>
</select> </select>
</div> </div>

View File

@ -110,6 +110,8 @@ import { ref, inject } from 'vue';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { post, del } from '#/src/api.js'; import { post, del } from '#/src/api.js';
import events from '#/src/events.js';
import ellipsis from '#/utils/ellipsis.js';
import Icon from '../icon/icon.vue'; import Icon from '../icon/icon.vue';
@ -125,23 +127,47 @@ const user = pageContext.user;
const pageStash = pageContext.pageProps.stash; const pageStash = pageContext.pageProps.stash;
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.primary)); const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.primary));
const fbCutoff = 20;
async function stash() { async function stash() {
try { try {
favorited.value = true; favorited.value = true;
await post(`/stashes/${user.primaryStash.id}/scenes`, { sceneId: props.scene.id }); await post(`/stashes/${user.primaryStash.id}/scenes`, { sceneId: props.scene.id });
events.emit('feedback', {
type: 'success',
message: `"${ellipsis(props.scene.title, fbCutoff)}" stashed to ${user.primaryStash.name}`,
});
} catch (error) { } catch (error) {
favorited.value = false; favorited.value = false;
events.emit('feedback', {
type: 'error',
message: `Failed to stash "${ellipsis(props.scene.title, fbCutoff)}" to ${user.primaryStash.name}`,
});
} }
} }
async function unstash() { async function unstash() {
try { try {
favorited.value = false; favorited.value = false;
await del(`/stashes/${user.primaryStash.id}/scenes/${props.scene.id}`); await del(`/stashes/${user.primaryStash.id}/scenes/${props.scene.id}`);
events.emit('feedback', {
type: 'remove',
message: `"${ellipsis(props.scene.title, fbCutoff)}" unstashed from ${user.primaryStash.name}`,
});
} catch (error) { } catch (error) {
console.error(error);
favorited.value = true; favorited.value = true;
console.error(error);
events.emit('feedback', {
type: 'error',
message: `Failed to unstash "${ellipsis(props.scene.title, fbCutoff)}" from ${user.primaryStash.name}`,
});
} }
} }
</script> </script>

View File

@ -26,8 +26,28 @@
</a> </a>
</div> </div>
<div class="scenes-container"> <div class="content">
<Scenes /> <nav class="domains">
<Link
:href="getPath('scenes')"
class="domain nolink"
:active="domain === 'scenes'"
>Scenes</Link>
<Link
:href="getPath('actors')"
class="domain nolink"
:active="domain === 'actors'"
>Actors</Link>
<Link
:href="getPath('movies')"
class="domain nolink"
:active="domain === 'movies'"
>Movies</Link>
</nav>
<slot />
</div> </div>
</div> </div>
</template> </template>
@ -35,10 +55,15 @@
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
import Scenes from '#/components/scenes/scenes.vue';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { routeParams } = pageContext;
const domain = routeParams.domain;
const stash = pageContext.pageProps.stash; const stash = pageContext.pageProps.stash;
function getPath(targetDomain) {
return `/stash/${stash.user.username}/${stash.slug}/${targetDomain}`;
}
</script> </script>
<style scoped> <style scoped>
@ -91,6 +116,31 @@ const stash = pageContext.pageProps.stash;
overflow-y: auto; overflow-y: auto;
} }
.domains {
display: flex;
gap: .5rem;
padding: .5rem 1rem;
}
.domain {
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);
}
}
.content {
overflow-y: auto;
}
@media(--small-50) { @media(--small-50) {
.title { .title {
font-size: 1rem; font-size: 1rem;

View File

@ -73,6 +73,9 @@ module.exports = {
usernameLength: [2, 24], usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/, usernamePattern: /^[a-zA-Z0-9_-]+$/,
}, },
stashes: {
viewRefreshCooldown: 60, // minutes
},
exclude: { exclude: {
channels: [ channels: [
// 21sextreme, no longer updated // 21sextreme, no longer updated

10
pages/+config.js Normal file
View File

@ -0,0 +1,10 @@
export default {
meta: {
data: {
env: {
server: true,
client: false,
},
},
},
};

5
pages/_error/+data.js Normal file
View File

@ -0,0 +1,5 @@
export function data() {
return {
foo: 'bar',
};
}

View File

@ -1,270 +1,9 @@
<template> <template>
<div class="page"> <div>
<Filters :results="total"> <Actors />
<div class="filter">
<input
v-model="q"
type="search"
placeholder="Search actors"
class="input search"
@search="search"
>
</div>
<GenderFilter
:filters="filters"
@update="updateFilter"
/>
<BirthdateFilter
:filters="filters"
@update="updateFilter"
/>
<BoobsFilter
:filters="filters"
:cup-range="cupRange"
@update="updateFilter"
/>
<PhysiqueFilter
:filters="filters"
@update="updateFilter"
/>
<CountryFilter
:filters="filters"
:countries="countries"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.avatarRequired"
label="Require photo"
@change="(checked) => updateFilter('avatarRequired', checked, true)"
/>
</div>
</Filters>
<div class="actors-container">
<div class="actors-header">
<div class="meta">
<span class="count">{{ total }} results</span>
<select
v-model="order"
class="input"
@change="search({ autoScope: false })"
>
<option value="name.asc">Name</option>
<option value="likes.desc">Likes</option>
<option value="scenes.desc">Scenes</option>
<option
value="relevance.desc"
:disabled="!q"
>Relevance</option>
</select>
</div>
</div>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
>
<ActorTile
:actor="actor"
/>
</li>
</ul>
<Pagination
:page="currentPage"
:total="total"
:redirect="false"
@navigation="paginate"
/>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, inject } from 'vue'; import Actors from '#/components/actors/actors.vue';
import { format, subYears } from 'date-fns';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import ActorTile from '#/components/actors/tile.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import CountryFilter from '#/components/filters/country.vue';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.total));
const order = ref(urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
});
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (q.value) {
order.value = 'relevance.desc';
}
if (!q.value && order.value.includes('relevance')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
};
navigate(`/actors/${currentPage.value}`, query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script> </script>
<style>
.gender-button {
&.selected .gender .icon {
fill: var(--text-light);
filter: none;
}
&:hover:not(.selected) {
.gender .icon {
fill: var(--text-light);
}
.male .icon {
filter: drop-shadow(0 0 1px var(--male));
}
.female .icon {
filter: drop-shadow(0 0 1px var(--female));
}
}
&:hover:not(.selected) .transsexual .icon {
fill: var(--female);
filter: drop-shadow(1px 0 0 var(--text-light)) drop-shadow(-1px 0 0 var(--text-light)) drop-shadow(0 1px 0 var(--text-light)) drop-shadow(0 -1px 0 var(--text-light)) drop-shadow(1px 0 0 var(--male)) drop-shadow(-1px 0 0 var(--male)) drop-shadow(0 1px 0 var(--male)) drop-shadow(0 -1px 0 var(--male)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
}
}
</style>
<style scoped>
.page {
min-height: 100%;
display: flex;
align-items: stretch;
}
.actors-header {
display: flex;
align-items: center;
padding: .5rem 0 .25rem 2rem;
}
.meta {
display: flex;
flex-grow: 1;
justify-content: space-between;
align-items: center;
}
.actors-container {
display: flex;
flex-grow: 1;
flex-direction: column;
box-sizing: border-box;
padding: 0 1rem 1rem 1rem;
}
.actors {
display: grid;
flex-grow: 1;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: .25rem;
}
@media(--small-40) {
.actors {
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
}
}
</style>

View File

@ -12,7 +12,7 @@ export async function onBeforeRender(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 120, limit: Number(pageContext.urlParsed.search.limit) || 120,
order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'], order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'],
}); }, pageContext.user);
return { return {
pageContext: { pageContext: {

View File

@ -25,20 +25,19 @@
</span> </span>
</li> </li>
<!-- <Icon
<Social v-show="favorited"
v-if="actor.social && actor.social.length > 0" icon="heart7"
:actor="actor" class="heart favorited"
class="header-social" @click.native.stop="unstash"
/> />
<StashButton <Icon
:stashed-by="stashedBy" v-show="!favorited && user"
class="actor-stash light" icon="heart8"
@stash="(stash) => stashActor(stash)" class="heart"
@unstash="(stash) => unstashActor(stash)" @click.native.stop="stash"
/> />
-->
</div> </div>
<div class="content"> <div class="content">
@ -50,15 +49,61 @@
</template> </template>
<script setup> <script setup>
import { inject } from 'vue'; import { ref, inject } from 'vue';
import { post, del } from '#/src/api.js';
import events from '#/src/events.js';
import Bio from '#/components/actors/bio.vue'; import Bio from '#/components/actors/bio.vue';
import Gender from '#/components/actors/gender.vue'; import Gender from '#/components/actors/gender.vue';
import Scenes from '#/components/scenes/scenes.vue'; import Scenes from '#/components/scenes/scenes.vue';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { pageProps } = pageContext; const { pageProps, user } = pageContext;
const { actor } = pageProps; const { actor } = pageProps;
const favorited = ref(actor.stashes?.some((sceneStash) => sceneStash.primary) || false);
async function stash() {
try {
favorited.value = true;
await post(`/stashes/${user.primaryStash.id}/actors`, { actorId: actor.id });
events.emit('feedback', {
type: 'success',
message: `${actor.name} stashed to ${user.primaryStash.name}`,
});
} catch (error) {
favorited.value = false;
events.emit('feedback', {
type: 'error',
message: `Failed to stash ${actor.name} to ${user.primaryStash.name}`,
});
}
}
async function unstash() {
try {
favorited.value = false;
await del(`/stashes/${user.primaryStash.id}/actors/${actor.id}`);
events.emit('feedback', {
type: 'remove',
message: `${actor.name} unstashed from ${user.primaryStash.name}`,
});
} catch (error) {
favorited.value = true;
console.error(error);
events.emit('feedback', {
type: 'error',
message: `Failed to unstash ${actor.name} from ${user.primaryStash.name}`,
});
}
}
</script> </script>
<style scoped> <style scoped>
@ -73,6 +118,7 @@ const { actor } = pageProps;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
position: relative;
color: var(--highlight-strong-30); color: var(--highlight-strong-30);
background: var(--grey-dark-40); background: var(--grey-dark-40);
} }
@ -103,4 +149,24 @@ const { actor } = pageProps;
flex-grow: 1; flex-grow: 1;
overflow-y: auto; overflow-y: auto;
} }
.icon.heart {
width: 2rem;
height: 1.5rem;
position: absolute;
top: 0;
right: 0;
padding: .5rem 1rem 1rem 1rem;
fill: var(--highlight-strong-10);
filter: drop-shadow(0 0 3px var(--shadow));
&:hover {
cursor: pointer;
fill: var(--primary);
}
&.favorited {
fill: var(--primary);
}
}
</style> </style>

View File

@ -4,7 +4,7 @@ import { curateScenesQuery } from '#/src/web/scenes.js';
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [[actor], actorScenes] = await Promise.all([ const [[actor], actorScenes] = await Promise.all([
fetchActorsById([Number(pageContext.routeParams.actorId)]), fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user),
fetchScenes(await curateScenesQuery({ fetchScenes(await curateScenesQuery({
...pageContext.urlQuery, ...pageContext.urlQuery,
scope: pageContext.routeParams.scope || 'latest', scope: pageContext.routeParams.scope || 'latest',
@ -13,7 +13,7 @@ export async function onBeforeRender(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}), }, pageContext.user),
]); ]);
const { const {

View File

@ -26,6 +26,7 @@
<div <div
v-if="scene.photos.length > 0" v-if="scene.photos.length > 0"
class="album" class="album"
:class="{ single: scene.photos.length === 1 }"
> >
<div <div
v-for="photo in scene.photos" v-for="photo in scene.photos"
@ -92,7 +93,10 @@
>{{ scene.title }}</h2> >{{ scene.title }}</h2>
<div class="actions"> <div class="actions">
<div class="bookmarks"> <div
v-if="user"
class="bookmarks"
>
<Icon icon="folder-heart" /> <Icon icon="folder-heart" />
<Icon <Icon
@ -103,7 +107,7 @@
/> />
<Icon <Icon
v-show="!favorited && user" v-show="!favorited"
icon="heart8" icon="heart8"
class="heart" class="heart"
@click.native.stop="stash" @click.native.stop="stash"
@ -179,7 +183,11 @@
<div class="section details"> <div class="section details">
<div class="detail"> <div class="detail">
<h3 class="heading">Added</h3> <h3 class="heading">Added</h3>
<span class="added-date">{{ formatDate(scene.createdAt, 'yyyy-MM-dd hh:mm') }}</span> <span class="added-batch">batch #{{ scene.createdBatchId }}</span> <span class="added-date">{{ formatDate(scene.createdAt, 'yyyy-MM-dd') }}</span>
<span
:title="`Batch ${scene.createdBatchId}`"
class="added-batch"
>#{{ scene.createdBatchId }}</span>
</div> </div>
<div <div
@ -246,28 +254,29 @@ async function unstash() {
.banner-container { .banner-container {
background-position: center; background-position: center;
background-size: cover; background-size: cover;
border-radius: .25rem .5rem 0 0; border-radius: .5rem .5rem 0 0;
margin-top: .5rem; margin-top: .5rem;
box-shadow: 0 0 3px var(--shadow-weak-30);
} }
.banner { .banner {
max-height: 21rem; max-height: 21rem;
border-radius: .5rem .5rem 0 0; border-radius: .5rem 0 0 0;
display: flex; display: flex;
font-size: 0; font-size: 0;
backdrop-filter: blur(1rem); backdrop-filter: brightness(150%) blur(1rem);
overflow: hidden; overflow: hidden;
} }
.poster-container { .poster-container {
flex-shrink: 0; flex-shrink: 0;
padding: .5rem; margin-right: .5rem;
} }
.poster { .poster {
height: 100%; height: 100%;
width: 100%; width: 100%;
border-radius: .25rem; border-radius: .25rem 0 0 0;
} }
.poster, .poster,
@ -282,19 +291,33 @@ async function unstash() {
height: auto; height: auto;
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: .5rem; gap: .25rem;
box-sizing: border-box;
padding: .5rem .5rem .5rem 0; padding: .5rem .5rem .5rem 0;
overflow-y: auto; overflow-y: auto;
scrollbar-width: 0;
&::-webkit-scrollbar {
display: none;
}
&.single .photo {
max-height: calc(100% - 1.5rem);
}
} }
.photo-container { .photo-container {
width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.photo-link {
width: 100%;
height: 100%;
}
.photo { .photo {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -341,7 +364,7 @@ async function unstash() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1rem 1.5rem 1rem; padding: 1rem 1rem 0.5rem 1rem;
} }
.title { .title {
@ -447,7 +470,8 @@ async function unstash() {
} }
.added-batch { .added-batch {
color: var(--shadow); color: var(--shadow-weak-10);
margin-left: .25rem;
} }
@media(--small-10) { @media(--small-10) {
@ -473,6 +497,14 @@ async function unstash() {
.album { .album {
display: none; display: none;
} }
.header {
padding: 1rem .5rem 1.5rem .5rem;
}
.info {
padding: 0 .5rem;
}
} }
@media (--small) { @media (--small) {

View File

@ -1,7 +1,10 @@
import { fetchScenesById } from '#/src/scenes.js'; import { fetchScenesById } from '#/src/scenes.js';
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [scene] = await fetchScenesById([Number(pageContext.routeParams.sceneId)], pageContext.user); const [scene] = await fetchScenesById([Number(pageContext.routeParams.sceneId)], {
reqUser: pageContext.user,
actorStashes: true,
});
return { return {
pageContext: { pageContext: {

View File

@ -0,0 +1,10 @@
<template>
<Stash>
<Actors />
</Stash>
</template>
<script setup>
import Stash from '#/components/stashes/stash.vue';
import Actors from '#/components/actors/actors.vue';
</script>

View File

@ -0,0 +1,49 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchStashByUsernameAndSlug } from '#/src/stashes.js';
import { fetchActors } from '#/src/actors.js';
import { curateActorsQuery } from '#/src/web/actors.js';
import { HttpError } from '#/src/errors.js';
export async function onBeforeRender(pageContext) {
try {
const stash = await fetchStashByUsernameAndSlug(pageContext.routeParams.username, pageContext.routeParams.stashSlug, pageContext.user);
const stashActors = await fetchActors(curateActorsQuery({
...pageContext.urlQuery,
stashId: stash.id,
}), {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 120,
order: pageContext.urlParsed.search.order?.split('.') || ['stashed', 'desc'],
}, pageContext.user);
const {
actors,
countries,
cupRange,
limit,
total,
} = stashActors;
return {
pageContext: {
title: `${stash.name} by ${stash.user.username}`,
pageProps: {
stash,
actors,
countries,
cupRange,
limit,
total,
},
},
};
} catch (error) {
if (error instanceof HttpError) {
throw render(error.httpCode, error.message);
}
throw error;
}
}

View File

@ -0,0 +1,24 @@
import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/stash/:username/:stashSlug/:domain(actors)/:page?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
username: matched.params.username,
stashSlug: matched.params.stashSlug,
domain: matched.params.domain,
order: 'stashed.desc',
page: matched.params.page || '1',
path,
},
};
}
return false;
};

View File

@ -0,0 +1,10 @@
<template>
<Stash>
<h2>Movies</h2>
</Stash>
</template>
<script setup>
import Stash from '#/components/stashes/stash.vue';
// import Actors from '#/components/actors/actors.vue';
</script>

View File

@ -0,0 +1,25 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchStashByUsernameAndSlug } from '#/src/stashes.js';
import { HttpError } from '#/src/errors.js';
export async function onBeforeRender(pageContext) {
try {
const stash = await fetchStashByUsernameAndSlug(pageContext.routeParams.username, pageContext.routeParams.stashSlug, pageContext.user);
return {
pageContext: {
title: `${stash.name} by ${stash.user.username}`,
pageProps: {
stash,
},
},
};
} catch (error) {
if (error instanceof HttpError) {
throw render(error.httpCode, error.message);
}
throw error;
}
}

View File

@ -1,7 +1,7 @@
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions // import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/stash/:username/:stashSlug/:scope?/:page?'; const path = '/stash/:username/:stashSlug/:domain(movies)/:scope?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent }); const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => { export default (pageContext) => {
@ -12,6 +12,7 @@ export default (pageContext) => {
routeParams: { routeParams: {
username: matched.params.username, username: matched.params.username,
stashSlug: matched.params.stashSlug, stashSlug: matched.params.stashSlug,
domain: matched.params.domain,
scope: matched.params.scope || 'stashed', scope: matched.params.scope || 'stashed',
page: matched.params.page || '1', page: matched.params.page || '1',
path, path,

View File

@ -0,0 +1,10 @@
<template>
<Stash>
<Scenes />
</Stash>
</template>
<script setup>
import Stash from '#/components/stashes/stash.vue';
import Scenes from '#/components/scenes/scenes.vue';
</script>

View File

@ -0,0 +1,24 @@
import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/stash/:username/:stashSlug/:domain?/:scope?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
username: matched.params.username,
stashSlug: matched.params.stashSlug,
domain: matched.params.domain || 'scenes',
scope: matched.params.scope || 'stashed',
page: matched.params.page || '1',
path,
},
};
}
return false;
};

View File

@ -1,3 +1,10 @@
export default { export default {
passToClient: ['pageProps', 'urlPathname', 'routeParams', 'urlParsed', 'env', 'user'], passToClient: [
'pageProps',
'urlPathname',
'routeParams',
'urlParsed',
'env',
'user',
],
}; };

View File

@ -23,11 +23,23 @@
class="nav" class="nav"
@sidebar="showSidebar = true" @sidebar="showSidebar = true"
/> />
<div
ref="feedbackContainer"
class="feedback-container"
>
<div
v-if="feedback"
ref="feedbackBubble"
class="feedback"
:class="{ [feedback.type]: true }"
>{{ feedback.message }}</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted, nextTick } from 'vue';
import events from '#/src/events.js'; import events from '#/src/events.js';
@ -36,6 +48,9 @@ import Sidebar from '#/components/sidebar/sidebar.vue';
import BottomNavigation from '#/components/footer/navigation.vue'; import BottomNavigation from '#/components/footer/navigation.vue';
const content = ref(null); const content = ref(null);
const feedback = ref(null);
const feedbackContainer = ref(null);
const feedbackBubble = ref(null);
const showSidebar = ref(false); const showSidebar = ref(false);
function blur() { function blur() {
@ -44,6 +59,38 @@ function blur() {
onMounted(() => { onMounted(() => {
events.on('scrollUp', () => { content.value.scrollTop = 0; }); events.on('scrollUp', () => { content.value.scrollTop = 0; });
events.on('feedback', async (event) => {
console.log(event);
feedback.value = event;
await nextTick();
feedbackBubble.value.animate([
{
visibility: 'visible',
transform: 'scaleY(0)',
opacity: 1,
},
{
transform: 'scaleY(1)',
opacity: 1,
offset: 0.002,
},
{
transform: 'scaleY(1)',
opacity: 1,
offset: 0.5,
},
{
opacity: 0,
},
], {
duration: 3000,
easing: 'ease-in-out',
});
});
}); });
</script> </script>
@ -102,9 +149,47 @@ onMounted(() => {
display: none; display: none;
} }
.feedback-container {
width: 100%;
display: flex;
justify-content: center;
position: fixed;
bottom: 1rem;;
z-index: 1000;
pointer-events: none;
}
.feedback {
padding: .5rem 1rem;
margin: 0 .5rem;
border-radius: 1rem;
box-shadow: 0 0 3px var(--shadow-weak-10);
background: var(--grey-dark-40);
color: var(--text-light);
font-size: .9rem;
visibility: hidden;
line-height: 1.5;
&.success {
background: var(--success);
}
&.error {
background: var(--error);
}
&.remove {
background: var(--warn);
}
}
@media(--small-10) { @media(--small-10) {
.nav { .nav {
display: flex; display: flex;
} }
.feedback-container {
bottom: 4rem;
}
} }
</style> </style>

View File

@ -1,18 +1,15 @@
// `usePageContext` allows us to access `pageContext` in any Vue component. // `usePageContext` allows us to access `pageContext` in any Vue component.
// See https://vike.dev/pageContext-anywhere // See https://vike.dev/pageContext-anywhere
import { inject } from 'vue' import { inject } from 'vue';
export { usePageContext } const key = Symbol(); // eslint-disable-line symbol-description
export { setPageContext }
const key = Symbol() export function usePageContext() {
const pageContext = inject(key);
function usePageContext() { return pageContext;
const pageContext = inject(key)
return pageContext
} }
function setPageContext(app, pageContext) { export function setPageContext(app, pageContext) {
app.provide(key, pageContext) app.provide(key, pageContext);
} }

View File

@ -2,10 +2,11 @@ import config from 'config';
import { differenceInYears } from 'date-fns'; import { differenceInYears } from 'date-fns';
import { unit } from 'mathjs'; import { unit } from 'mathjs';
import knex from './knex.js'; import { knexOwner as knex, knexManticore } from './knex.js';
import { searchApi } from './manticore.js'; import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { fetchCountriesByAlpha2 } from './countries.js'; import { fetchCountriesByAlpha2 } from './countries.js';
import { curateStash } from './stashes.js';
export function curateActor(actor, context = {}) { export function curateActor(actor, context = {}) {
return { return {
@ -58,6 +59,7 @@ export function curateActor(actor, context = {}) {
createdAt: actor.created_at, createdAt: actor.created_at,
updatedAt: actor.updated_at, updatedAt: actor.updated_at,
likes: actor.stashed, likes: actor.stashed,
stashes: context.stashes?.map((stash) => curateStash(stash)) || [],
...context.append?.[actor.id], ...context.append?.[actor.id],
}; };
} }
@ -73,8 +75,8 @@ export function sortActorsByGender(actors) {
return genderActors; return genderActors;
} }
export async function fetchActorsById(actorIds, options = {}) { export async function fetchActorsById(actorIds, options = {}, reqUser) {
const [actors] = await Promise.all([ const [actors, stashes] = await Promise.all([
knex('actors') knex('actors')
.select( .select(
'actors.*', 'actors.*',
@ -93,10 +95,19 @@ export async function fetchActorsById(actorIds, options = {}) {
builder.orderBy(...options.order); builder.orderBy(...options.order);
} }
}), }),
reqUser
? knex('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actorIds)
: [],
]); ]);
if (options.order) { if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, { append: options.append })); return actors.map((actorEntry) => curateActor(actorEntry, {
stashes: stashes.filter((stash) => stash.actor_id === actorEntry.id),
append: options.append,
}));
} }
const curatedActors = actorIds.map((actorId) => { const curatedActors = actorIds.map((actorId) => {
@ -107,7 +118,10 @@ export async function fetchActorsById(actorIds, options = {}) {
return null; return null;
} }
return curateActor(actor, { append: options.append }); return curateActor(actor, {
stashes: stashes.filter((stash) => stash.actor_id === actor.id),
append: options.append,
});
}).filter(Boolean); }).filter(Boolean);
return curatedActors; return curatedActors;
@ -126,6 +140,30 @@ function curateOptions(options) {
}; };
} }
/*
const sortMap = {
likes: 'stashed',
scenes: 'scenes',
relevance: '_score',
};
function getSort(order) {
if (order[0] === 'name') {
return [{
slug: order[1],
}];
}
return [
{
[sortMap[order[0]]]: order[1],
},
{
slug: 'asc', // sort by name where primary order is equal
},
];
}
function buildQuery(filters) { function buildQuery(filters) {
const query = { const query = {
bool: { bool: {
@ -230,31 +268,7 @@ function buildQuery(filters) {
return { query, expressions }; return { query, expressions };
} }
const sortMap = { async function queryManticoreJson(filters, options) {
likes: 'stashed',
scenes: 'scenes',
relevance: '_score',
};
function getSort(order) {
if (order[0] === 'name') {
return [{
slug: order[1],
}];
}
return [
{
[sortMap[order[0]]]: order[1],
},
{
slug: 'asc', // sort by name where primary order is equal
},
];
}
export async function fetchActors(filters, rawOptions) {
const options = curateOptions(rawOptions);
const { query, expressions } = buildQuery(filters); const { query, expressions } = buildQuery(filters);
const result = await searchApi.search({ const result = await searchApi.search({
@ -279,16 +293,176 @@ export async function fetchActors(filters, rawOptions) {
}, },
}); });
const actorIds = result.hits.hits.map((hit) => Number(hit._id)); const actors = result.hits.hits.map((hit) => ({
id: hit._id,
...hit._source,
_score: hit._score,
}));
return {
actors,
total: result.hits.total,
aggregations: result.aggregations && Object.fromEntries(Object.entries(result.aggregations).map(([key, { buckets }]) => [key, buckets])),
};
}
*/
async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize;
const sqlQuery = knexManticore.raw(`
:query:
OPTION
max_matches=:maxMatches:,
max_query_time=:maxQueryTime:
:countriesFacet:;
show meta;
`, {
query: knexManticore(filters.stashId ? 'actors_stashed' : 'actors')
.modify((builder) => {
if (filters.stashId) {
builder.select(knex.raw(`
actors.id as id,
actors.country as country,
actors.scenes as scenes,
actors.stashed as stashed,
created_at as stashed_at
`));
// weight() as _score
builder
.innerJoin('actors', 'actors.id', 'actors_stashed.actor_id')
.where('stash_id', filters.stashId);
} else {
// builder.select(knex.raw('*, weight() as _score'));
builder.select(knex.raw('*'));
}
if (filters.query) {
builder.whereRaw('match(\'@name :query:\', actors)', { query: filters.query });
}
['gender', 'country'].forEach((attribute) => {
if (filters[attribute]) {
builder.where(attribute, filters[attribute]);
}
});
['age', 'height', 'weight'].forEach((attribute) => {
if (filters[attribute]) {
builder
.where(attribute, '>=', filters[attribute][0])
.where(attribute, '<=', filters[attribute][1]);
}
});
if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') {
builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000));
}
if (filters.dateOfBirth && filters.dobType === 'birthday') {
const month = filters.dateOfBirth.getMonth() + 1;
const day = filters.dateOfBirth.getDate();
builder
.where('month(date_of_birth)', month)
.where('day(date_of_birth)', day);
}
if (filters.cup) {
builder.where(`regex(cup, '^[${filters.cup[0]}-${filters.cup[1]}]')`, 1);
}
if (typeof filters.naturalBoobs === 'boolean') {
builder.where('natural_boobs', filters.naturalBoobs ? 2 : 1); // manticore boolean does not support null, so 0 = null, 1 = false (enhanced), 2 = true (natural)
}
if (filters.requireAvatar) {
builder.where('has_avatar', 1);
}
if (options.order?.[0] === 'name') {
builder.orderBy('actors.slug', options.order[1]);
} else if (options.order?.[0] === 'likes') {
builder.orderBy([
{ column: 'actors.stashed', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order?.[0] === 'scenes') {
builder.orderBy([
{ column: 'actors.scenes', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order?.[0] === 'stashed' && filters.stashId) {
builder.orderBy([
{ column: 'stashed_at', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order) {
builder.orderBy([
{ column: `actors.${options.order[0]}`, order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else {
builder.orderBy('actors.slug', 'asc');
}
})
.limit(options.limit)
.offset((options.page - 1) * options.limit)
.toString(),
// option threads=1 fixes actors, but drastically slows down performance, wait for fix
countriesFacet: options.aggregateActors ? knex.raw('facet actors.country order by count(*) desc limit 300', [aggSize]) : null,
maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime,
}).toString();
// manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around
const curatedSqlQuery = filters.stashId
? sqlQuery
: sqlQuery.replace(/actors\./g, '');
if (process.env.NODE_ENV === 'development') {
console.log(curatedSqlQuery);
}
const results = await utilsApi.sql(curatedSqlQuery);
// console.log(results[0]);
const countries = results
.find((result) => (result.columns[0].actor_ids || result.columns[0]['scenes.country']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.actor_ids || row['scenes.country'], doc_count: row['count(*)'] }))
|| [];
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found').Value);
return {
actors: results[0].data,
total,
aggregations: {
countries,
},
};
}
export async function fetchActors(filters, rawOptions, reqUser) {
const options = curateOptions(rawOptions);
console.log('filters', filters);
console.log('options', options);
const result = await queryManticoreSql(filters, options, reqUser);
const actorIds = result.actors.map((actor) => Number(actor.id));
const [actors, countries] = await Promise.all([ const [actors, countries] = await Promise.all([
fetchActorsById(actorIds), fetchActorsById(actorIds, {}, reqUser),
fetchCountriesByAlpha2(result.aggregations.countries.buckets.map((bucket) => bucket.key)), fetchCountriesByAlpha2(result.aggregations.countries.map((bucket) => bucket.key)),
]); ]);
return { return {
actors, actors,
countries, countries,
total: result.hits.total, total: result.total,
limit: options.limit, limit: options.limit,
}; };
} }

View File

@ -2,7 +2,7 @@ import config from 'config';
import util from 'util'; /* eslint-disable-line no-unused-vars */ import util from 'util'; /* eslint-disable-line no-unused-vars */
import { knexOwner as knex, knexManticore } from './knex.js'; import { knexOwner as knex, knexManticore } from './knex.js';
import { searchApi, utilsApi } from './manticore.js'; import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js'; import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
import { fetchTagsById } from './tags.js'; import { fetchTagsById } from './tags.js';
@ -55,7 +55,10 @@ function curateScene(rawScene, assets) {
type: assets.channel.network_type, type: assets.channel.network_type,
hasLogo: assets.channel.has_logo, hasLogo: assets.channel.has_logo,
} : null, } : null,
actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, { sceneDate: rawScene.effective_date }))), actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, {
sceneDate: rawScene.effective_date,
stashes: assets.actorStashes,
}))),
directors: assets.directors.map((director) => ({ directors: assets.directors.map((director) => ({
id: director.id, id: director.id,
slug: director.slug, slug: director.slug,
@ -74,7 +77,7 @@ function curateScene(rawScene, assets) {
}; };
} }
export async function fetchScenesById(sceneIds, reqUser) { export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
const [scenes, channels, actors, directors, tags, posters, photos, stashes] = await Promise.all([ const [scenes, channels, actors, directors, tags, posters, photos, stashes] = await Promise.all([
knex('releases').whereIn('releases.id', sceneIds), knex('releases').whereIn('releases.id', sceneIds),
knex('releases') knex('releases')
@ -125,6 +128,13 @@ export async function fetchScenesById(sceneIds, reqUser) {
: [], : [],
]); ]);
const actorStashes = reqUser && context.actorStashes
? await knex('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actors.map((actor) => actor.id))
: [];
return sceneIds.map((sceneId) => { return sceneIds.map((sceneId) => {
const scene = scenes.find((sceneEntry) => sceneEntry.id === sceneId); const scene = scenes.find((sceneEntry) => sceneEntry.id === sceneId);
@ -139,6 +149,7 @@ export async function fetchScenesById(sceneIds, reqUser) {
const scenePoster = posters.find((poster) => poster.release_id === sceneId); const scenePoster = posters.find((poster) => poster.release_id === sceneId);
const scenePhotos = photos.filter((photo) => photo.release_id === sceneId); const scenePhotos = photos.filter((photo) => photo.release_id === sceneId);
const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId); const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId);
const sceneActorStashes = sceneActors.map((actor) => actorStashes.find((stash) => stash.actor_id === actor.id)).filter(Boolean);
return curateScene(scene, { return curateScene(scene, {
channel: sceneChannel, channel: sceneChannel,
@ -148,6 +159,7 @@ export async function fetchScenesById(sceneIds, reqUser) {
poster: scenePoster, poster: scenePoster,
photos: scenePhotos, photos: scenePhotos,
stashes: sceneStashes, stashes: sceneStashes,
actorStashes: sceneActorStashes,
}); });
}).filter(Boolean); }).filter(Boolean);
} }
@ -171,6 +183,7 @@ function curateOptions(options) {
}; };
} }
/*
function buildQuery(filters = {}, options) { function buildQuery(filters = {}, options) {
const query = { const query = {
bool: { bool: {
@ -215,23 +228,6 @@ function buildQuery(filters = {}, options) {
} }
if (filters.query) { if (filters.query) {
/*
query.bool.must.push({
bool: {
should: [
{ match: { title_filtered: filters.query } },
{ match: { actors: filters.query } },
{ match: { tags: filters.query } },
{ match: { channel_name: filters.query } },
{ match: { network_name: filters.query } },
{ match: { channel_slug: filters.query } },
{ match: { network_slug: filters.query } },
{ match: { meta: filters.query } }, // date
],
},
});
*/
query.bool.must.push({ match: { '!title': filters.query } }); // title_filtered is matched instead of title query.bool.must.push({ match: { '!title': filters.query } }); // title_filtered is matched instead of title
} }
@ -262,16 +258,6 @@ function buildQuery(filters = {}, options) {
query.bool.must.push({ equals: { stash_id: filters.stashId } }); query.bool.must.push({ equals: { stash_id: filters.stashId } });
} }
/* tag filter
must_not: [
{
in: {
'any(tag_ids)': [101, 180, 32],
},
},
],
*/
return { query, sort }; return { query, sort };
} }
@ -311,14 +297,6 @@ function buildAggregates(options) {
return aggregates; return aggregates;
} }
function countAggregations(buckets) {
if (!buckets) {
return null;
}
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
}
async function queryManticoreJson(filters, options, _reqUser) { async function queryManticoreJson(filters, options, _reqUser) {
const { query, sort } = buildQuery(filters, options); const { query, sort } = buildQuery(filters, options);
@ -357,24 +335,26 @@ async function queryManticoreJson(filters, options, _reqUser) {
aggregations: result.aggregations && Object.fromEntries(Object.entries(result.aggregations).map(([key, { buckets }]) => [key, buckets])), aggregations: result.aggregations && Object.fromEntries(Object.entries(result.aggregations).map(([key, { buckets }]) => [key, buckets])),
}; };
} }
*/
async function queryManticoreSql(filters, options, _reqUser) { async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize; const aggSize = config.database.manticore.maxAggregateSize;
const sqlQuery = knexManticore.raw(` const sqlQuery = knexManticore.raw(`
:query: :query:
OPTION field_weights=( OPTION
title_filtered=7, field_weights=(
actors=10, title_filtered=7,
tags=9, actors=10,
meta=6, tags=9,
channel_name=2, meta=6,
channel_slug=3, channel_name=2,
network_name=1, channel_slug=3,
network_slug=1 network_name=1,
), network_slug=1
max_matches=:maxMatches:, ),
max_query_time=:maxQueryTime: max_matches=:maxMatches:,
max_query_time=:maxQueryTime:
:actorsFacet: :actorsFacet:
:tagsFacet: :tagsFacet:
:channelsFacet:; :channelsFacet:;
@ -391,6 +371,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
scenes.channel_id as channel_id, scenes.channel_id as channel_id,
scenes.network_id as network_id, scenes.network_id as network_id,
scenes.effective_date as effective_date, scenes.effective_date as effective_date,
scenes.stashed as stashed,
scenes.created_at, scenes.created_at,
created_at as stashed_at, created_at as stashed_at,
weight() as _score weight() as _score
@ -481,7 +462,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
const results = await utilsApi.sql(curatedSqlQuery); const results = await utilsApi.sql(curatedSqlQuery);
console.log(results[0]); // console.log(results[0]);
const actorIds = results const actorIds = results
.find((result) => (result.columns[0].actor_ids || result.columns[0]['scenes.actor_ids']) && result.columns[1]['count(*)']) .find((result) => (result.columns[0].actor_ids || result.columns[0]['scenes.actor_ids']) && result.columns[1]['count(*)'])
@ -511,6 +492,14 @@ async function queryManticoreSql(filters, options, _reqUser) {
}; };
} }
function countAggregations(buckets) {
if (!buckets) {
return null;
}
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
}
export async function fetchScenes(filters, rawOptions, reqUser) { export async function fetchScenes(filters, rawOptions, reqUser) {
const options = curateOptions(rawOptions); const options = curateOptions(rawOptions);
@ -527,10 +516,6 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
const result = await queryManticoreSql(filters, options, reqUser); const result = await queryManticoreSql(filters, options, reqUser);
console.timeEnd('manticore sql'); console.timeEnd('manticore sql');
console.time('manticore json');
await queryManticoreJson(filters, options, reqUser);
console.timeEnd('manticore json');
const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds); const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds);
const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds); const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds);
const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds); const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds);
@ -547,7 +532,7 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
console.time('fetch full'); console.time('fetch full');
const sceneIds = result.scenes.map((scene) => Number(scene.id)); const sceneIds = result.scenes.map((scene) => Number(scene.id));
const scenes = await fetchScenesById(sceneIds, reqUser); const scenes = await fetchScenesById(sceneIds, { reqUser });
console.timeEnd('fetch full'); console.timeEnd('fetch full');
return { return {

View File

@ -170,11 +170,25 @@ export async function refreshActorsView() {
export async function stashActor(actorId, stashId, sessionUser) { export async function stashActor(actorId, stashId, sessionUser) {
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
await knex('stashes_actors') const [stashed] = await knex('stashes_actors')
.insert({ .insert({
stash_id: stash.id, stash_id: stash.id,
actor_id: actorId, actor_id: actorId,
}); })
.returning(['id', 'created_at']);
await indexApi.replace({
index: 'actors_stashed',
id: stashed.id,
doc: {
actor_id: actorId,
user_id: sessionUser.id,
stash_id: stashId,
created_at: Math.round(stashed.created_at.getTime() / 1000),
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) stashed actor ${actorId} to stash ${stash.id} (${stash.name})`);
refreshActorsView(); refreshActorsView();
@ -192,6 +206,25 @@ export async function unstashActor(actorId, stashId, sessionUser) {
.where('stashes.user_id', sessionUser.id)) .where('stashes.user_id', sessionUser.id))
.delete(); .delete();
try {
await indexApi.callDelete({
index: 'actors_stashed',
query: {
bool: {
must: [
{ equals: { actor_id: actorId } },
{ equals: { stash_id: stashId } },
{ equals: { user_id: sessionUser.id } },
],
},
},
});
} catch (error) {
console.log(error);
}
logger.verbose(`${sessionUser.username} (${sessionUser.id}) unstashed actor ${actorId} from stash ${stashId}`);
refreshActorsView(); refreshActorsView();
return fetchStashes('actor', actorId, sessionUser); return fetchStashes('actor', actorId, sessionUser);
@ -200,11 +233,26 @@ export async function unstashActor(actorId, stashId, sessionUser) {
export async function stashScene(sceneId, stashId, sessionUser) { export async function stashScene(sceneId, stashId, sessionUser) {
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
await knex('stashes_scenes') const [stashed] = await knex('stashes_scenes')
.insert({ .insert({
stash_id: stash.id, stash_id: stash.id,
scene_id: sceneId, scene_id: sceneId,
}); })
.returning(['id', 'created_at']);
await indexApi.replace({
index: 'scenes_stashed',
id: stashed.id,
doc: {
// ...doc.replace.doc,
scene_id: sceneId,
user_id: sessionUser.id,
stash_id: stashId,
created_at: Math.round(stashed.created_at.getTime() / 1000),
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) stashed scene ${sceneId} to stash ${stash.id} (${stash.name})`);
return fetchStashes('scene', sceneId, sessionUser); return fetchStashes('scene', sceneId, sessionUser);
} }
@ -225,7 +273,7 @@ export async function unstashScene(sceneId, stashId, sessionUser) {
query: { query: {
bool: { bool: {
must: [ must: [
{ equals: { id: sceneId } }, { equals: { scene_id: sceneId } },
{ equals: { stash_id: stashId } }, { equals: { stash_id: stashId } },
{ equals: { user_id: sessionUser.id } }, { equals: { user_id: sessionUser.id } },
], ],
@ -233,6 +281,8 @@ export async function unstashScene(sceneId, stashId, sessionUser) {
}, },
}); });
logger.verbose(`${sessionUser.username} (${sessionUser.id}) unstashed scene ${sceneId} from stash ${stashId}`);
return fetchStashes('scene', sceneId, sessionUser); return fetchStashes('scene', sceneId, sessionUser);
} }

View File

@ -2,7 +2,7 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { indexApi, utilsApi } from '../manticore.js'; import { indexApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js'; import { knexOwner as knex } from '../knex.js';
import slugify from '../utils/slugify.js'; import slugify from '../utils/slugify.js';
@ -105,11 +105,11 @@ async function updateStashed(docs) {
const stashDoc = sceneStashes.map((stash) => ({ const stashDoc = sceneStashes.map((stash) => ({
replace: { replace: {
index: 'movies_liked', index: 'scenes_stashed',
id: stash.stashed_id, id: stash.stashed_id,
doc: { doc: {
// ...doc.replace.doc, // ...doc.replace.doc,
movie_id: doc.replace.id, scene_id: doc.replace.id,
user_id: stash.user_id, user_id: stash.user_id,
}, },
}, },
@ -127,35 +127,6 @@ async function updateStashed(docs) {
} }
async function init() { async function init() {
await utilsApi.sql('drop table if exists movies');
await utilsApi.sql('drop table if exists movies_liked');
await utilsApi.sql(`create table movies (
id int,
title text,
title_filtered text,
channel_id int,
channel_name text,
channel_slug text,
network_id int,
network_name text,
network_slug text,
actor_ids multi,
actors text,
tag_ids multi,
tags text,
meta text,
date timestamp,
created_at timestamp,
effective_date timestamp,
liked int
)`);
await utilsApi.sql(`create table movies_liked (
movie_id int,
user_id int
)`);
const scenes = await fetchScenes(); const scenes = await fetchScenes();
const docs = scenes.map((scene) => { const docs = scenes.map((scene) => {
@ -165,7 +136,7 @@ async function init() {
return { return {
replace: { replace: {
index: 'movies', index: 'scenes',
id: scene.id, id: scene.id,
doc: { doc: {
title: scene.title || undefined, title: scene.title || undefined,

51
src/tools/sync-stashes.js Normal file
View File

@ -0,0 +1,51 @@
import { indexApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js';
import chunk from '../utils/chunk.js';
async function syncActorStashes() {
const stashes = await knex('stashes_actors')
.select(
'stashes_actors.id as stashed_id',
'stashes_actors.actor_id',
'stashes.id as stash_id',
'stashes.user_id as user_id',
'stashes_actors.created_at as created_at',
)
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id');
if (stashes.length > 0) {
console.log(stashes);
}
await chunk(stashes, 1000).reduce(async (chain, stashChunk, index) => {
await chain;
const stashDocs = stashChunk.map((stash) => ({
replace: {
index: 'actors_stashed',
id: stash.stashed_id,
doc: {
actor_id: stash.actor_id,
stash_id: stash.stash_id,
user_id: stash.user_id,
created_at: Math.round(stash.created_at.getTime() / 1000),
},
},
}));
console.log(stashDocs);
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
console.log(`Synced ${index * 1000 + stashChunk.length}/${stashes.length} actor stashes`);
}, Promise.resolve());
}
async function init() {
await syncActorStashes();
console.log('Done!');
knex.destroy();
}
init();

View File

@ -13,6 +13,7 @@ export function curateActorsQuery(query) {
height: query.height?.split(',').map((height) => Number(height)), height: query.height?.split(',').map((height) => Number(height)),
weight: query.weight?.split(',').map((weight) => Number(weight)), weight: query.weight?.split(',').map((weight) => Number(weight)),
requireAvatar: query.avatar, requireAvatar: query.avatar,
stashId: Number(query.stashId) || null,
}; };
} }
@ -26,7 +27,7 @@ export async function fetchActorsApi(req, res) {
page: Number(req.query.page) || 1, page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 120, limit: Number(req.query.limit) || 120,
order: req.query.order?.split('.') || ['likes', 'desc'], order: req.query.order?.split('.') || ['likes', 'desc'],
}); }, req.user);
res.send({ res.send({
actors, actors,

View File

@ -11,7 +11,7 @@ export async function curateScenesQuery(query) {
actorIds: [query.actorId, ...(query.actors?.split(',') || []).map((identifier) => parseActorIdentifier(identifier)?.id)].filter(Boolean), actorIds: [query.actorId, ...(query.actors?.split(',') || []).map((identifier) => parseActorIdentifier(identifier)?.id)].filter(Boolean),
tagIds: await getIdsBySlug([query.tagSlug, ...(query.tags?.split(',') || [])], 'tags'), tagIds: await getIdsBySlug([query.tagSlug, ...(query.tags?.split(',') || [])], 'tags'),
entityId: query.e ? await getIdsBySlug([query.e], 'entities').then(([id]) => id) : query.entityId, entityId: query.e ? await getIdsBySlug([query.e], 'entities').then(([id]) => id) : query.entityId,
stashId: Number(query.stashId), stashId: Number(query.stashId) || null,
}; };
} }

View File

@ -17,37 +17,37 @@ export async function createStashApi(req, res) {
} }
export async function updateStashApi(req, res) { export async function updateStashApi(req, res) {
const stash = await updateStash(req.params.stashId, req.body, req.session.user); const stash = await updateStash(Number(req.params.stashId), req.body, req.session.user);
res.send(stash); res.send(stash);
} }
export async function removeStashApi(req, res) { export async function removeStashApi(req, res) {
await removeStash(req.params.stashId, req.session.user); await removeStash(Number(req.params.stashId), req.session.user);
res.status(204).send(); res.status(204).send();
} }
export async function stashActorApi(req, res) { export async function stashActorApi(req, res) {
const stashes = await stashActor(req.body.actorId, req.params.stashId, req.user); const stashes = await stashActor(req.body.actorId, Number(req.params.stashId), req.user);
res.send(stashes); res.send(stashes);
} }
export async function stashSceneApi(req, res) { export async function stashSceneApi(req, res) {
const stashes = await stashScene(req.body.sceneId, req.params.stashId, req.user); const stashes = await stashScene(req.body.sceneId, Number(req.params.stashId), req.user);
res.send(stashes); res.send(stashes);
} }
export async function stashMovieApi(req, res) { export async function stashMovieApi(req, res) {
const stashes = await stashMovie(req.body.movieId, req.params.stashId, req.user); const stashes = await stashMovie(req.body.movieId, Number(req.params.stashId), req.user);
res.send(stashes); res.send(stashes);
} }
export async function unstashActorApi(req, res) { export async function unstashActorApi(req, res) {
const stashes = await unstashActor(req.params.actorId, req.params.stashId, req.user); const stashes = await unstashActor(Number(req.params.actorId), Number(req.params.stashId), req.user);
res.send(stashes); res.send(stashes);
} }
@ -59,7 +59,7 @@ export async function unstashSceneApi(req, res) {
} }
export async function unstashMovieApi(req, res) { export async function unstashMovieApi(req, res) {
const stashes = await unstashMovie(req.params.movieId, req.params.stashId, req.user); const stashes = await unstashMovie(Number(req.params.movieId), Number(req.params.stashId), req.user);
res.send(stashes); res.send(stashes);
} }

11
utils/ellipsis.js Normal file
View File

@ -0,0 +1,11 @@
export default function ellipsis(text, limit = 50, ellipse = '...') {
if (!text) {
return '';
}
if (text.length > limit) {
return `${text.slice(0, limit - ellipse.length)}${ellipse}`;
}
return text;
}