Removed some obsolete client components. Added ASG Max with affiliates.

This commit is contained in:
DebaucheryLibrarian 2026-01-30 05:25:35 +01:00
parent 5042f8fb40
commit 91771c9ef4
155 changed files with 982 additions and 22880 deletions

View File

@ -1,10 +1,13 @@
{
"root": true,
"extends": ["airbnb-base", "plugin:vue/recommended"],
"extends": ["airbnb-base"],
"env": {
"node": true
},
"parserOptions": {
"parser": "@babel/eslint-parser",
"ecmaVersion": 2019,
"sourceType": "module"
"ecmaVersion": "latest",
"sourceType": "script"
},
"rules": {
"indent": ["error", "tab"],
@ -17,15 +20,14 @@
"max-len": 0,
"func-names": 0,
"space-before-function-paren": 0,
"vue/no-v-html": 0,
"vue/html-indent": ["error", "tab"],
"vue/multiline-html-element-content-newline": 0,
"vue/singleline-html-element-content-newline": 0,
"vue/multi-word-component-names": 0,
"vue/no-reserved-component-names": 0,
"strict": 0,
"no-underscore-dangle": 0,
"prefer-destructuring": "off",
"object-curly-newline": "off",
"require-await": "off",
"no-param-reassign": ["error", {
"props": true,
"ignorePropertyModificationsFor": ["state", "acc"]
"ignorePropertyModificationsFor": ["state", "acc", "req"]
}]
},
"globals": {

View File

@ -1,883 +0,0 @@
<template>
<div
v-if="actor"
class="content actor"
>
<div class="actor-header">
<h2 class="header-name">
<span v-if="actor.entity">{{ actor.name }} ({{ actor.entity.name }})</span>
<span v-else>{{ actor.name }}</span>
<Gender
:gender="actor.gender"
class="header-gender"
/>
</h2>
<li
v-if="actor.aliases.length"
class="bio-item"
>
<dfn class="bio-label">Also known as</dfn>
<span class="bio-value">
<span
v-for="alias in actor.aliases"
:key="`alias-${alias.id}`"
class="alias"
>{{ alias.name }}</span>
</span>
</li>
<Social
v-if="actor.social && actor.social.length > 0"
:actor="actor"
class="header-social"
/>
<StashButton
:stashed-by="stashedBy"
class="actor-stash light"
@stash="(stash) => stashActor(stash)"
@unstash="(stash) => unstashActor(stash)"
/>
</div>
<div
class="content-inner actor-inner"
@scroll="events.emit('scroll', $event)"
>
<div
class="profile"
:class="{ expanded: bioExpanded, 'with-avatar': !!actor.avatar }"
>
<a
v-if="actor.avatar"
:href="getPath(actor.avatar)"
target="_blank"
rel="noopener noreferrer"
class="avatar-link"
>
<img
:src="getPath(actor.avatar, 'thumbnail')"
:title="actor.avatar.credit && `© ${actor.avatar.credit}`"
class="avatar"
>
</a>
<ul class="bio nolist">
<!-- probably not a good idea
<li
v-if="actor.realName"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="vcard" />Real name</dfn>
<span class="bio-value">{{ actor.realName }}</span>
</li>
-->
<li
v-if="actor.dateOfBirth"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="cake" />Date of birth</dfn>
<span class="birthdate">{{ formatDate(actor.dateOfBirth, 'MMMM D, YYYY') }}<span
v-if="!actor.dateOfDeath"
class="age"
>{{ actor.ageFromBirth }}</span></span>
</li>
<li
v-else-if="actor.age && !actor.dateOfDeath"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="cake" />Age</dfn>
<span
v-tooltip="'Exact date of birth or age unknown'"
class="birthdate"
>&gt; {{ actor.age }}</span>
</li>
<li
v-if="actor.dateOfDeath"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="tombstone" />Date of death</dfn>
<span class="birthdate">{{ formatDate(actor.dateOfDeath, 'MMMM D, YYYY') }}<span
v-if="actor.ageAtDeath"
class="age"
>{{ actor.ageAtDeath }}</span></span>
</li>
<li
v-if="actor.orientation"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="heart7" />Orientation</dfn>
<span class="orientation">{{ actor.orientation }}</span>
</li>
<li
v-if="actor.origin"
class="bio-item birth"
>
<dfn class="bio-label"><Icon icon="home2" />Born in</dfn>
<span>
<span
v-if="actor.origin.city"
class="city"
>{{ actor.origin.city }}</span><span
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
class="state"
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span>
<span
v-if="actor.origin.country"
class="country birthcountry"
>
<img
class="flag"
:src="`/img/flags/${actor.origin.country.alpha2.toLowerCase()}.svg`"
>{{ actor.origin.country.alias || actor.origin.country.name }}
</span>
</span>
</li>
<li
v-if="actor.residence"
class="bio-item residence hideable"
>
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
<span>
<span
v-if="actor.residence.city"
class="city"
>{{ actor.residence.city }}</span><span
v-if="actor.residence.state && actor.residence.country && actor.residence.country.alpha2 === 'US'"
class="state"
>{{ actor.residence.city ? `, ${actor.residence.state}` : actor.residence.state }}</span>
<span
v-if="actor.residence.country"
class="country"
>
<img
class="flag"
:src="`/img/flags/${actor.residence.country.alpha2.toLowerCase()}.svg`"
>{{ actor.residence.country.alias || actor.residence.country.name }}
</span>
</span>
</li>
<li
v-if="actor.ethnicity"
class="bio-item ethnicity hideable"
>
<dfn class="bio-label"><Icon icon="earth2" />Ethnicity</dfn>
<span>{{ actor.ethnicity }}</span>
</li>
<li
v-if="actor.bust || actor.waist || actor.hip"
title="bust-waist-hip"
class="bio-item figure"
>
<dfn class="bio-label"><Icon icon="ruler" />Figure</dfn>
<span class="bio-value">
<Icon
v-if="actor.naturalBoobs === false"
v-tooltip="'Enhanced boobs'"
icon="magic-wand2"
class="enhanced"
/>{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
</span>
</li>
<li
v-if="actor.penisLength || actor.penisGirth || actor.circumcised"
class="bio-item penis"
>
<dfn class="bio-label"><Icon icon="pencil-ruler" />Dick</dfn>
<span>
<Icon
v-if="actor.circumcised"
v-tooltip="'Circumcised'"
icon="page-break"
class="circumcised"
/>
<template v-if="actor.penisLengthMetric && actor.penisGirthMetric">
<span>{{ actor.penisLengthMetric }} * {{ actor.penisGirthMetric }} cm</span>
<span class="bio-segment">{{ actor.penisLengthImperial }}" * {{ actor.penisGirthImperial }}"</span>
</template>
<template v-else-if="actor.penisLengthMetric">
<span>{{ actor.penisLengthMetric }} cm</span>
<span class="bio-segment">{{ actor.penisLengthImperial }}"</span>
</template>
</span>
</li>
<li
v-if="actor.height"
class="bio-item height"
>
<dfn class="bio-label"><Icon icon="height" />Height</dfn>
<span>
<span class="height-metric">{{ actor.height.metric }} cm</span>
<span class="height-imperial">{{ actor.height.imperial }}</span>
</span>
</li>
<li
v-if="actor.weight"
class="bio-item weight hideable"
>
<dfn class="bio-label"><Icon icon="scale" />Weight</dfn>
<span>
<span class="weight-metric">{{ actor.weight.metric }} kg</span>
<span class="weight-imperial">{{ actor.weight.imperial }} lbs</span>
</span>
</li>
<li
v-if="actor.eyes"
class="bio-item eyes hideable"
>
<dfn class="bio-label"><Icon icon="eye" />Eyes</dfn>
<span>{{ actor.eyes }}</span>
</li>
<li
v-if="actor.hairColor"
class="bio-item hair hideable"
>
<dfn class="bio-label"><Icon icon="haircut" />Hair</dfn>
<span><span v-if="actor.hairLength">{{ actor.hairLength }}, </span>{{ actor.hairColor }}</span>
</li>
<li
v-if="actor.hasTattoos"
class="bio-item tattoos hideable"
>
<dfn class="bio-label"><Icon icon="lotus" />Tattoos</dfn>
<span
v-if="actor.tattoos"
v-tooltip="actor.tattoos"
class="bio-value"
>{{ actor.tattoos }}</span>
<span v-else>Yes</span>
</li>
<li
v-if="actor.hasPiercings"
class="bio-item piercings hideable"
>
<dfn class="bio-label"><Icon icon="trophy4" />Piercings</dfn>
<span
v-if="actor.piercings"
v-tooltip="actor.piercings"
class="bio-value"
>{{ actor.piercings }}</span>
<span v-else>Yes</span>
</li>
<li class="bio-item scraped hideable">Updated {{ formatDate(actor.updatedAt, 'YYYY-MM-DD HH:mm') }}, ID: {{ actor.id }}</li>
</ul>
<div class="descriptions-container">
<div
v-if="actor.descriptions && actor.descriptions.length > 0"
class="descriptions"
>
<p
v-for="description in actor.descriptions"
:key="`description-${description.entity.id}`"
class="description"
>
{{ description.text }}
<RouterLink :to="`/${description.entity.type}/${description.entity.slug}`">
<img
v-if="description.entity.type === 'network' || !description.entity.parent || description.entity.independent"
:src="`/img/logos/${description.entity.slug}/thumbs/network.png`"
class="description-logo"
>
<img
v-else
:src="`/img/logos/${description.entity.parent.slug}/thumbs/${description.entity.slug}.png`"
class="description-logo"
>
</RouterLink>
</p>
</div>
</div>
<Social
v-if="actor.social && actor.social.length > 0"
:actor="actor"
class="profile-social"
/>
<Expand
:expanded="bioExpanded"
class="expand expand-light"
@expand="(state) => bioExpanded = state"
/>
</div>
<Album
v-if="showAlbum"
:items="[actor.avatar, ...actor.photos]"
:title="actor.name"
:portrait="true"
:comments="true"
@close="$router.replace({ hash: undefined })"
/>
<div class="actor-content">
<Scroll
v-if="actor.avatar || (actor.photos && actor.photos.length > 0)"
v-slot="scroll"
>
<Photos
:actor="actor"
:class="{ expanded: photosExpanded }"
@load="scroll.loaded"
/>
</Scroll>
<button
v-if="actor.photos && actor.photos.length > 2"
class="album-toggle"
@click="$router.push({ hash: '#album' })"
><Icon icon="grid3" />View album</button>
<FilterBar
ref="filter"
:fetch-releases="fetchActor"
:items-total="totalCount"
:items-per-page="limit"
:available-tags="actor.tags"
:available-channels="actor.channels"
:available-actors="actor.actors"
/>
<Releases
:releases="releases"
:done="done"
/>
<Pagination
:items-total="totalCount"
:items-per-page="limit"
name="actorRange"
class="pagination-top"
/>
</div>
<Footer />
</div>
</div>
</template>
<script>
import config from 'config';
import Pagination from '../pagination/pagination.vue';
import FilterBar from '../filters/filter-bar.vue';
import Releases from '../releases/releases.vue';
import Photos from './photos.vue';
import Album from '../album/album.vue';
import Expand from '../expand/expand.vue';
import Scroll from '../scroll/scroll.vue';
import Gender from './gender.vue';
import Social from './social.vue';
import StashButton from '../stashes/button.vue';
async function fetchActor(scroll = true) {
this.done = false;
const { actor, releases, totalCount } = await this.$store.dispatch('fetchActorById', {
actorId: Number(this.$route.params.actorId),
limit: this.limit,
pageNumber: Number(this.$route.params.pageNumber),
range: this.$route.params.range,
});
this.actor = actor;
this.releases = releases;
this.totalCount = totalCount;
this.stashedBy = actor.stashes;
this.done = true;
if (this.$refs.filter && scroll) {
this.$refs.filter.$el.scrollIntoView();
}
}
async function stashActor(stashId) {
this.stashedBy = await this.$store.dispatch('stashActor', {
actorId: this.actor.id,
stashId,
});
}
async function unstashActor(stashId) {
this.stashedBy = await this.$store.dispatch('unstashActor', {
actorId: this.actor.id,
stashId,
});
}
function me() {
return this.$store.state.auth.user;
}
function sfw() {
return this.$store.state.ui.sfw;
}
function showAlbum() {
return this.actor.photos?.length > 0 && this.$route.hash === '#album';
}
async function watchRoute(to, from) {
if ((to.name === 'actor' || to.name === 'actorRange') && to.hash !== '#album' && from.hash !== '#album') {
await this.fetchActor();
}
}
async function mounted() {
await this.fetchActor();
if (this.actor) {
this.pageTitle = this.actor.name;
}
}
export default {
components: {
Album,
FilterBar,
Pagination,
Photos,
Scroll,
Expand,
Releases,
Gender,
Social,
StashButton,
},
data() {
return {
actor: null,
releases: null,
done: false,
totalCount: 0,
limit: Number(this.$route.query.limit || 30) - config.campaigns.tiles, // reserve one campaign spot
pageTitle: null,
bioExpanded: false,
photosExpanded: false,
stashed: false,
stashedBy: [],
};
},
computed: {
me,
sfw,
showAlbum,
},
watch: {
$route: watchRoute,
'$store.state.ui.tagFilter': fetchActor,
},
mounted,
methods: {
fetchActor,
stashActor,
unstashActor,
},
};
</script>
<style lang="scss">
.header-gender .icon {
width: 1.25rem;
height: 1.25rem;
}
</style>
<style lang="scss" scoped>
@import 'theme';
.actor-header {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--lighten-extreme);
background: var(--profile);
}
.actor-stash {
margin: 0 1rem 0 0;
}
.header-name {
padding: .5rem 1rem;
margin: 0;
display: inline-flex;
justify-content: space-between;
flex-shrink: 0;
}
.header-gender {
display: inline-block;
margin: 0 0 0 .5rem;
transform: translate(0, .125rem);
}
.header-social {
overflow: hidden;
white-space: nowrap;
margin: 0 1rem 0 0;
}
.profile {
background: var(--profile);
color: var(--lighten-extreme);
width: 100%;
max-height: 18rem;
display: flex;
flex-direction: row;
flex-shrink: 0;
&.with-avatar {
height: 18rem; /* profile overlaps avatar in chrome */
}
.avatar-link {
padding: 0 0 1rem 1rem;
flex-shrink: 0;
}
.avatar {
height: 100%;
flex-shrink: 0;
border: solid 3px var(--lighten-hint);
margin: 0 .5rem 0 0;
}
}
.bio {
flex-grow: 1;
height: 100%;
display: flex;
flex-direction: column;
flex-wrap: wrap;
box-sizing: border-box;
overflow: hidden;
}
.bio-header {
width: calc(50% - 2rem);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 .5rem .5rem 0;
margin: 0 0 0 1rem;
}
.bio-item {
width: calc(50% - 4rem);
display: flex;
justify-content: space-between;
box-sizing: border-box;
padding: .25rem 0 ;
margin: 0 0 .25rem 1rem;
line-height: 1.75;
text-align: right;
font-size: .9rem;
font-weight: 600;
overflow: hidden;
&:not(:last-of-type) {
border-bottom: solid 1px var(--lighten-hint);
}
}
.bio-label,
.bio-value {
display: flex;
align-items: center;
}
.bio-label {
color: var(--lighten);
margin: 0 1rem 0 0;
flex-shrink: 0;
font-style: normal;
font-weight: 400;
.icon {
fill: var(--lighten);
margin: -.25rem .5rem 0 0;
}
}
.bio-value {
margin: 0 0 0 2rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.icon {
margin: -.25rem 0 0 0;
}
}
.flag {
height: 1rem;
margin: .25rem .25rem 0 0;
}
.bio-name {
display: inline-block;
padding: 0;
margin: 0;
}
.birthdate {
display: block;
}
.age {
font-weight: bold;
padding: 0 0 0 .5rem;
border-left: solid 1px var(--lighten-weak);
margin: 0 0 0 .5rem;
}
.country {
display: flex;
justify-content: flex-end;
}
.figure .bio-label .icon {
margin: -.5rem .5rem 0 0;
}
.height-imperial,
.weight-imperial,
.penis-girth-imperial,
.penis-length-imperial,
.bio-segment {
padding: 0 0 0 .5rem;
border-left: solid 1px var(--lighten-weak);
margin: 0 0 0 .5rem;
}
.enhanced.icon,
.circumcised.icon {
fill: var(--primary);
padding: 0 .5rem;
}
.enhanced.icon {
transform: scaleX(-1);
}
.ethnicity,
.hair,
.eyes,
.orientation {
text-transform: capitalize;
}
.alias:not(:last-child)::after {
content: ',\00a0';
}
.scraped {
color: var(--lighten-weak);
font-size: .8rem;
}
.descriptions-container {
max-width: 30rem;
max-height: 100%;
position: relative;
display: block;
flex-grow: 1;
box-sizing: border-box;
overflow: hidden;
&::after {
content: '';
width: 100%;
height: 1.5rem;
position: absolute;
bottom: 0;
background: linear-gradient(transparent, 25%, var(--profile) 75%);
pointer-events: none;
}
}
.descriptions {
height: 100%;
overflow: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.description {
margin: 0;
padding: 0 1rem;
border-left: solid 3px var(--lighten-hint);
line-height: 1.5;
font-size: .9rem;
}
.description-logo {
display: block;
width: 12rem;
max-height: 1.5rem;
margin: .5rem 0 1.5rem 0;
object-fit: contain;
object-position: 0 50%;
}
.actor-content {
display: flex;
flex-grow: 1;
flex-direction: column;
background: var(--background-soft);
}
.heading {
padding: 0;
margin: 0 0 1rem 0;
}
.profile-social {
display: none;
}
.expand {
display: none;
}
.scroll {
background: var(--background-dim);
border-bottom: solid 1px var(--shadow-hint);
}
.stash.icon {
width: 1.5rem;
height: 1.5rem;
padding: 0 1rem;
fill: var(--lighten);
&.stashed {
fill: var(--primary);
}
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
@media(max-width: $breakpoint4) {
.descriptions-container {
display: none;
}
}
@media(max-width: $breakpoint3) {
.profile .avatar-link {
display: none;
}
.actor-content {
flex-direction: column;
}
}
@media(max-width: $breakpoint) {
.profile {
height: auto;
max-height: none;
flex-direction: column;
&.with-avatar {
height: auto;
max-height: none;
}
&:not(.expanded) .hideable {
display: none;
}
}
.bio {
width: 100%;
height: auto;
padding: 0 1rem;
margin: 0;
}
.bio-item {
width: 100%;
margin: 0;
}
.expanded .bio-value {
white-space: normal;
}
.expand {
display: block;
}
.actor-stash {
margin: 0 .5rem 0 0;
}
}
@media(max-width: $breakpoint0) {
.header-social {
display: none;
}
.expanded .profile-social {
display: block;
margin: 1rem 0 0 0;
}
.header-name {
flex-grow: 1;
font-size: 1.3rem;
padding: .5rem .5rem .5rem 1rem;
}
.stash.icon {
width: 1.25rem;
height: 1.25rem;
padding: 0 1rem 0 .25rem;
transform: translate(0, -.1rem);
}
}
</style>

View File

@ -1,770 +0,0 @@
<template>
<div
ref="content"
class="actors"
>
<nav
ref="filters"
class="filters"
>
<div class="filters-row">
<ul class="genders nolist">
<li class="gender">
<router-link
:to="{ name: 'actors', params: { gender: 'all', pageNumber: 1 }, query: $route.query }"
:class="{ selected: gender === 'all' }"
class="gender-link all"
>all</router-link>
</li>
<li class="gender">
<router-link
:to="{ name: 'actors', params: { gender: 'female', pageNumber: 1 }, query: $route.query }"
:class="{ selected: gender === 'female' }"
class="gender-link female"
><Gender gender="female" /></router-link>
</li>
<li class="gender">
<router-link
:to="{ name: 'actors', params: { gender: 'male', pageNumber: 1 }, query: $route.query }"
:class="{ selected: gender === 'male' }"
class="gender-link male"
replace
><Gender gender="male" /></router-link>
</li>
<li class="gender">
<router-link
:to="{ name: 'actors', params: { gender: 'trans', pageNumber: 1 }, query: $route.query }"
:class="{ selected: gender === 'trans' }"
class="gender-link transsexual"
replace
><Gender gender="transsexual" /></router-link>
</li>
<li class="gender">
<router-link
:to="{ name: 'actors', params: { gender: 'other', pageNumber: 1 }, query: $route.query }"
:class="{ selected: gender === 'other' }"
class="gender-link other"
replace
><Icon icon="question5" /></router-link>
</li>
</ul>
<ul class="filters-attributes nolist">
<li>
<Tooltip class="filter boobs">
<span
class="filter-trigger"
:class="{ enabled: ageRequired }"
><Icon icon="vcard" />Age</span>
<template v-slot:tooltip>
<RangeFilter
label="age"
:min="18"
:max="100"
:value="age"
:disabled="!ageRequired"
@enable="(checked) => updateValue('ageRequired', checked, true)"
@input="(range) => updateValue('age', range, false)"
@change="(range) => updateValue('age', range, true)"
>
<template v-slot:start><Icon icon="leaf" /></template>
<template v-slot:end><Icon icon="tree3" /></template>
</RangeFilter>
<div class="filter-section">
<label class="filter-label">
<span class="label">
<Checkbox
:checked="dobRequired"
class="checkbox"
@change="(checked) => updateValue('dobRequired', checked, true)"
/>Date of birth
</span>
</label>
<div
class="input-container"
@click="() => updateValue('dobRequired', true, true)"
>
<input
v-model="dob"
:disabled="!dobRequired"
type="date"
class="input"
@change="updateFilters"
>
</div>
</div>
</template>
</Tooltip>
</li>
<li>
<Tooltip class="filter">
<span
class="filter-trigger boobs"
:class="{ enabled: boobSizeRequired || naturalBoobs !== 1 }"
><Icon icon="boobs" />Boobs</span>
<template v-slot:tooltip>
<RangeFilter
label="size"
:min="0"
:max="boobSizes.length - 1"
:value="boobSize"
:values="boobSizes"
:disabled="!boobSizeRequired"
@enable="(checked) => updateValue('boobSizeRequired', checked, true)"
@input="(range) => updateValue('boobSize', range, false)"
@change="(range) => updateValue('boobSize', range, true)"
>
<template v-slot:start><Icon icon="boobs-small" /></template>
<template v-slot:end><Icon icon="boobs-big" /></template>
</RangeFilter>
<div class="filter-section">
<span class="filter-label">Enhanced</span>
<span
:class="{ [['off', 'default', 'on'][naturalBoobs]]: true }"
class="toggle-container noclick"
>
<span
class="toggle-label off"
@click="updateValue('naturalBoobs', 0)"
><Icon icon="leaf" /></span>
<input
v-model.number="naturalBoobs"
class="toggle"
type="range"
min="0"
max="2"
@change="updateFilters"
>
<span
class="toggle-label on"
@click="updateValue('naturalBoobs', 2)"
><Icon icon="magic-wand2" /></span>
</span>
</div>
</template>
</Tooltip>
</li>
<li>
<Tooltip class="filter boobs">
<span
class="filter-trigger"
:class="{ enabled: heightRequired || weightRequired }"
><Icon icon="rulers" />Physique</span>
<template v-slot:tooltip>
<RangeFilter
label="height"
:min="50"
:max="220"
:value="height"
:disabled="!heightRequired"
unit="cm"
@enable="(checked) => updateValue('heightRequired', checked, true)"
@input="(range) => updateValue('height', range, false)"
@change="(range) => updateValue('height', range, true)"
>
<template v-slot:start><Icon icon="height-short" /></template>
<template v-slot:end><Icon icon="height" /></template>
</RangeFilter>
<RangeFilter
label="weight"
:min="30"
:max="200"
:value="weight"
:disabled="!weightRequired"
unit="kg"
@enable="(checked) => updateValue('weightRequired', checked, true)"
@input="(range) => updateValue('weight', range, false)"
@change="(range) => updateValue('weight', range, true)"
>
<template v-slot:start><Icon icon="meter-slow" /></template>
<template v-slot:end><Icon icon="meter-fast" /></template>
</RangeFilter>
</template>
</Tooltip>
</li>
<li>
<Tooltip class="filter">
<span
:class="{ enabled: country }"
class="filter-trigger"
><img
v-if="$route.query.c"
:src="`/img/flags/${$route.query.c.toLowerCase()}.svg`"
class="flag"
><Icon
v-else
icon="earth2"
/>Country</span>
<template v-slot:tooltip>
<input
v-model="countryQuery"
placeholder="Search"
class="input"
>
<Countries
v-if="!countryQuery"
:countries="topCountries"
:selected-country="country"
:update-value="updateValue"
/>
<Countries
:countries="filteredCountries"
:selected-country="country"
:update-value="updateValue"
/>
</template>
</Tooltip>
</li>
</ul>
</div>
<SearchBar :placeholder="`Search ${totalCount} actors`" />
</nav>
<div
ref="tiles"
class="tiles"
>
<Actor
v-for="actor in actors"
:key="`actor-${actor.id}`"
:actor="actor"
/>
</div>
<Pagination
v-if="totalCount > 0"
:items-total="totalCount"
:items-per-page="limit"
class="pagination-bottom"
/>
<Footer />
</div>
</template>
<script>
import dayjs from 'dayjs';
import Actor from './tile.vue';
import Gender from './gender.vue';
import Checkbox from '../form/checkbox.vue';
import Countries from './countries.vue';
import RangeFilter from './filter-range.vue';
import SearchBar from '../search/bar.vue';
import Pagination from '../pagination/pagination.vue';
const toggleValues = [true, null, false];
const boobSizes = 'ABCDEFGHIJKZ'.split('');
const topCountries = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
function updateFilters() {
this.$router.push({
name: 'actors',
params: {
pageNumber: 1,
gender: this.gender,
},
query: {
nb: this.naturalBoobs !== 1 ? this.naturalBoobs : undefined,
bs: this.boobSizeRequired ? this.boobSize.join(',') : undefined,
h: this.heightRequired ? this.height.join(',') : undefined,
w: this.weightRequired ? this.weight.join(',') : undefined,
c: this.country ? this.country : undefined,
age: this.ageRequired ? this.age.join(',') : undefined,
dob: this.dobRequired ? this.dob : undefined,
query: this.$route.query.query,
},
});
}
function updateValue(prop, value, load = true) {
this[prop] = value;
if (load) {
this.updateFilters();
}
}
async function fetchActors(scroll) {
const curatedGender = this.gender.replace('trans', 'transsexual');
const { actors, countries, totalCount } = await this.$store.dispatch('fetchActors', {
limit: this.limit,
pageNumber: Number(this.$route.params.pageNumber) || 1,
query: this.$route.query.query,
gender: curatedGender === 'other' ? null : curatedGender,
age: this.ageRequired && this.age,
dob: this.dobRequired && this.dob,
boobSize: this.boobSizeRequired && this.boobSize,
country: this.country,
naturalBoobs: toggleValues[this.naturalBoobs] ?? null,
height: this.heightRequired && this.height,
weight: this.weightRequired && this.weight,
});
const countriesByAlpha2 = countries.reduce((acc, country) => ({ ...acc, [country.alpha2]: country }), {});
this.actors = actors;
this.totalCount = totalCount;
this.countries = countries;
this.topCountries = [...(this.country && !topCountries.includes(this.country) ? [this.country] : []), ...topCountries].map(alpha2 => countriesByAlpha2[alpha2]);
if (scroll) {
this.$refs.tiles?.scrollIntoView();
}
}
function filteredCountries() {
const countryQueryExpression = new RegExp(this.countryQuery, 'i');
return this.countryQuery?.length > 0
? this.countries.filter(country => countryQueryExpression.test(country.name) || countryQueryExpression.test(country.alpha2))
: this.countries;
}
function gender() {
return this.$route.params.gender || 'all';
}
async function route(to, from) {
const scroll = to.params.pageNumber !== from.params.pageNumber
|| to.params.gender !== from.params.gender;
await this.fetchActors(scroll);
}
async function mounted() {
this.pageTitle = 'Actors';
await this.fetchActors();
}
export default {
components: {
Actor,
Checkbox,
Countries,
Gender,
RangeFilter,
SearchBar,
Pagination,
},
data() {
return {
actors: [],
countries: [],
topCountries: [],
countryQuery: null,
pageTitle: null,
totalCount: 0,
limit: 50,
age: this.$route.query.age?.split(',') || [18, 100],
ageRequired: !!this.$route.query.age,
dob: this.$route.query.dob || dayjs().subtract(21, 'years').format('YYYY-MM-DD'),
dobRequired: !!this.$route.query.dob,
boobSizes,
boobSize: this.$route.query.bs?.split(',') || ['A', 'Z'],
boobSizeRequired: !!this.$route.query.bs,
country: this.$route.query.c || null,
naturalBoobs: Number(this.$route.query.nb) || 1,
height: this.$route.query.h?.split(',').map(Number) || [50, 220],
heightRequired: !!this.$route.query.h,
weight: this.$route.query.w?.split(',').map(Number) || [30, 200],
weightRequired: !!this.$route.query.w,
};
},
computed: {
gender,
filteredCountries,
},
watch: {
$route: route,
},
mounted,
methods: {
fetchActors,
updateFilters,
updateValue,
},
};
</script>
<style lang="scss">
.gender-link {
&.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 lang="scss" scoped>
@import 'breakpoints';
.actors {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
grid-template-rows: min-content;
grid-gap: .5rem;
padding: 1rem;
flex-grow: 1;
}
.search {
width: 0;
justify-content: flex-end;
flex-grow: 1;
box-sizing: border-box;
padding: 0 1rem;
}
.filters,
.filters-row {
display: flex;
justify-content: flex-end;
align-items: center;
}
.filters {
margin: 1rem 0 .5rem 0;
}
.filters-row,
.filter {
padding: 0 1rem;
}
.genders {
display: flex;
flex-shrink: 0;
padding: 0 .5rem 0 0;
}
.gender {
display: inline-block;
}
.gender-link {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
margin: .25rem .5rem .25rem 0;
color: var(--shadow);
background: var(--background);
font-weight: bold;
text-decoration: none;
box-shadow: 0 0 3px var(--darken-weak);
.male,
.female,
.transsexual {
padding: .2rem 0 0 0;
}
.icon {
fill: var(--shadow);
}
&:hover {
color: var(--text);
cursor: pointer;
.icon {
fill: var(--text);
}
}
&.selected {
background: var(--primary);
color: var(--text-light);
&.other .icon {
fill: var(--text-light);
}
}
}
.filter-trigger {
display: inline-flex;
align-items: center;
color: var(--shadow);
font-weight: bold;
.icon,
.flag {
fill: var(--shadow);
width: 1rem;
height: 1rem;
margin: -.1rem .75rem 0 0;
}
&:hover {
color: var(--shadow-strong);
cursor: pointer;
.icon {
fill: var(--shadow-strong);
}
}
&.enabled {
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.label-values {
font-weight: normal;
}
.filter-split {
display: flex;
align-items: center;
}
.filter-label {
display: flex;
justify-content: space-between;
padding: .75rem .5rem .5rem .5rem;
color: var(--shadow);
font-weight: bold;
font-size: .9rem;
.checkbox {
margin: 0 .75rem 0 0;
}
.label {
display: inline-flex;
align-items: center;
text-transform: capitalize;
}
}
.input-container {
box-sizing: border-box;
padding: 0 .5rem .5rem .5rem;
.input {
width: 100%;
}
}
.toggle-container,
.range-container {
display: flex;
flex-grow: 1;
align-items: center;
padding: .5rem 0;
&.on {
.toggle-label.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
.toggle {
background-color: var(--enabled-background);
&::-webkit-slider-thumb {
background: var(--enabled);
}
&::-moz-range-thumb {
background: var(--enabled);
}
}
}
&.off {
.toggle-label.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
.toggle {
background-color: var(--disabled-background);
&::-webkit-slider-thumb {
background: var(--disabled);
}
&::-moz-range-thumb {
background: var(--disabled);
}
}
}
}
.toggle-label {
display: inline-flex;
justify-content: center;
min-width: 1.5rem;
flex-shrink: 0;
padding: 0 .5rem;
color: var(--shadow);
font-weight: bold;
font-size: .9rem;
&.on {
text-align: right;
}
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
&.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
&.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
}
}
.toggle {
width: 0;
flex-grow: 1;
height: 1.25rem;
appearance: none;
border-radius: 1rem;
background-color: var(--shadow-hint);
background-image: radial-gradient(circle, var(--shadow-weak) .3rem, transparent calc(.3rem + 1px));
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
background: var(--disabled-handle);
width: 1.25rem;
height: 1.25rem;
border-radius: .625rem;
box-shadow: 0 0 3px var(--darken-weak);
}
&::-moz-range-thumb {
appearance: none;
background: var(--disabled-handle);
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: .625rem;
box-shadow: 0 0 3px var(--darken-weak);
}
}
@media(max-width: $breakpoint-mega) {
.filters {
flex-direction: column-reverse;
}
::v-deep(.search) {
width: 100%;
justify-content: center;
margin: 0 0 1rem 0;
}
}
@media(max-width: $breakpoint-kilo) {
.filters {
margin: 1rem 0 0 0;
}
.filters-row {
flex-direction: column;
.filter {
padding: 0 1rem 1rem 1rem;
}
}
.filters-attributes {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.genders {
padding: 0;
margin: 0 0 1.5rem 0;
}
.tiles {
padding: .5rem 1rem 1rem 1rem;
}
}
@media(max-width: $breakpoint-micro) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}
}
</style>

View File

@ -1,88 +0,0 @@
<template>
<ul class="countries nolist">
<li
v-for="country in countries"
:key="country.alpha2"
:title="country.name"
:class="{ selected: selectedCountry === country.alpha2 }"
class="country"
@click="updateValue('country', country.alpha2, true)"
>
<img
:src="`/img/flags/${country.alpha2.toLowerCase()}.svg`"
class="flag"
>
<span class="country-name">{{ country.alias || country.name }}</span>
<Icon
v-if="selectedCountry === country.alpha2"
icon="cross2"
@click.native.stop="updateValue('country', null, true)"
/>
</li>
</ul>
</template>
<script>
export default {
props: {
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
updateValue: {
type: Function,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
.countries:not(:last-child) {
border-bottom: solid 1px var(--shadow-hint);
}
.country {
width: 15rem;
display: flex;
align-items: center;
overflow: hidden;
.flag {
width: 1.25rem;
padding: .25rem .5rem;
}
.icon {
padding: .25rem .5rem;;
fill: var(--shadow);
&:hover {
fill: var(--shadow-strong);
}
}
&:hover {
background: var(--shadow-hint);
cursor: pointer;
}
&.selected {
font-weight: bold;
}
}
.country-name {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: .25rem .5rem;
}
</style>

View File

@ -1,215 +0,0 @@
<template>
<div class="filter-section">
<label class="filter-label noselect">
<span class="label">
<Checkbox
:checked="!disabled"
class="checkbox"
@change="(checked) => $emit('enable', checked)"
/>{{ label }}
</span>
<span
v-if="!disabled"
class="label-values"
>{{ value[0] }} - {{ value[1] }}<template v-if="unit">&nbsp;{{ unit }}</template></span>
</label>
<span class="filter-split">
<Range
:min="min"
:max="max"
:value="value"
:values="values"
:disabled="disabled"
:allow-enable="allowEnable"
@enable="$emit('enable', true)"
@input="(range) => $emit('input', range)"
@change="(range) => $emit('change', range)"
>
<template v-slot:start><slot name="start" /></template>
<template v-slot:end><slot name="end" /></template>
</Range>
</span>
</div>
</template>
<script>
import Checkbox from '../form/checkbox.vue';
import Range from '../form/range.vue';
export default {
components: {
Checkbox,
Range,
},
props: {
label: {
type: String,
default: null,
},
value: {
type: Array,
default: () => [0, 10],
},
values: {
type: Array,
default: null,
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 10,
},
unit: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
allowEnable: {
type: Boolean,
default: true,
},
},
emits: ['change', 'input', 'enable'],
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.filter-section {
width: 15rem;
max-width: 100%;
border-bottom: solid 1px var(--shadow-hint);
}
.filter-label {
display: flex;
justify-content: space-between;
padding: .75rem .5rem .5rem .5rem;
color: var(--shadow);
font-weight: bold;
font-size: .9rem;
.label {
display: inline-flex;
align-items: center;
text-transform: capitalize;
}
.checkbox {
margin: 0 .75rem 0 0;
}
.icon {
margin: 0 .5rem 0 0;
}
}
.label-values {
font-weight: normal;
}
.filter-split {
display: flex;
align-items: center;
}
.toggle-container,
.range-container {
display: flex;
flex-grow: 1;
align-items: center;
padding: .5rem 0;
&.on {
.toggle-label.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
.toggle {
background-color: var(--enabled-background);
&::-webkit-slider-thumb {
background: var(--enabled);
}
&::-moz-range-thumb {
background: var(--enabled);
}
}
}
&.off {
.toggle-label.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
.toggle {
background-color: var(--disabled-background);
&::-webkit-slider-thumb {
background: var(--disabled);
}
&::-moz-range-thumb {
background: var(--disabled);
}
}
}
}
.toggle-label {
display: inline-flex;
justify-content: center;
min-width: 1.5rem;
flex-shrink: 0;
padding: 0 .5rem;
color: var(--shadow);
font-weight: bold;
font-size: .9rem;
&.on {
text-align: right;
}
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
&.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
&.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
}
}
</style>

View File

@ -1,41 +0,0 @@
<template>
<span
v-if="gender"
class="gender"
:class="{ [gender]: true }"
><Icon :icon="gender" /></span>
</template>
<script>
export default {
props: {
gender: {
type: String,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.gender {
&.female .icon {
fill: var(--female);
filter: drop-shadow(0 0 1px var(--darken));
}
&.male .icon {
fill: var(--male);
filter: drop-shadow(0 0 1px var(--darken));
}
&.transsexual .icon {
fill: var(--text-light);
filter: drop-shadow(1px 0 0 var(--female)) drop-shadow(-1px 0 0 var(--female)) drop-shadow(0 1px 0 var(--female)) drop-shadow(0 -1px 0 var(--female))
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 var(--darken))
}
}
</style>

View File

@ -1,144 +0,0 @@
<template>
<div
class="photos"
:class="{
avatar: !!actor.avatar,
empty: actor.photos.length === 0,
}"
>
<a
v-if="actor.avatar"
:href="getPath(actor.avatar)"
target="_blank"
rel="noopener noreferrer"
class="avatar-link photo-link"
>
<img
:src="getPath(actor.avatar, 'thumbnail')"
:style="{ 'background-image': getBgPath(actor.avatar, 'lazy') }"
:title="actor.avatar.credit && `© ${actor.avatar.credit}`"
:width="actor.avatar.thumbnailWidth"
:height="actor.avatar.thumbnailHeight"
loading="lazy"
class="avatar photo"
@load="$emit('load', $event)"
>
</a>
<a
v-for="photo in photos"
:key="`photo-${photo.id}`"
:href="getPath(photo)"
target="_blank"
rel="noopener noreferrer"
class="photo-link"
>
<img
:src="getPath(photo, 'thumbnail')"
:style="{ 'background-image': getBgPath(photo, 'lazy') }"
:title="`© ${photo.credit || photo.entity.name}`"
:width="photo.thumbnailWidth"
:height="photo.thumbnailHeight"
loading="lazy"
class="photo"
@load="$emit('load', $event)"
>
</a>
</div>
</template>
<script>
function photos() {
return this.actor.photos.filter(photo => !photo.entropy || photo.entropy > 5.5);
}
function sfw() {
return this.$store.state.ui.sfw;
}
export default {
props: {
actor: {
type: Object,
default: null,
},
},
emits: ['load'],
computed: {
sfw,
photos,
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.photos {
display: flex;
box-sizing: border-box;
padding: .5rem 1rem;
font-size: 0;
&.expanded {
flex-wrap: wrap;
justify-content: center;
margin: 0;
.photo-link {
margin: 0 .5rem .5rem 0;
}
.photo {
height: 18rem;
}
}
&.empty {
display: none;
}
.avatar-link {
display: none;
}
}
.photo-link {
padding: 0 .5rem 0 0;
&:last-child {
padding: 0 1rem 0 0;
}
}
.photo {
height: 15rem;
width: auto;
box-shadow: 0 0 3px var(--darken-weak);
object-fit: cover;
background-position: center;
background-size: cover;
}
@media(max-width: $breakpoint3) {
.photos {
&.empty.avatar {
display: flex;
}
.avatar-link {
display: inline-block;
}
&.expanded {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
}
}
@media(max-width: $breakpoint0) {
.photos.expanded {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}
}
</style>

View File

@ -1,91 +0,0 @@
<template>
<RouterLink
:to="`/actor/${actor.id}/${actor.slug}`"
:target="target"
class="actor nolink"
>
<div class="avatar">
<img
v-if="actor.avatar"
:src="getPath(actor.avatar, 'lazy')"
class="avatar-image"
>
<Icon
v-else-if="actor.gender"
:icon="actor.gender"
class="avatar-fallback"
/>
</div>
<span class="name">{{ actor.name }}</span>
</RouterLink>
</template>
<script>
async function unstashActor(actorId, stashId) {
await this.$store.dispatch('unstashActor', { actorId, stashId });
}
export default {
props: {
actor: {
type: Object,
default: null,
},
stash: {
type: Object,
default: null,
},
target: {
type: String,
default: null,
},
},
methods: {
unstashActor,
},
};
</script>
<style lang="scss" scoped>
.actor {
height: 2.5rem;
display: inline-flex;
align-items: center;
border: solid 1px var(--shadow-hint);
&:hover {
border: solid 1px var(--primary);
}
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 100%;
border-right: solid 1px var(--shadow-hint);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 0;
}
.avatar-fallback {
fill: var(--shadow-weak);
}
.name {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 .5rem;
font-size: .8rem;
font-weight: bold;
}
</style>

View File

@ -1,61 +0,0 @@
<template>
<div
v-if="actor"
class="social"
>
<a
v-for="social in actor.social"
:key="`social-${social.id}`"
v-tooltip.bottom="social.url"
:href="social.url"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<Icon
v-if="social.platform"
:icon="social.platform"
/>
<Icon
v-else
icon="link"
/>
</a>
</div>
</template>
<script>
export default {
props: {
actor: {
type: Object,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.social {
display: block;
}
.social-link {
display: inline-block;
padding: 0 0 0 1rem;
.icon {
color: var(--highlight);
fill: var(--highlight);
width: 1.5rem;
height: 1.5rem;
}
&:hover .icon {
fill: var(--primary);
}
}
</style>

View File

@ -1,393 +0,0 @@
<template>
<div
v-if="actor"
class="actor"
>
<div
class="link"
>
<span
class="handle"
>
<span
v-tooltip.top="actor.name"
class="name"
>{{ actor.name }}</span>
<RouterLink
v-if="actor.entity"
v-tooltip="actor.entity.name"
:to="{ name: actor.entity.type, params: { entitySlug: actor.entity.slug, range: 'new', pageNumber: 1 } }"
class="favicon"
>
<img
:src="`/img/logos/${actor.entity.slug}/favicon_light.png`"
class="favicon-icon"
>
</RouterLink>
<Icon
v-if="alias"
v-tooltip="`Alias of ${alias.name}`"
icon="users3"
class="favicon alias"
/>
<Icon
v-if="actor.dateOfDeath"
v-tooltip="`Died ${formatDate(actor.dateOfDeath, 'MMMM D, YYYY')}`"
icon="tombstone"
class="favicon died"
/>
</span>
<RouterLink
:to="hasScrolled ? '' : { name: 'actor', params: { actorId: actor.id, actorSlug: actor.slug } }"
class="avatar-container"
>
<img
v-if="actor.avatar"
:src="getPath(actor.avatar, 'thumbnail')"
:style="{ 'background-image': getBgPath(actor.avatar, 'lazy') }"
loading="lazy"
draggable="false"
class="avatar"
>
<span
v-else
class="avatar"
><img
:src="`/img/avatar_${actor.gender || 'female'}.svg`"
draggable="false"
class="avatar-fallback"
></span>
<Icon
v-show="(!stash || stash.primary) && favorited"
icon="heart7"
class="stash stashed"
@click.stop.native="unstashActor"
/>
<Icon
v-show="(!stash || stash.primary) && favorited === false"
icon="heart8"
class="stash unstashed"
@click.stop.native="stashActor"
/>
<Icon
v-show="stash && !stash.primary"
icon="cross2"
class="stash unstash"
@click.stop.native="unstashActor"
/>
<span class="details">
<span class="gender-age">
<Gender :gender="actor.gender" />
<span
v-if="actor.ageAtDeath"
v-tooltip="`Born ${formatDate(actor.dateOfBirth, 'MMMM D, YYYY')}<br>Died ${formatDate(actor.dateOfDeath, 'MMMM D, YYYY')}`"
class="age-death"
>{{ actor.ageAtDeath }}</span>
<span
v-else-if="actor.ageFromBirth"
v-tooltip="`Born on ${formatDate(actor.dateOfBirth, 'MMMM D, YYYY')}`"
class="age-now"
>{{ actor.ageFromBirth }}</span>
<span
v-else-if="actor.age"
v-tooltip="`At least ${actor.age}`"
class="age-now"
>{{ actor.age }}</span>
<span
v-if="actor.ageThen && actor.ageThen < actor.ageFromBirth"
v-tooltip="`${actor.ageThen} years old on release date`"
class="age-then"
>{{ actor.ageThen }}</span>
</span>
<span
v-if="actor.origin"
v-tooltip="`Born in ${actor.origin.country.alias || actor.origin.country.name}`"
class="country"
>
{{ actor.origin.country.alpha2 }}
<img
class="flag"
:src="`/img/flags/${actor.origin.country.alpha2.toLowerCase()}.svg`"
>
</span>
<span
v-else
class="country"
/>
</span>
</RouterLink>
</div>
</div>
</template>
<script>
import Gender from './gender.vue';
async function stashActor() {
this.favorited = true;
try {
await this.$store.dispatch('stashActor', {
actorId: this.actor.id,
stashId: this.$store.getters.favorites.id,
});
this.$emit('stash', true);
} catch (error) {
this.favorited = false;
}
}
async function unstashActor() {
if (!this.stash || this.stash.primary) {
this.favorited = false;
}
try {
await this.$store.dispatch('unstashActor', {
actorId: this.actor.id,
stashId: this.stash?.id || this.$store.getters.favorites.id,
});
this.$emit('stash', false);
} catch (error) {
this.favorited = true;
}
}
function sfw() {
return this.$store.state.ui.sfw;
}
export default {
components: {
Gender,
},
props: {
actor: {
type: Object,
default: null,
},
alias: {
type: Object,
default: null,
},
stash: {
type: Object,
default: null,
},
hasScrolled: {
type: Boolean,
default: false,
},
},
emits: ['stash'],
data() {
return {
favorited: this.actor.isFavorited,
};
},
computed: {
sfw,
},
methods: {
stashActor,
unstashActor,
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.actor {
width: 100%;
display: inline-block;
position: relative;
box-shadow: 0 0 3px var(--darken-weak);
background: var(--info);
overflow: hidden;
&::before {
content: '';
display: inline-block;
padding-bottom: 150%;
}
&:hover .unstashed,
&:hover .unstash {
fill: var(--lighten);
}
}
.link {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
color: var(--text-light);
text-decoration: none;
}
.handle {
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 0 3px var(--shadow);
.name {
padding: .25rem .5rem;
font-size: .9rem;
}
.alias {
fill: var(--shadow);
}
}
.favicon {
font-size: 0;
padding: .5rem .25rem;
&:last-child {
padding: .5rem;
}
&.died {
fill: var(--shadow);
}
}
.favicon-icon {
width: 1rem;
height: 1rem;
}
.name {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.avatar-container {
display: flex;
flex-grow: 1;
position: relative;
overflow: hidden;
background: var(--profile);
cursor: pointer;
}
.avatar {
color: var(--darken-weak);
background: var(--darken-hint);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
object-fit: cover;
object-position: center 0;
background-size: cover;
background-position: center 0;
}
.avatar-fallback {
max-height: 100%;
max-width: 100%;
opacity: .1;
}
.stash {
width: 1.5rem;
height: 1.5rem;
padding: .25rem .25rem .5rem .5rem;
position: absolute;
top: 0;
right: 0;
fill: var(--lighten-weak);
filter: drop-shadow(0 0 2px var(--darken));
&:hover.unstashed,
&.stashed {
fill: var(--primary);
}
&:hover.unstash {
fill: var(--text-light);
filter: drop-shadow(0 0 2px var(--darken-weak));
}
}
.details {
background: var(--darken);
color: var(--text-light);
width: 100%;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: .5rem;
position: absolute;
bottom: 0;
font-size: .9rem;
font-weight: bold;
}
.gender-age {
display: flex;
align-items: center;
}
.gender {
margin: .25rem .25rem 0 0;
}
.country {
display: flex;
justify-content: flex-end;
align-items: center;
}
.flag {
height: .75rem;
margin: 0 0 0 .5rem;
}
.age-now,
.age-death {
margin: 0 .25rem 0 0;
}
.age-then {
color: var(--lighten);
}
@media(max-width: $breakpoint) {
.name {
font-size: .9rem;
}
}
</style>

View File

@ -1,295 +0,0 @@
<template>
<teleport to="body">
<div class="album">
<div class="album-header">
<h3 class="album-title">{{ title }}</h3>
<Icon
icon="cross2"
class="close"
@click.native="$emit('close')"
/>
</div>
<div class="album-body">
<div
v-if="entity || tag"
class="campaign-container"
>
<Campaign
:entity="entity"
:tag="tag"
:min-ratio="3"
/>
</div>
<div
class="album-items"
:class="{ portrait }"
>
<div
v-for="item in albumItems"
:key="item.id"
class="item-container"
>
<a
:href="getPath(item, null, { local })"
class="item-link"
target="_blank"
>
<img
:src="getPath(item, 'thumbnail', { local })"
:style="{ 'background-image': getBgPath(item, 'lazy', { local }) }"
:width="item.thumbnailWidth"
:height="item.thumbnailHeight"
:title="item.title"
loading="lazy"
class="item image"
>
<Logo :photo="item" />
<router-link
v-if="comments && item.title && item.entity"
:to="`/${item.entity.type}/${item.entity.slug}`"
class="item-comment"
>{{ item.title }} for {{ item.entity.name }}</router-link>
<span
v-else-if="comments && item.title"
class="item-comment"
>{{ item.title }}</span>
</a>
</div>
</div>
</div>
</div>
</teleport>
</template>
<script>
import Logo from './logo.vue';
import Campaign from '../campaigns/campaign.vue';
function albumItems() {
return this.items
.filter(Boolean)
.map(item => ({
...item,
title: item.comment || (item.credit && `© ${item.credit}`) || (item.entity && `© ${item.entity.name}`),
}));
}
export default {
components: {
Logo,
Campaign,
},
props: {
items: {
type: Array,
default: () => [],
},
title: {
type: String,
default: null,
},
local: {
type: Boolean,
default: false,
},
portrait: {
type: Boolean,
default: false,
},
comments: {
type: Boolean,
default: true,
},
entity: {
type: Object,
default: null,
},
tag: {
type: Object,
default: null,
},
},
computed: {
albumItems,
},
emits: ['close'],
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.album {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
background: var(--darken-extreme);
z-index: 10;
}
.album-header {
display: flex;
justify-content: space-between;
flex-shrink: 0;
}
.album-title {
display: block;
flex-grow: 1;
align-items: center;
padding: 1rem;
margin: 0;
color: var(--text-light);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
text-transform: capitalize;
}
.close {
width: 1.5rem;
height: 1.5rem;
padding: 1rem;
fill: var(--lighten);
&:hover {
cursor: pointer;
fill: var(--text-light);
}
}
.album-body {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
overflow-y: auto;
}
.campaign-container {
flex-shrink: 0;
text-align: center;
}
.album-items {
height: 0;
display: grid;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
justify-content: center;
grid-template-columns: repeat(auto-fit, minmax(25rem, 1fr));
grid-gap: 0 1rem;
padding: 1rem;
margin: auto 0;
&.portrait {
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}
}
.item-container {
display: flex;
align-items: center;
justify-content: center;
}
.item-link {
position: relative;
margin: 0 0 .5rem 0;
overflow: hidden;
&:hover {
.item-comment {
transform: translateY(0);
}
::v-deep(.album-logo) {
opacity: 0;
}
}
}
.item {
max-width: 100%;
max-height: 100%;
height: auto;
background-size: cover;
background-position: center;
}
.item-comment {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
box-sizing: border-box;
padding: .5rem;
color: var(--text-light);
background: var(--darken);
font-size: .9rem;
text-shadow: 0 0 3px var(--darken);
text-decoration: none;
white-space: normal;
line-height: 1.25;
transform: translateY(100%);
transition: transform .25s ease;
}
@media(max-width: $breakpoint-giga) {
.album-items {
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
.portrait {
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
}
}
@media(max-width: $breakpoint-mega) {
.album-items.portrait {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
}
@media(max-width: $breakpoint-kilo) {
.album-items {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0 .5rem;
padding: .5rem;
}
.item-link {
margin: 0 0 .25rem 0;
}
}
@media(max-width: $breakpoint) {
.album-items {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
}
@media(max-width: $breakpoint-micro) {
.album-items {
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
}
@media(max-width: $breakpoint-pico) {
.album-items {
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
}
}
</style>

View File

@ -1,66 +0,0 @@
<template>
<router-link
v-if="photo.entity"
v-tooltip="photo.entity.name"
:to="`/${photo.entity.type}/${photo.entity.slug}`"
>
<img
v-if="favicon && photo.entity.type !== 'network' && !photo.entity.independent && photo.entity.parent"
:src="`/img/logos/${photo.entity.parent.slug}/favicon.png`"
class="album-logo favicon"
>
<img
v-else-if="favicon"
:src="`/img/logos/${photo.entity.slug}/favicon.png`"
class="album-logo favicon"
>
<img
v-else-if="photo.entity.type !== 'network' && !photo.entity.independent && photo.entity.parent"
:src="`/img/logos/${photo.entity.parent.slug}/${photo.entity.slug}.png`"
class="album-logo"
>
<img
v-else
:src="`/img/logos/${photo.entity.slug}/network.png`"
class="album-logo"
>
</router-link>
</template>
<script>
module.exports = {
props: {
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" scoped>
.album-logo {
max-height: .75rem;
max-width: 5rem;
position: absolute;
right: 0;
bottom: 0;
padding: .5rem;
transition: transform .25s ease, opacity .25s ease;
filter: drop-shadow(0 0 2px var(--shadow-weak));
}
@media(max-width: $breakpoint-small) {
.album-logo:not(.favicon) {
max-height: .5rem;
max-width: 3.5rem;
}
}
</style>

View File

@ -1,623 +0,0 @@
<template>
<Dialog
title="Add alert"
@close="$emit('close')"
>
<div
v-if="error"
class="dialog-error"
>{{ error }}</div>
<form
class="dialog-body"
@submit.prevent="addAlert"
>
<div class="dialog-section">
<h3 class="dialog-heading">
When
<label class="dialog-description noselect">
<template v-if="all">Scene must match&nbsp;<strong>all</strong>&nbsp;fields</template>
<template v-else>Scene must match&nbsp;<strong>any</strong>&nbsp;field</template>
<Toggle
:checked="all"
@change="(checked) => all = checked"
/>
</label>
</h3>
<div class="alert-section">
<h4
v-if="actors.length > 1"
class="alert-heading"
>Actors</h4>
<h4
v-else
class="alert-heading"
>Actor</h4>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
class="actor"
>
<ActorPreview
:actor="actor"
target="_blank"
/>
<Icon
icon="cross3"
class="remove"
@click.native="removeActor(actor)"
/>
</li>
<Tooltip>
<li class="actor placeholder"><template v-if="actors.length === 0">Any actor</template>
<Icon
icon="plus3"
class="add"
/>
</li>
<template #tooltip>
<Search
content="actors"
@select="actor => addActor(actor)"
/>
</template>
</Tooltip>
</ul>
</div>
<div class="alert-section">
<h4
v-if="actors.length > 1"
class="alert-heading"
>Do</h4>
<h4
v-else
class="alert-heading"
>Does</h4>
<ul class="tags nolist">
<li
v-for="tag in tags"
:key="`tag-${tag.id}`"
class="tag"
>{{ tag.name }}
<Icon
icon="cross3"
class="remove"
@click.native="removeTag(tag)"
/>
</li>
<Tooltip>
<li class="tag placeholder"><template v-if="tags.length === 0">Any type of scene</template>
<Icon
icon="plus3"
class="add"
/>
</li>
<template #tooltip>
<Search
content="tags"
:defaults="['anal', 'blowbang', 'mfm', 'dp', 'gangbang', 'airtight']"
@select="tag => addTag(tag)"
/>
</template>
</Tooltip>
</ul>
</div>
<div class="alert-section">
<h4 class="alert-heading">For</h4>
<div class="entities">
<div
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
:class="{ invalid: all && index > 0 }"
class="entity"
>
<Entity
:entity="entity"
target="_blank"
/>
<Icon
icon="cross3"
class="remove"
@click.native="removeEntity(entity)"
/>
</div>
<Tooltip v-if="entities.length < 1 || !all">
<div class="entity placeholder">
Any channel
<Icon
icon="plus3"
class="add"
/>
</div>
<template #tooltip>
<Search
label="Search channels"
content="entities"
@select="entity => addEntity(entity)"
/>
</template>
</Tooltip>
</div>
</div>
<div class="alert-section">
<h4 class="alert-heading">Matching</h4>
<ul class="matches nolist">
<li
v-for="(match, index) in matches"
:key="`match-${index}`"
class="match"
>
<span class="match-property">{{ match.property }}:&nbsp;</span>
<span
v-if="match.expression.slice(0, 1) === '/' && match.expression.slice(-1) === '/'"
class="match-expression"
><span class="match-slash">/</span>{{ match.expression.slice(1, -1) }}<span class="match-slash">/</span></span>
<span
v-else
class="match-expression"
>{{ match.expression }}</span>
<Icon
icon="cross3"
class="remove"
@click.native="removeMatch(index)"
/>
</li>
<Tooltip
v-if="entities.length === 0"
@open="$refs.expression?.focus()"
>
<li class="match placeholder">
Anything
<Icon
icon="plus3"
class="add"
/>
</li>
<template #tooltip>
<form
class="pattern-tooltip"
@submit.prevent="addMatch"
>
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
ref="expression"
v-model="matchExpression"
class="input"
placeholder="Expression, // for RegExp"
>
</form>
</template>
</Tooltip>
</ul>
</div>
</div>
<div class="dialog-section">
<h3 class="dialog-heading">Then</h3>
<label class="alert-label">
<Checkbox
:checked="notify"
@change="checked => notify = checked"
/>Notify me in traxxx
</label>
<!--
<label class="alert-label">
<Checkbox
:checked="email"
@change="checked => email = checked"
/>Send me an e-mail
</label>
-->
<div class="stashes-container">
<ul class="stashes nolist">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="stash"
>{{ stash.name }}
<Icon
icon="cross3"
class="remove"
@click.native="removeStash(stash)"
/>
</li>
<Tooltip>
<li class="stash placeholder">
Add to stash
<Icon
icon="plus3"
class="add"
/>
</li>
<template #tooltip>
<Search
content="stashes"
@select="stash => addStash(stash)"
/>
</template>
</Tooltip>
</ul>
</div>
</div>
<div class="dialog-actions right">
<button
:disabled="actors.length === 0 && tags.length === 0 && entities.length === 0 && matches.length === 0"
type="submit"
class="button button-primary"
>Add alert</button>
</div>
</form>
</Dialog>
</template>
<script>
import ActorPreview from '../actors/preview.vue';
import Entity from '../entities/tile.vue';
import Checkbox from '../form/checkbox.vue';
import Toggle from '../form/toggle.vue';
import Search from './search.vue';
async function addAlert() {
this.error = null;
try {
await this.$store.dispatch('addAlert', {
all: this.all,
actors: this.actors.map((actor) => actor.id),
tags: this.tags.map((tag) => tag.id),
matches: this.matches,
entities: this.entities.map((entity) => entity.id),
notify: this.notify,
email: this.email,
stashes: this.stashes.map((stash) => stash.id),
});
this.$emit('close', true);
} catch (error) {
this.error = error.message;
}
}
function addActor(actor) {
if (!this.actors.some((selectedActor) => selectedActor.id === actor.id)) {
this.actors = this.actors.concat(actor);
}
this.events.emit('blur');
}
function addEntity(entity) {
this.entities = this.entities.concat(entity);
this.events.emit('blur');
}
function addTag(tag) {
if (!this.tags.some((selectedTag) => selectedTag.id === tag.id)) {
this.tags = this.tags.concat(tag);
}
this.events.emit('blur');
}
function removeActor(actor) {
this.actors = this.actors.filter((listedActor) => listedActor.id !== actor.id);
}
function removeEntity(entity) {
this.entities = this.entities.filter((alertEntity) => alertEntity.id !== entity.id);
}
function removeTag(tag) {
this.tags = this.tags.filter((listedTag) => listedTag.id !== tag.id);
}
function addMatch() {
if (!this.matchExpression) {
return;
}
this.matches = this.matches.concat({
property: this.matchProperty,
expression: this.matchExpression,
});
this.matchProperty = 'title';
this.matchExpression = null;
this.events.emit('blur');
}
function removeMatch(removeIndex) {
this.matches = this.matches.filter((match, matchIndex) => matchIndex !== removeIndex);
}
function addStash(stash) {
if (!this.stashes.some((selectedStash) => selectedStash.id === stash.id)) {
this.stashes = this.stashes.concat(stash);
}
this.events.emit('blur');
}
function removeStash(stash) {
this.stashes = this.stashes.filter((listedStash) => listedStash.id !== stash.id);
}
export default {
components: {
ActorPreview,
Checkbox,
Entity,
Search,
Toggle,
},
emits: ['close'],
data() {
return {
error: null,
actors: [],
tags: [],
all: true,
entities: [],
matches: [],
matchProperty: 'title',
matchExpression: null,
notify: true,
email: false,
stashes: [],
availableStashes: this.$store.state.auth.user.stashes,
};
},
methods: {
addActor,
addAlert,
addEntity,
addMatch,
addTag,
addStash,
removeActor,
removeEntity,
removeMatch,
removeTag,
removeStash,
},
};
</script>
<style lang="scss" scoped>
.dialog-section {
width: 30rem;
max-width: 100%;
&:first-child {
border-bottom: solid 1px var(--shadow-hint);
margin: 0 0 1rem 0;
}
}
.dialog-heading {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 .25rem 0;
color: var(--primary);
}
.dialog-description {
display: flex;
align-items: center;
color: var(--shadow);
font-size: .9rem;
font-weight: normal;
.toggle-container {
margin-left: .5rem;
}
}
.dialog-error {
padding: 1rem;
margin-bottom: 1rem;
background: var(--error);
color: var(--text-light);
font-weight: bold;
text-align: center;
}
.alert-heading {
margin: .75rem 0 .25rem 0;
}
.actors,
.entities,
.tags {
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 0;
}
.match {
display: flex;
align-items: center;
padding: .25rem 0;
font-family: inherit;
.remove {
position: relative;
top: -.1rem;
right: 0;
}
}
.match-property {
text-transform: capitalize;
color: var(--shadow);
}
.match-expression {
flex-grow: 1;
}
.match-slash {
padding: 0 .1rem;
color: var(--primary);
font-weight: bold;
}
.actors > .actor,
.entity,
.tag,
.stash {
position: relative;
font-size: 1rem;
margin: 0 .5rem .5rem 0;
}
.entity.invalid {
opacity: .5;
pointer-events: none;
}
.entity .tile {
width: 10rem;
height: 2.5rem;
}
.tag:not(.placeholder),
.stash:not(.placeholder) {
padding: .5rem .75rem;
border: solid 1px var(--shadow-hint);
margin: 0 .75rem 0 0;
font-size: .9rem;
font-weight: bold;
}
.stashes {
margin: 0 0 0 .25rem;
color: var(--text);
}
.pattern-tooltip {
display: flex;
gap: .5rem;
position: relative;
padding: .5rem;
overflow: hidden;
}
.remove {
width: 1rem;
height: 1rem;
position: absolute;
top: -.35rem;
right: -.35rem;
z-index: 1;
border: solid 1px var(--darken-hint);
border-radius: 50%;
background: var(--background);
fill: var(--shadow-weak);
box-shadow: 0 0 1px var(--shadow);
&:hover {
fill: var(--text-light);
background: var(--primary);
border: solid 1px var(--primary);
cursor: pointer;
}
}
.placeholder {
display: inline-flex;
align-items: center;
color: var(--shadow-strong);
padding: .75rem 0;
margin: 0 0 .5rem 0;
font-size: 1rem;
.add {
fill: var(--shadow);
margin: 0 0 0 .5rem;
}
&:hover {
color: var(--primary);
cursor: pointer;
.add {
fill: var(--primary);
}
}
}
.alert-label {
display: flex;
align-items: center;
padding: .5rem 0;
margin: 0 0 .25rem 0;
cursor: pointer;
}
.stashes-heading {
display: flex;
align-items: center;
margin: 0 0 .5rem 0;
font-size: 1rem;
.alert-label {
display: inline-block;
margin: 0;
}
}
.tooltip-container {
display: inline-block;
}
.check-container {
display: inline-block;
margin: 0 .5rem 0 0;
}
</style>

View File

@ -1,41 +0,0 @@
<template>
<Dialog
title="Remove alert"
@close="$emit('close', false)"
>
<form
class="dialog-body"
@submit.prevent="removeAlert"
>
Are you sure you want to remove alert?
<div class="dialog-actions right">
<button
type="submit"
class="button button-primary"
>Remove</button>
</div>
</form>
</Dialog>
</template>
<script>
async function removeAlert() {
await this.$store.dispatch('removeAlert', this.alert.id);
this.$emit('close', true);
}
export default {
props: {
alert: {
type: Object,
default: null,
},
},
emits: ['close'],
methods: {
removeAlert,
},
};
</script>

View File

@ -1,147 +0,0 @@
<template>
<div>
<input
ref="input"
v-model="query"
:placeholder="label || `Search ${content}`"
class="input"
@input="search"
>
<ul
v-if="results.length > 0"
class="nolist"
>
<li
v-for="result in results"
:key="`result-${result.id}`"
class="result"
@click="selectResult(result)"
>
<Icon
v-if="result.type === 'network'"
v-tooltip="'Network'"
icon="device_hub"
/>
<Icon
v-if="result.type === 'channel'"
v-tooltip="'Channel'"
icon="tv"
/>
<img
v-if="result.avatar"
:src="getPath(result.avatar)"
class="avatar"
>{{ result.name }}
</li>
</ul>
</div>
</template>
<script>
async function search() {
if (this.content === 'actors') {
const { actors } = await this.$store.dispatch('fetchActors', {
query: this.query,
limit: 10,
});
this.results = actors;
}
if (this.content === 'entities') {
this.results = await this.$store.dispatch('searchEntities', {
query: this.query,
limit: 10,
});
}
if (this.content === 'tags') {
this.results = await this.$store.dispatch('searchTags', {
query: this.query,
minLength: 1,
limit: 10,
});
}
if (this.content === 'stashes') {
this.results = this.$store.state.auth.user.stashes.filter(stash => new RegExp(this.query).test(stash.name));
}
}
function selectResult(item) {
this.query = null;
this.results = [];
this.$emit('select', item);
}
async function mounted() {
this.$refs.input.focus();
if (this.defaults.length > 0 && this.content === 'tags') {
this.results = await this.$store.dispatch('fetchTags', {
slugs: this.defaults,
});
}
if (this.content === 'stashes') {
this.results = this.$store.state.auth.user.stashes;
}
}
export default {
props: {
content: {
type: String,
default: null,
},
defaults: {
type: Array,
default: () => [],
},
label: {
type: String,
default: null,
},
},
data() {
return {
query: null,
results: [],
};
},
emits: ['select'],
mounted,
methods: {
search,
selectResult,
},
};
</script>
<style lang="scss" scoped>
.result {
color: var(--text);
display: flex;
align-items: center;
padding: .5rem;
&:hover {
color: var(--primary);
cursor: pointer;
}
}
.avatar {
height: 2rem;
margin: 0 .5rem 0 0;
}
.icon {
fill: var(--shadow);
margin: -.1rem .75rem 0 0;
}
</style>

View File

@ -1,124 +0,0 @@
<template>
<form
class="login"
@submit.prevent="login"
>
<div
v-if="error"
class="feedback error"
>{{ error }}</div>
<div
v-if="success"
class="feedback success"
>Login successful, redirecting</div>
<template v-else>
<input
v-model="username"
placeholder="Username or e-mail"
type="text"
class="input"
required
>
<input
v-model="password"
placeholder="Password"
type="password"
class="input"
required
>
<button
type="submit"
class="button button-primary"
>Log in</button>
<RouterLink
v-if="$store.state.auth.signup"
:to="{ name: 'signup', query: { ref: $route.query.ref } }"
class="link link-external signup"
>Sign up</RouterLink>
</template>
</form>
</template>
<script>
async function login() {
this.error = null;
this.success = false;
try {
const user = await this.$store.dispatch('login', {
username: this.username,
password: this.password,
});
this.success = true;
setTimeout(() => {
this.$router.replace(this.$route.query.ref || { name: 'user', params: { username: user.username } });
}, 1000);
} catch (error) {
this.error = error.message;
}
}
function mounted() {
if (!this.$store.state.auth.login) {
this.$router.replace({ name: 'not-found' });
}
}
export default {
data() {
return {
username: null,
password: null,
success: false,
error: null,
};
},
mounted,
methods: {
login,
},
};
</script>
<style lang="scss" scoped>
.login {
width: 20rem;
display: flex;
flex-direction: column;
margin: auto;
}
.input {
margin: 0 0 .5rem 0;
}
.feedback {
padding: 1rem;
text-align: center;
font-weight: bold;
}
.error {
color: var(--error);
}
.success {
color: var(--shadow-strong);
}
.button {
margin: 0 0 .25rem 0;
}
.signup {
padding: .5rem;
text-align: center;
}
</style>

View File

@ -1,134 +0,0 @@
<template>
<form
class="signup"
@submit.prevent="signup"
>
<div
v-if="error"
class="feedback error"
>{{ error }}</div>
<div
v-if="success"
class="feedback success"
>Signup successful, redirecting</div>
<template v-else>
<input
v-model="username"
placeholder="Username"
type="text"
class="input"
required
>
<input
v-model="email"
placeholder="E-mail"
type="email"
class="input"
required
>
<input
v-model="password"
placeholder="Password"
type="password"
class="input"
required
>
<button
type="submit"
class="button button-primary"
>Sign up</button>
<RouterLink
v-if="$store.state.auth.login"
:to="{ name: 'login', query: { ref: $route.query.ref } }"
class="link link-external login"
>Log in</RouterLink>
</template>
</form>
</template>
<script>
async function signup() {
this.error = null;
this.success = false;
try {
await this.$store.dispatch('signup', {
username: this.username,
email: this.email,
password: this.password,
});
this.success = true;
setTimeout(() => {
this.$router.replace(this.$route.query.ref || { name: 'home' });
}, 1000);
} catch (error) {
this.error = error.message;
}
}
function mounted() {
if (!this.$store.state.auth.signup) {
this.$router.replace({ name: 'not-found' });
}
}
export default {
data() {
return {
username: null,
email: null,
password: null,
success: false,
error: null,
};
},
mounted,
methods: {
signup,
},
};
</script>
<style lang="scss" scoped>
.signup {
width: 20rem;
display: flex;
flex-direction: column;
margin: auto;
}
.input {
margin: 0 0 .5rem 0;
}
.feedback {
padding: 1rem;
text-align: center;
font-weight: bold;
}
.error {
color: var(--error);
}
.success {
color: var(--shadow-strong);
}
.button {
margin: 0 0 .25rem 0;
}
.login {
padding: .5rem;
text-align: center;
}
</style>

View File

@ -1,237 +0,0 @@
<template>
<iframe
v-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width"
:height="campaign.banner.height"
:src="getSource(campaign)"
scrolling="none"
marginwidth="0"
marginheight="0"
class="campaign frame"
data-umami-event="campaign-click"
:data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`"
/>
<a
v-else-if="campaign"
:href="campaign.url || campaign.affiliate?.url"
target="_blank"
class="campaign"
data-umami-event="campaign-click"
:data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`"
>
<img
:src="getSource(campaign)"
:width="campaign.banner.width"
:height="campaign.banner.height"
class="campaign-banner"
>
</a>
</template>
<script>
function ratioFilter(banner) {
if (!banner) {
return false;
}
if (this.minHeight && banner.height < this.minHeight) {
return false;
}
if (this.maxHeight && banner.height > this.minHeight) {
return false;
}
if (banner.type === 'html' && banner.width > window.innerWidth) {
// usually non-scalable iframes
return false;
}
if (this.minRatio && banner.ratio < this.minRatio) {
return false;
}
if (this.maxRatio && banner.ratio > this.maxRatio) {
return false;
}
return true;
}
function getSource(campaign) {
if (campaign.banner.entity.type === 'network' || !campaign.banner.entity.parent) {
return `/banners/${campaign.banner.entity.slug}/${campaign.banner.id}.${campaign.banner.type || 'jpg'}`;
}
if (campaign.banner.entity.type === 'channel' && campaign.banner.entity.parent?.type === 'network') {
return `/banners/${campaign.banner.entity.parent.slug}/${campaign.banner.entity.slug}/${campaign.banner.id}.${campaign.banner.type || 'jpg'}`;
}
return null;
}
function entityCampaign() {
const bannerCampaigns = this.entity.campaigns
.concat(this.entity.children?.flatMap((child) => child.campaigns))
.concat(this.entity.parent?.campaigns)
.filter((campaignX) => campaignX && this.ratioFilter(campaignX.banner));
if (bannerCampaigns.length > 0) {
const randomCampaign = bannerCampaigns[Math.floor(Math.random() * bannerCampaigns.length)];
this.campaign = randomCampaign;
this.$emit('campaign', randomCampaign);
return randomCampaign;
}
if (this.allowGeneric) {
return this.genericCampaign();
}
this.$emit('campaign', null);
return null;
}
function tagCampaign() {
const campaignBanners = this.tag.banners.filter((banner) => banner.campaigns.length > 0 && this.ratioFilter(banner));
const banner = campaignBanners[Math.floor(Math.random() * campaignBanners.length)];
if (banner?.campaigns.length > 0) {
const randomCampaign = {
...banner.campaigns[Math.floor(Math.random() * banner.campaigns.length)],
banner,
};
this.campaign = randomCampaign;
this.$emit('campaign', randomCampaign);
return randomCampaign;
}
if (this.allowGeneric) {
return this.genericCampaign();
}
this.$emit('campaign', null);
return null;
}
async function genericCampaign() {
const randomCampaign = await this.$store.dispatch('fetchRandomCampaign', { minRatio: this.minRatio, maxRatio: this.maxRatio });
this.campaign = randomCampaign;
this.$emit('campaign', randomCampaign);
return randomCampaign;
}
async function specificCampaign(campaignId) {
const campaign = await this.$store.dispatch('fetchCampaign', campaignId);
this.campaign = campaign;
this.$emit('campaign', campaign);
return campaign;
}
async function mounted() {
if (this.$route.query.campaign) {
await this.specificCampaign(this.$route.query.campaign);
return;
}
if (this.entity) {
await this.entityCampaign();
return;
}
if (this.tag) {
await this.tagCampaign();
return;
}
await this.genericCampaign();
}
export default {
props: {
entity: {
type: Object,
default: null,
},
tag: {
type: Object,
default: null,
},
minHeight: {
type: Number,
default: null,
},
maxHeight: {
type: Number,
default: null,
},
minRatio: {
type: Number,
default: null,
},
allowGeneric: {
type: Boolean,
default: false,
},
maxRatio: {
type: Number,
default: null,
},
},
emits: ['campaign'],
data() {
return {
campaign: null,
};
},
mounted,
methods: {
entityCampaign,
genericCampaign,
getSource,
ratioFilter,
specificCampaign,
tagCampaign,
},
};
</script>
<style lang="scss" scoped>
.campaign {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
}
.campaign-banner {
height: auto;
width: auto;
max-height: 100%;
max-width: 100%;
}
.frame-container {
position: relative;
font-size: 0;
}
.frame-target {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@ -1,234 +0,0 @@
<template>
<div class="container">
<Warning
v-if="showWarning"
class="warning-container"
@enter="(includeQueer) => setConsent(true, includeQueer)"
@leave="setConsent(false)"
/>
<transition name="slide">
<Sidebar v-if="showSidebar" />
</transition>
<Header />
<p
v-if="config.showDisclaimer"
class="disclaimer"
v-html="config.disclaimer"
/>
<p
v-if="config.showAnnouncement"
class="announcement"
v-html="config.announcement"
/>
<div
ref="content"
class="content"
@scroll="scroll"
>
<RouterView @scroll="scrollToTop" />
</div>
<Filters
v-if="showFilters"
@close="toggleFilters(false)"
/>
<Settings
v-if="showSettings"
@close="toggleSettings(false)"
/>
</div>
</template>
<script>
import Warning from './warning.vue';
import Header from '../header/header.vue';
import Sidebar from '../sidebar/sidebar.vue';
import Filters from '../filters/filters.vue';
import Settings from '../settings/settings.vue';
function toggleSidebar(state) {
this.showSidebar = typeof state === 'boolean' ? state : !this.showSidebar;
}
function toggleFilters(state) {
this.showFilters = state;
this.showSidebar = false;
}
function toggleSettings(state) {
this.showSettings = state;
this.showSidebar = false;
}
async function setConsent(consent, includeQueer) {
if (consent) {
this.showWarning = false;
localStorage.setItem('consent', window.env.sessionId);
}
if (includeQueer) {
this.$store.dispatch('setTagFilter', this.$store.state.ui.tagFilter.filter((tag) => !['gay', 'bisexual', 'transsexual'].includes(tag)));
return;
}
this.$store.dispatch('setTagFilter', this.$store.state.ui.tagFilter.concat(['gay', 'bisexual', 'transsexual']));
}
function blur(event) {
this.events.emit('blur', event);
}
function resize(event) {
this.events.emit('resize', event);
}
function scroll(event) {
this.events.emit('scroll', event);
}
function scrollToTop() {
this.$refs.content.scrollTop = 0;
}
function trackIframeCampaign() {
// no way to capture clicks from an iframe directly
if (window.umami && document.activeElement.tagName === 'IFRAME' && document.activeElement.dataset.umamiEvent === 'campaign-click') {
window.umami.track('campaign-click', {
'campaign-id': document.activeElement.dataset.umamiEventCampaignId,
});
}
}
function mounted() {
document.addEventListener('click', this.blur);
window.addEventListener('resize', this.resize);
this.events.on('toggleSettings', this.toggleSettings);
this.events.on('toggleSidebar', this.toggleSidebar);
window.addEventListener('blur', this.trackIframeCampaign);
}
function beforeUnmount() {
document.removeEventListener('click', this.blur);
window.removeEventListener('resize', this.resize);
window.removeEventListener('blur', this.trackIframeCampaign);
}
export default {
components: {
Header,
Sidebar,
Warning,
Filters,
Settings,
},
data() {
return {
showSidebar: false,
showWarning: localStorage.getItem('consent') !== window.env.sessionId,
showFilters: false,
showSettings: false,
selected: null,
};
},
mounted,
beforeUnmount,
methods: {
setConsent,
toggleSidebar,
toggleFilters,
toggleSettings,
trackIframeCampaign,
blur,
resize,
scroll,
scrollToTop,
},
};
</script>
<style lang="scss">
.container {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--background-soft);
color: var(--text);
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
}
.content-inner {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
z-index: 1;
}
.slide-enter-active,
.slide-leave-active {
&.sidebar-container {
transition: background .15s ease-in-out;
}
.sidebar {
transition: transform .15s ease-in-out;
}
}
.slide-enter-from,
.slide-leave-to {
&.sidebar-container {
background: transparent;
}
.sidebar {
transform: translate(100%, 0);
}
}
.column {
width: 1200px;
max-width: 100%;
padding: 0 1rem;
margin: 0 auto;
box-sizing: border-box;
}
</style>
<style lang="scss" scoped>
.disclaimer,
.announcement {
padding: .5rem 1rem;
margin: 0;
color: var(--text-light);
font-weight: bold;
box-shadow: inset 0 0 3px var(--darken-weak);
text-align: center;
}
.disclaimer {
background: var(--warn);
}
.announcement {
background: var(--notice);
}
</style>

View File

@ -1,268 +0,0 @@
<template>
<div class="warning-container">
<div class="warning">
<strong class="title">
<span
class="logo"
v-html="logo"
/>contains sexually explicit content
</strong>
<ul class="rules">
<li class="rule">You are at least 18 years old, and legally permitted to view adult material in your jurisdiction.</li>
<li class="rule">You do not regard erotic and frankly pornographic media as obscene or offensive.</li>
<li class="rule">You understand that most sexual scenarios depicted on this website are fictional, performed by professional actors for the purpose of entertainment, and not representative of real-life interactions.</li>
</ul>
<span class="preferences">You can adjust your content preferences later</span>
<div class="actions">
<a
href="https://www.google.com"
class="button leave"
@click="$emit('leave')"
><Icon icon="arrow-left16" />Leave</a>
<button
class="button enter queer"
@click="$emit('enter', true)"
>
<span class="button-title">Enter</span>
<span class="button-sub">I want to see gay, bi and trans content</span>
</button>
<button
class="button enter straight"
@click="$emit('enter', false)"
>
<span class="button-title">Enter</span>
<span class="button-sub">I prefer straight content</span>
</button>
</div>
</div>
</div>
</template>
<script>
import logo from '../../img/logo.svg';
export default {
data() {
return {
logo,
};
},
emits: ['enter'],
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.warning-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
z-index: 10;
background: var(--background-censor);
backdrop-filter: blur(.25rem);
overflow-y: auto;
}
.warning {
color: var(--text);
width: 50rem;
max-height: 100%;
max-width: 100%;
margin: 1rem;
}
.copy,
.rules {
padding: .5rem 1rem;
}
.title {
display: block;
font-size: 2rem;
margin: 1rem 0;
color: var(--text);
text-align: center;
}
.logo {
width: 6.5rem;
display: inline-block;
fill: var(--primary);
margin: 0 .6rem 0 0;
::v-deep(svg) {
width: 100%;
height: 100%;
}
}
.copy {
display: block;
padding: 0 1rem;
font-weight: bold;
}
.rules {
margin: 0 0 0 1rem;
text-align: left;
text-shadow: 0 0 3px var(--highlight-strong);
}
.rule {
padding: .5rem 1rem;
line-height: 1.5;
}
.actions {
display: flex;
text-align: center;
padding: 1rem 0;
}
.button {
display: inline-flex;
flex-basis: 0;
flex-direction: column;
align-items: center;
justify-content: center;
border: none;
border-radius: .25rem;
padding: 0;
position: relative;
cursor: pointer;
font-size: 1.5rem;
text-decoration: none;
transition: border-radius .2s ease;
&.leave {
color: var(--shadow-strong);
flex-direction: row;
padding: 1rem;
.icon {
width: 1.5rem;
height: 1.5rem;
margin: 0 1rem 0 0;
fill: var(--shadow);
}
&:hover {
color: var(--text);
}
}
&.enter {
flex-grow: 1;
}
&.straight,
&.queer {
color: var(--lighten-strong);
background: var(--darken-censor);
&:before {
content: '';
width: calc(100% + .3rem);
height: calc(100% + .25rem);
position: absolute;
z-index: -1;
filter: blur(.25rem);
transition: filter .2s ease;
}
&:hover {
color: var(--text-light);
border-radius: 0;
.button-sub {
color: var(--text-light);
}
}
}
&.straight:before {
background: var(--primary);
}
&.queer:before {
background: linear-gradient(90deg, #f00, #f80, #ff0, #0f0, #00f, #a0f, #fff, #f8f, #0ff);
}
&:not(:last-child) {
margin: 0 2rem 0 0;
}
&:hover {
.icon {
fill: var(--text);
}
&.straight:before,
&.queer:before {
filter: blur(0);
}
}
}
.button-title,
.button-sub {
width: 100%;
box-sizing: border-box;
}
.button-title {
font-size: 1.5rem;
padding: .5rem 0 .15rem 0;
font-weight: bold;
}
.button-sub {
display: block;
font-size: .8rem;
padding: .15rem .5rem .75rem .5rem;
color: var(--lighten);
}
.preferences {
color: var(--shadow);
display: block;
padding: .5rem 0 1rem 0;
text-align: center;
font-size: .9rem;
}
@media(max-width: $breakpoint) {
.title {
font-size: 1.75rem;
}
.logo {
width: 5.75rem;
margin: 0 .5rem 0 0;
}
}
@media(max-width: $breakpoint-small) {
.actions {
flex-direction: column-reverse;
padding: 0;
.button {
margin: 0 0 1rem 0;
}
.button:first-child {
margin: 0;
}
}
}
</style>

View File

@ -1,142 +0,0 @@
<template>
<teleport to="body">
<div
class="container"
@click="$emit('close')"
>
<div
class="dialog"
@click.stop="events.emit('blur')"
>
<div
v-if="title || $slots.header"
class="header"
>
<slot name="header">
<h2 class="header-title">{{ title }}</h2>
</slot>
<Icon
icon="cross2"
class="close"
@click="$emit('close')"
/>
</div>
<div class="body">
<slot />
</div>
</div>
</div>
</teleport>
</template>
<script>
function mounted() {
this.events.emit('blur');
}
export default {
props: {
title: {
type: String,
default: null,
},
},
emits: ['close'],
mounted,
};
</script>
<style lang="scss" scoped>
.container {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 1rem;
background: var(--darken-strong);
z-index: 10;
}
.dialog {
display: flex;
flex-direction: column;
max-width: 100%;
max-height: 100%;
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--primary);
color: var(--text-light);
font-size: 1.5rem;
font-weight: bold;
}
.header-title {
padding: .5rem .5rem .5rem 1rem;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.25rem;
}
.close {
height: 100%;
padding: 0 1rem;
fill: var(--lighten);
&:hover {
fill: var(--lighten-strong);
cursor: pointer;
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
::v-deep(.section) {
padding: 1rem;
}
}
::v-deep(.dialog-body) {
flex-grow: 1;
box-sizing: border-box;
padding: 1rem;
overflow-y: auto;
}
::v-deep(.dialog-section:not(:last-child)) {
padding-bottom: 1rem;
border-bottom: solid 1px var(--shadow-hint);
margin-bottom: 1rem;
overflow: auto;
}
::v-deep(.dialog-actions) {
display: flex;
margin: 1rem 0 0 0;
&.center {
justify-content: center;
}
&.right {
justify-content: flex-end;
}
}
</style>

View File

@ -1,74 +0,0 @@
<template>
<div class="children">
<div
v-for="child in entity.children"
:key="`child-${child.id}`"
class="tile-container"
>
<EntityTile
:entity="child"
@load="(event) => $emit('load', event)"
/>
</div>
</div>
</template>
<script>
import EntityTile from './tile.vue';
export default {
components: {
EntityTile,
},
props: {
entity: {
type: Object,
default: null,
},
},
emits: ['load'],
};
</script>
<style lang="scss" scoped>
@import 'theme';
.children {
display: flex;
box-sizing: border-box;
padding: 1rem;
margin: 0 1rem 0 0;
border-bottom: solid 1px var(--darken-hint);
.tile-container {
display: inline-block;
padding: 0 1rem 0 0;
}
.tile {
width: 15rem;
height: 6rem;
}
&.expanded {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-gap: 1rem;
margin: 0;
.tile-container {
padding: 0;
}
.tile {
width: 100%;
}
}
}
@media(max-width: $breakpoint0) {
.children.expanded {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
}
</style>

View File

@ -1,345 +0,0 @@
<template>
<div
v-if="entity"
class="entity content"
>
<div class="info">
<a
:href="entityUrl"
target="_blank"
rel="noopener"
class="link link-child"
>
<template v-if="entity.hasLogo">
<img
v-if="$route.name === 'network'"
class="logo logo-child"
:src="`/img/logos/${entity.slug}/thumbs/network.png`"
>
<img
v-else-if="entity.parent && !entity.independent"
class="logo logo-child"
:src="`/img/logos/${entity.parent.slug}/thumbs/${entity.slug}.png`"
>
<img
v-else
class="logo logo-child"
:src="`/img/logos/${entity.slug}/thumbs/${entity.slug}.png`"
>
</template>
<h2
v-else
class="name"
>{{ entity.name }}</h2>
<Icon
v-if="entity.url"
icon="share2"
/>
</a>
<ul
v-if="entity.tags.length > 0"
class="tags"
>
<li
v-for="tag in entity.tags"
:key="`tag-${tag.slug}`"
>{{ tag.name }}</li>
</ul>
<RouterLink
v-if="entity.parent"
:to="`/${entity.parent.type}/${entity.parent.slug}`"
class="link link-parent"
>
<img
v-if="entity.parent.hasLogo"
class="logo logo-parent"
:src="`/img/logos/${entity.parent.slug}/thumbs/network.png`"
>
<img
v-if="entity.parent.hasLogo"
class="favicon"
:src="`/img/logos/${entity.parent.slug}/favicon.png`"
>
<h3
v-else
class="name parent-name"
>{{ entity.parent.name }}</h3>
<Icon icon="device_hub" />
</RouterLink>
</div>
<div
class="content-inner"
@scroll="events.emit('scroll', $event)"
>
<Scroll
v-if="entity.children.length > 0"
v-slot="scroll"
:expandable="true"
:expanded="expanded"
class="scroll-light"
@expand="(state) => expanded = state"
>
<Children
:entity="entity"
:class="{ expanded }"
@load="scroll.loaded"
/>
</Scroll>
<div
v-if="config.campaigns.entity"
class="campaign-container"
>
<Campaign
:entity="entity"
:min-ratio="3"
/>
</div>
<FilterBar
ref="filter"
:fetch-releases="fetchEntity"
:items-total="totalCount"
:items-per-page="limit"
:available-tags="entity.tags"
/>
<div class="releases">
<Releases
:releases="entity.releases"
:entity="entity"
:done="done"
/>
<Pagination
:items-total="totalCount"
:items-per-page="limit"
class="pagination-top"
/>
</div>
<Footer />
</div>
</div>
</template>
<script>
import config from 'config';
import FilterBar from '../filters/filter-bar.vue';
import Pagination from '../pagination/pagination.vue';
import Releases from '../releases/releases.vue';
import Children from './children.vue';
import Scroll from '../scroll/scroll.vue';
import Campaign from '../campaigns/campaign.vue';
async function fetchEntity(scroll = true) {
this.done = false;
this.entityUrl = null;
const { entity, totalCount } = await this.$store.dispatch('fetchEntityBySlugAndType', {
entitySlug: this.$route.params.entitySlug,
entityType: this.$route.name,
limit: this.limit,
range: this.$route.params.range,
pageNumber: Number(this.$route.params.pageNumber),
});
this.entity = entity;
this.totalCount = totalCount;
this.pageTitle = entity.name;
const campaign = entity.campaigns.find((campaignX) => !campaignX.banner)
|| entity.parent?.campaigns.find((campaignX) => !campaignX.banner);
if (entity.url) {
const { searchParams, pathname, origin } = new URL(entity.url);
const affiliateParams = new URLSearchParams({
...(entity.url && Object.fromEntries(searchParams)), // preserve any query in entity URL, e.g. ?siteId=5
...(campaign?.affiliate?.parameters && Object.fromEntries(new URLSearchParams(campaign.affiliate.parameters))), // append affiliate parameters
}).toString();
this.entityUrl = campaign?.url || campaign?.affiliate?.url || `${origin}${pathname}${campaign?.affiliate?.parameters ? `?${affiliateParams}` : ''}`;
}
this.done = true;
if (scroll && this.$refs.filter?.$el) {
this.$refs.filter.$el.scrollIntoView();
}
}
async function mounted() {
await this.fetchEntity();
}
async function route(to) {
if (to.name === 'channel' || to.name === 'network' || to.name === 'studio') {
await this.fetchEntity();
this.expanded = false;
}
}
export default {
components: {
FilterBar,
Pagination,
Children,
Releases,
Scroll,
Campaign,
},
data() {
return {
entity: null,
pageTitle: null,
totalCount: null,
done: false,
limit: Number(this.$route.query.limit || 30) - config.campaigns.tiles, // reserve one campaign spot
expanded: false,
entityUrl: null,
};
},
watch: {
$route: route,
'$store.state.ui.tagFilter': fetchEntity,
},
mounted,
methods: {
fetchEntity,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.info {
display: flex;
justify-content: space-between;
background: var(--profile);
border-bottom: solid 1px var(--lighten-hint);
}
.link {
max-width: 15rem;
display: flex;
align-items: center;
box-sizing: border-box;
padding: 1rem;
text-decoration: none;
}
.link-child {
flex-shrink: 0;
.icon {
fill: var(--lighten);
margin: 0 0 0 1rem;
}
&:hover .icon {
fill: var(--text-light);
}
}
.link-parent {
flex-direction: row-reverse;
flex-shrink: 0;
margin: 0 0 0 3rem;
.icon {
width: 1.25rem;
height: 1.25rem;
fill: var(--lighten);
margin: 0 .5rem 0 0;
}
}
.logo {
height: 100%;
max-width: 100%;
object-fit: contain;
}
.logo-child {
height: 3rem;
}
.logo-parent {
height: 1.5rem;
}
.favicon {
height: 1rem;
flex-shrink: 0;
}
.content-inner {
display: flex;
flex-direction: column;
}
.scroll {
background: var(--profile);
}
.campaign-container {
max-height: 150px;
background: var(--background-dim);
text-align: center;
padding: .5rem;
}
.releases {
flex-grow: 1;
}
.name {
color: var(--text-light);
display: flex;
align-items: center;
padding: 0;
margin: 0;
white-space: nowrap;
font-size: 1.5rem;
}
.favicon {
display: none;
}
@media(max-width: $breakpoint) {
.tags {
display: none;
}
}
@media(max-width: $breakpoint-micro) {
.logo-parent {
display: none;
}
.favicon {
display: inline-block;
}
}
@media(max-width: $breakpoint-nano) {
.link-child .icon {
display: none;
}
}
</style>

View File

@ -1,132 +0,0 @@
<template>
<RouterLink
:to="`/${entity.type}/${entity.slug}`"
:title="entity.name"
:target="target"
class="tile"
>
<div class="tile-logo">
<template v-if="entity.hasLogo">
<img
v-if="entity.type === 'network' || entity.independent"
:src="`/img/logos/${entity.slug}/thumbs/network.png`"
:alt="entity.name"
loading="lazy"
class="logo"
@load="$emit('load', $event)"
>
<img
v-else-if="entity.parent"
:src="`/img/logos/${entity.parent.slug}/thumbs/${entity.slug}.png`"
:alt="entity.name"
loading="lazy"
class="logo"
@load="$emit('load', $event)"
>
<img
v-else
:src="`/img/logos/${entity.slug}/thumbs/${entity.slug}.png`"
:alt="entity.name"
loading="lazy"
class="logo"
@load="$emit('load', $event)"
>
</template>
<span
v-else
class="name"
>{{ entity.name }}</span>
</div>
<span
v-if="entity.childrenTotal > 0 || typeof entity.sceneTotal !== 'undefined'"
class="count"
>
<span v-if="typeof entity.sceneTotal !== 'undefined'">{{ entity.sceneTotal }} scenes</span>
<span v-if="entity.type === 'network'">{{ entity.childrenTotal }} channels</span>
</span>
</RouterLink>
</template>
<script>
export default {
props: {
entity: {
type: Object,
default: null,
},
target: {
type: String,
default: null,
},
},
emits: ['load'],
};
</script>
<style lang="scss" scoped>
@import 'theme';
.tile {
height: 100%;
background: var(--tile);
display: flex;
flex-direction: column;
flex-shrink: 0;
justify-content: center;
align-items: center;
box-sizing: border-box;
border-radius: .25rem;
position: relative;
box-shadow: 0 0 3px rgba(0, 0, 0, .25);
text-align: center;
text-decoration: none;
overflow: hidden;
&:hover .count {
bottom: 0;
}
}
.tile-logo {
display: flex;
flex-grow: 1;
padding: .5rem 1rem;
overflow: hidden;
}
.logo {
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
align-self: center;
}
.name {
display: flex;
align-items: center;
color: var(--text-light);
font-size: 1.25rem;
font-weight: bold;
}
.count {
display: flex;
justify-content: space-between;
width: 100%;
position: absolute;
bottom: -1.75rem;
box-sizing: border-box;
padding: .25rem .5rem;
background: var(--darken-strong);
box-shadow: 0 0 3px var(--darken);
color: var(--text-light);
text-align: center;
text-shadow: 1px 1px var(--darken);
transition: bottom .1s ease;
}
</style>

View File

@ -1,34 +0,0 @@
<template>
<div class="errorpage">
<h1 class="error">404 - Not Found</h1>
<a
href="/"
class="home"
>Take me home</a>
</div>
</template>
<style lang="scss" scoped>
@import 'theme';
.errorpage {
background: var(--background);
color: var(--primary);
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 2rem;
}
.error {
margin: 0;
}
.home {
color: var(--shadow);
margin: 3rem 0;
}
</style>

View File

@ -1,63 +0,0 @@
<template>
<div>
<div
v-show="expanded"
class="expand-button expanded noselect"
@click="$emit('expand', false)"
><Icon icon="arrow-up3" /></div>
<div
v-show="!expanded"
class="expand-button noselect"
@click="$emit('expand', true)"
><Icon icon="arrow-down3" /></div>
</div>
</template>
<script>
export default {
props: {
expanded: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" scoped>
.expand-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: .5rem;
.icon {
fill: var(--lighten);
}
&:hover {
cursor: pointer;
background: var(--lighten-hint);
.icon {
fill: var(--text-light);
}
}
}
.expand-dark {
.icon {
fill: var(--darken-weak);
}
&:hover {
background: var(--darken-hint);
.icon {
fill: var(--darken);
}
}
}
</style>

View File

@ -1,88 +0,0 @@
<template>
<Tooltip class="filter-container">
<div class="filter">
<Icon icon="users" />
<div
v-if="selectedActors.length > 0"
class="filter-applied"
>{{ selectedActors.length }} {{ selectedActors.length > 1 ? 'actors' : 'actor' }}</div>
<div
v-else
class="filter-applied empty"
>Actors</div>
</div>
<template v-slot:tooltip>
<div @click.stop>
<router-link
class="filter-clear"
:to="{ query: { ...$route.query, actors: undefined } }"
:class="{ active: selectedActors.length > 0 }"
>clear all<Icon icon="cross2" /></router-link>
<ul class="filter-items nolist">
<li
v-for="actor in availableActors"
:key="actor.id"
class="filter-item"
:class="{ selected: selectedActors.includes(actor.slug) }"
>
<router-link
:to="{ query: {
...$route.query,
actors: actor.slug,
}, params: { pageNumber: 1 } }"
class="filter-name"
>{{ actor.name }}</router-link>
<router-link
:to="{ query: { ...$route.query, ...getNewRange(actor) }, params: { pageNumber: 1 } }"
class="filter-include"
>
<Icon
icon="checkmark"
class="filter-add"
/>
<Icon
icon="cross2"
class="filter-remove"
/>
</router-link>
</li>
</ul>
</div>
</template>
</Tooltip>
</template>
<script>
function getNewRange(actor) {
if (this.selectedActors.includes(actor.slug)) {
return { actors: this.selectedActors.filter(selectedActor => selectedActor !== actor.slug).join(',') || undefined };
}
return { actors: this.selectedActors.concat(actor.slug).join(',') };
}
function selectedActors() {
return this.$route.query.actors ? this.$route.query.actors.split(',') : [];
}
export default {
props: {
availableActors: {
type: Array,
default: () => [],
},
},
computed: {
selectedActors,
},
methods: {
getNewRange,
},
};
</script>

View File

@ -1,179 +0,0 @@
<template>
<Tooltip class="filter-container">
<div class="filter">
<Icon icon="antenna" />
<div
v-if="selectedLength > 0"
class="filter-applied"
>{{ selectedLength }} {{ selectedLength > 1 ? 'channels' : 'channel' }}</div>
<div
v-else
class="filter-applied empty"
>Channels</div>
</div>
<template v-slot:tooltip>
<div @click.stop>
<router-link
class="filter-clear"
:to="{ query: { ...$route.query, channels: undefined, networks: undefined } }"
:class="{ active: selectedLength > 0 }"
>clear all<Icon icon="cross2" /></router-link>
<ul class="filter-items nolist">
<li
v-for="channel in channelsPerNetwork"
:key="`channel-${channel.id}`"
class="filter-item"
:class="{
[channel.type]: true,
independent: channel.independent,
selected: channel.type === 'network' ? selectedNetworks.includes(channel.slug) : selectedChannels.includes(channel.slug),
disabled: channel.parent && selectedNetworks.includes(channel.parent.slug),
}"
>
<router-link
:to="{ query: {
...$route.query,
[channel.type === 'network' ? 'networks' : 'channels']: channel.slug,
[channel.type === 'network' ? 'channels' : 'networks']: undefined,
}, params: { pageNumber: 1 } }"
class="filter-name"
>
<img
v-if="channel.type === 'network' || channel.independent || !channel.parent "
:src="`/img/logos/${channel.slug}/favicon.png`"
class="favicon"
>
{{ channel.name }}
</router-link>
<router-link
:to="{ query: { ...$route.query, ...getNewRange(channel) }, params: { pageNumber: 1 } }"
class="filter-include"
>
<Icon
icon="checkmark"
class="filter-add"
/>
<Icon
icon="cross2"
class="filter-remove"
/>
</router-link>
</li>
</ul>
</div>
</template>
</Tooltip>
</template>
<script>
function getNewRange(entity) {
if (entity.type === 'network' && this.selectedNetworks.includes(entity.slug)) {
return {
channels: this.selectedChannels.join(',') || undefined,
networks: this.selectedNetworks.filter(selectedTag => selectedTag !== entity.slug).join(',') || undefined,
};
}
if (entity.type === 'network') {
return {
channels: this.selectedChannels.join(',') || undefined,
networks: this.selectedNetworks.concat(entity.slug).join(','),
};
}
if (this.selectedChannels.includes(entity.slug)) {
return {
channels: this.selectedChannels.filter(selectedTag => selectedTag !== entity.slug).join(',') || undefined,
networks: this.selectedNetworks.join(',') || undefined,
};
}
return {
channels: this.selectedChannels.concat(entity.slug).join(','),
networks: this.selectedNetworks.join(',') || undefined,
};
}
function selectedChannels() {
return this.$route.query.channels ? this.$route.query.channels.split(',') : [];
}
function selectedNetworks() {
return this.$route.query.networks ? this.$route.query.networks.split(',') : [];
}
function channelsPerNetwork() {
const networks = this.availableChannels.reduce((acc, channel) => {
if (channel.type === 'network' || channel.independent || !channel.parent) {
acc[channel.slug] = { ...channel, children: [] };
return acc;
}
if (!acc[channel.parent.slug]) {
acc[channel.parent.slug] = { ...channel.parent, children: [] };
}
acc[channel.parent.slug].children.push(channel);
return acc;
}, {});
return Object.values(networks).reduce((acc, network) => [...acc, network, ...(network.children || [])], []);
}
function selectedLength() {
return this.selectedChannels.length + this.selectedNetworks.length;
}
export default {
props: {
filter: {
type: Array,
default: () => [],
},
compact: {
type: Boolean,
default: false,
},
availableChannels: {
type: Array,
default: () => [],
},
},
computed: {
channelsPerNetwork,
selectedChannels,
selectedNetworks,
selectedLength,
},
methods: {
getNewRange,
},
};
</script>
<style lang="scss" scoped>
.favicon {
width: 1rem;
height: 1rem;
padding: 0 .75rem 0 0;
filter: drop-shadow(0 0 1px var(--darken));
}
.network .filter-name,
.independent .filter-name {
font-weight: bold;
padding: .5rem;
}
.channel:not(.independent) .filter-name {
padding: .5rem .5rem .5rem 2.25rem;
}
</style>

View File

@ -1,369 +0,0 @@
<template>
<div class="filter-bar noselect">
<div class="sort">
<RouterLink
v-for="section in ranges"
:key="section"
:to="{ params: { range: section, pageNumber: 1 }, query: $route.query }"
:class="{ active: $route.name === section || range === section }"
class="range range-button"
>{{ section }}</RouterLink>
</div>
<span
v-if="itemsTotal && showTotal"
class="total"
>{{ itemsTotal }} <template v-if="itemsTotal === 1">scene</template><template v-else>scenes</template></span>
<div
v-if="showFilters"
class="filters"
>
<ActorFilter
class="filters-filter"
:available-actors="availableActors"
/>
<ChannelFilter
class="filters-filter"
:available-channels="availableChannels"
/>
<TagFilter
class="filters-filter"
:available-tags="availableTags"
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import ActorFilter from './actor-filter.vue';
import ChannelFilter from './channel-filter.vue';
import TagFilter from './tag-filter.vue';
function range() {
return this.$route.params.range;
}
function batch(state) {
return state.ui.batch;
}
async function setRange(newRange) {
this.$store.dispatch('setRange', newRange);
await this.fetchReleases();
}
async function setBatch(newBatch) {
this.$store.dispatch('setBatch', newBatch);
await this.fetchReleases();
}
export default {
components: {
ActorFilter,
ChannelFilter,
TagFilter,
},
props: {
fetchReleases: {
type: Function,
default: null,
},
showFilters: {
type: Boolean,
default: true,
},
showTotal: {
type: Boolean,
default: true,
},
itemsTotal: {
type: Number,
default: 0,
},
itemsPerPage: {
type: Number,
default: 10,
},
ranges: {
type: Array,
default: () => ['latest', 'upcoming', 'new'],
},
availableTags: {
type: Array,
default: () => [],
},
availableChannels: {
type: Array,
default: () => [],
},
availableActors: {
type: Array,
default: () => [],
},
},
computed: {
...mapState({
range,
batch,
}),
},
methods: {
setRange,
setBatch,
},
};
</script>
<style lang="scss">
@import 'breakpoints';
.filter {
color: var(--shadow);
display: inline-flex;
align-items: center;
.filter-applied {
flex-grow: 1;
padding: .75rem .5rem;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
&.empty {
color: var(--shadow);
}
}
.icon {
fill: var(--shadow);
margin: -.1rem 0 0 0;
}
&:hover {
cursor: pointer;
.applied {
color: var(--shadow-strong);
}
.icon {
fill: var(--shadow-strong);
}
}
}
.filter-mode {
width: 100%;
color: var(--shadow);
background: none;
padding: .75rem;
margin: 0 0 .5rem 0;
font-size: 1rem;
border: none;
border-bottom: solid 1px var(--shadow-hint);
option {
color: var(--text-dark);
}
}
.filter-clear {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem 1rem;
color: var(--shadow-weak);
text-decoration: none;
cursor: default;
.icon {
fill: var(--shadow-hint);
margin: 0 0 0 1rem;
}
&.active {
color: var(--shadow);
.icon {
fill: var(--shadow-weak);
}
&:hover {
color: var(--text);
background: var(--shadow-hint);
cursor: pointer;
.icon {
fill: var(--alert);
}
}
}
}
.filter-items .filter-item {
display: flex;
align-items: center;
&:hover {
background: var(--shadow-hint);
cursor: pointer;
}
&.selected {
.filter-include {
.filter-add {
fill: var(--success);
}
&:hover {
.filter-add {
display: none;
}
.filter-remove {
display: inline-block;
}
}
}
}
}
.filter-include {
.icon {
width: 1rem;
height: 1rem;
padding: .5rem 1rem;
fill: var(--shadow-hint);
}
.filter-remove {
display: none;
fill: var(--alert);
}
&:hover {
cursor: pointer;
}
}
.filter-name {
min-width: 8rem;
display: flex;
flex-grow: 1;
padding: .5rem .75rem .5rem 1rem;
color: var(--text);
text-decoration: none;
}
.filter-include:hover,
.filter-name:hover {
background: var(--shadow-hint);
}
@media(max-width: $breakpoint-small) {
.filter-applied {
display: none;
}
.filters-filter:not(:last-child) .filter {
padding: .5rem;
}
.filters-filter:last-child .filter {
padding: .5rem 0 .5rem .5rem;
}
.total {
display: none;
}
}
</style>
<style lang="scss" scoped>
@import 'theme';
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: .5rem 1rem 0 1rem;
z-index: 1;
background: var(--background-dim);
font-size: 0;
.icon {
margin: 0 .5rem 0 0;
fill: var(--shadow);
}
}
.filters {
flex-shrink: 0;
}
.sort {
display: flex;
align-items: center;
}
.range-button {
color: var(--shadow);
display: inline-block;
padding: .8rem 1rem;
border: none;
position: relative;
font-size: .8rem;
font-weight: bold;
text-decoration: none;
border: solid 1px transparent;
border-bottom: none;
text-transform: capitalize;
&:hover:not(.active) {
color: var(--shadow-strong);
cursor: pointer;
}
&.active {
color: var(--primary);
background: var(--background-soft);
border-color: var(--crease);
&::after {
/* hide grey border for tab effect, negative margin shows grey crease on mobile */
content: '';
width: 100%;
height: 2px;
background: var(--background-soft);
position: absolute;
left: 0;
bottom: -2px;
}
}
}
.filters-filter {
display: inline-block;
flex-shrink: 0;
&:not(:last-child) {
margin: 0 1rem 0 0;
}
}
.total {
flex-shrink: 0;
padding: 0 1rem;
color: var(--shadow);
font-size: .8rem;
}
</style>

View File

@ -1,84 +0,0 @@
<template>
<Dialog
title="Filters"
@close="$emit('close')"
>
<div class="filters dialog-body">
<h3 class="form-heading">Show me</h3>
<ul class="tags nolist">
<li
v-for="tag in tags"
:key="tag"
class="tags-item"
>
<Checkbox
:checked="!tagFilter.includes(tag)"
:label="tag"
class="tag"
@change="(state) => filterTag(tag, state)"
/>
</li>
</ul>
<p class="disclaimer">You may still incidentally see filtered out content</p>
</div>
</Dialog>
</template>
<script>
import Checkbox from '../form/checkbox.vue';
function tagFilter() {
return this.$store.state.ui.tagFilter;
}
function filterTag(tag, isChecked) {
if (isChecked) {
this.$store.dispatch('setTagFilter', this.tagFilter.filter(filteredTag => filteredTag !== tag));
} else {
this.$store.dispatch('setTagFilter', this.tagFilter.concat(tag));
}
}
export default {
components: {
Checkbox,
},
data() {
return {
tags: ['anal', 'gay', 'transsexual', 'bisexual', 'pissing', 'anal prolapse'],
};
},
computed: {
tagFilter,
},
emits: ['close'],
methods: {
filterTag,
},
};
</script>
<style lang="scss" scoped>
.filters {
width: 20rem;
max-width: 100%;
}
.tags-item {
display: block;
}
.tag {
padding: .5rem 0;
}
.disclaimer {
margin: 1rem 0 0 0;
line-height: 1.5;
text-align: center;
font-size: .9rem;
color: var(--shadow);
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<Tooltip class="filter-container">
<div class="filter">
<Icon icon="price-tag4" />
<div
v-if="selectedTags.length > 0"
class="filter-applied"
>{{ selectedTags.length }} {{ selectedTags.length > 1 ? 'tags' : 'tag' }}</div>
<div
v-else
class="filter-applied empty"
>Tags</div>
</div>
<template #tooltip>
<div
class="filter-options"
@click.stop
>
<select
v-model="mode"
class="filter-mode"
@change="$router.push({ query: { ...$route.query, mode }, params: { pageNumber: 1 } })"
>
<option value="all">match all selected</option>
<option value="any">match any selected</option>
</select>
<router-link
class="filter-clear"
:to="{ query: { ...$route.query, tags: undefined, mode: undefined } }"
:class="{ active: selectedTags.length > 0 }"
>clear all<Icon icon="cross2" /></router-link>
<ul class="filter-items nolist">
<li
v-for="tag in availableTags"
:key="`tag-${tag.id}`"
class="filter-item"
:class="{ selected: selectedTags.includes(tag.slug) }"
>
<router-link
:to="{ query: { ...$route.query, tags: tag.slug, mode }, params: { pageNumber: 1 } }"
class="filter-name"
>{{ tag.name }}</router-link>
<router-link
:to="{ query: { ...$route.query, ...getNewRange(tag.slug), mode }, params: { pageNumber: 1 } }"
class="filter-include"
>
<Icon
icon="checkmark"
class="filter-add"
/>
<Icon
icon="cross2"
class="filter-remove"
/>
</router-link>
</li>
</ul>
</div>
</template>
</Tooltip>
</template>
<script>
function getNewRange(tag) {
if (this.selectedTags.includes(tag)) {
return { tags: this.selectedTags.filter((selectedTag) => selectedTag !== tag).join(',') || undefined };
}
return { tags: this.selectedTags.concat(tag).join(',') };
}
function selectedTags() {
return this.$route.query.tags ? this.$route.query.tags.split(',') : [];
}
export default {
props: {
compact: {
type: Boolean,
default: false,
},
availableTags: {
type: Array,
default: () => [],
},
},
data() {
return {
mode: this.$route.query.mode || 'all',
};
},
computed: {
selectedTags,
},
methods: {
getNewRange,
},
};
</script>
<style lang="scss" scoped>
.filter-options {
width: 15rem;
}
</style>

View File

@ -1,60 +0,0 @@
<template>
<footer class="footer">
<span class="segment">© traxxx</span>
<router-link
:to="{ name: 'stats' }"
class="segment footer-link nolink"
>stats</router-link>
<a
v-if="config.discord"
:href="config.discord"
target="_blank"
rel="noopener noreferrer"
class="segment footer-link nolink discord"
><Icon icon="discord" /></a>
</footer>
</template>
<style lang="scss" scoped>
.footer {
display: flex;
justify-content: center;
align-items: center;
background: var(--background-dim);
color: var(--shadow);
font-size: .8rem;
font-weight: bold;
box-shadow: inset -3px 0 3px var(--shadow-hint);
}
.segment {
display: inline-flex;
align-items: center;
padding: .5rem;
&:not(:last-child) {
border-right: solid 1px var(--shadow-hint);
}
}
.footer-link {
text-decoration: underline;
&:hover {
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.discord {
.icon {
fill: var(--shadow);
width: 4rem;
}
}
</style>

View File

@ -1,102 +0,0 @@
<template>
<label class="check-container noselect">
<span
v-if="label"
class="check-label"
>{{ label }}</span>
<input
v-show="false"
:id="`checkbox-${uid}`"
:checked="checked"
type="checkbox"
class="check-checkbox"
@change="$emit('change', $event.target.checked)"
>
<label
:for="`checkbox-${uid}`"
class="check"
/>
</label>
</template>
<script>
export default {
props: {
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
},
emits: ['change'],
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.check-container {
display: flex;
justify-content: space-between;
cursor: pointer;
}
.check {
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
background-color: var(--shadow-hint);
cursor: pointer;
transition: background .15s ease;
&::after {
content: '';
width: .5rem;
height: .3rem;
border: solid 2px var(--text-light);
border-top: none;
border-right: none;
margin: -.2rem 0 0 0;
transform: rotateZ(-45deg) scaleX(0);
transition: transform .15s ease;
}
}
.check-cross .check::before {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: url('/img/icons/cross3.svg') no-repeat center/80%;
opacity: .15;
transition: transform .1s ease;
}
.check-checkbox:checked + .check {
background: var(--primary);
&::after {
transform: rotateZ(-45deg) scaleX(1);
}
&::before {
transform: scaleX(0);
}
}
.check-label {
overflow: hidden;
text-transform: capitalize;
text-overflow: ellipsis;
margin: 0 .5rem 0 0;
}
</style>

View File

@ -1,245 +0,0 @@
<template>
<div class="range-container">
<div
class="label label-start"
:class="{ disabled }"
@click="setValue('valueA', min)"
>
<slot name="start" />
</div>
<div
class="range"
:class="{ disabled }"
:style="{ background: `linear-gradient(90deg, var(--slider-track) ${minPercentage}%, var(--slider-range) ${minPercentage}%, var(--slider-range) ${maxPercentage}%, var(--slider-track) ${maxPercentage}%)` }"
@click="setNearest"
>
<input
v-model.number="valueA"
:min="min"
:max="max"
:data-value="valueA"
:disabled="disabled"
type="range"
class="slider"
@input="emit('input')"
@change="emit('change')"
@click.stop
>
<input
v-model.number="valueB"
:min="min"
:max="max"
:data-value="valueB"
:disabled="disabled"
type="range"
class="slider"
@input="emit('input')"
@change="emit('change')"
@click.stop
>
</div>
<div
class="label label-end"
:class="{ disabled }"
@click="setValue('valueB', max)"
>
<slot name="end" />
</div>
</div>
</template>
<script>
import { nextTick } from 'vue';
function minValue() {
return Math.min(this.valueA, this.valueB);
}
function maxValue() {
return Math.max(this.valueA, this.valueB);
}
function minPercentage() {
return ((this.minValue - this.min) / (this.max - this.min)) * 100;
}
function maxPercentage() {
return ((this.maxValue - this.min) / (this.max - this.min)) * 100;
}
function emit(type = 'change') {
if (this.values) {
this.$emit(type, [this.values[this.minValue], this.values[this.maxValue]]);
return;
}
this.$emit(type, [this.minValue, this.maxValue]);
}
function setNearest(event) {
if (this.allowEnable) {
this.emit('enable');
}
nextTick(() => {
if (!this.disabled) {
const closestValue = Math.round((event.offsetX / event.target.getBoundingClientRect().width) * (this.max - this.min)) + this.min;
const closestSlider = Math.abs(this.valueA - closestValue) < Math.abs(this.valueB - closestValue) ? 'valueA' : 'valueB';
this[closestSlider] = closestValue;
this.emit();
}
});
}
function setValue(prop, value) {
if (this.allowEnable) {
this.emit('enable');
}
nextTick(() => {
if (!this.disabled) {
this[prop] = value;
this.emit();
}
});
}
export default {
props: {
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 10,
},
value: {
type: Array,
default: () => [3, 7],
},
values: {
type: Array,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
allowEnable: {
type: Boolean,
default: true,
},
},
emits: ['change', 'input', 'enable'],
data() {
if (this.values) {
return {
valueA: this.values.indexOf(this.value[0]),
valueB: this.values.indexOf(this.value[1]),
};
}
return {
valueA: this.value[0],
valueB: this.value[1],
};
},
computed: {
minValue,
maxValue,
minPercentage,
maxPercentage,
},
methods: {
emit,
setNearest,
setValue,
},
};
</script>
<style lang="scss">
.dark .range-container .range {
--slider-range: var(--lighten-weak);
}
</style>
<style lang="scss" scoped>
@mixin thumb {
appearance: none;
display: block;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 50%;
background: var(--slider-thumb);
pointer-events: visible;
cursor: pointer;
box-shadow: 0 0 3px var(--darken-weak);
}
.range-container {
display: flex;
justify-content: space-between;
}
.range {
--slider-track: var(--shadow-hint);
--slider-range: var(--primary-faded);
--slider-thumb: var(--primary);
position: relative;
height: 1.25rem;
flex-grow: 1;
border-radius: .625rem;
&.disabled {
--slider-range: var(--shadow-weak);
--slider-thumb: var(--disabled-handle);
}
}
.slider {
width: 100%;
top: 0;
margin: 0;
appearance: none;
position: absolute;
background: none;
outline: none;
pointer-events: none;
}
.slider::-webkit-slider-thumb {
@include thumb;
}
.slider::-moz-range-thumb {
@include thumb;
transform: translateY(2px);
}
.label {
padding: 0 .5rem;
&:hover:not(.disabled) {
cursor: pointer;
::v-deep(.icon) {
fill: var(--primary);
}
}
}
::v-deep(.icon) {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
fill: var(--shadow);
}
</style>

View File

@ -1,115 +0,0 @@
<template>
<label
class="toggle-container noselect"
:class="{ light: $store.state.ui.theme === 'dark' }"
>
<input
:id="`toggle-${id}`"
:checked="checked"
:true-value="trueValue"
:false-value="falseValue"
:disabled="disabled"
type="checkbox"
class="toggle-input"
@change="$emit('change', $event.target.checked)"
>
<label
:for="`toggle-${id}`"
class="toggle"
/>
</label>
</template>
<script>
export default {
props: {
checked: {
type: Boolean,
default: false,
},
trueValue: {
type: null,
default: true,
},
falseValue: {
type: null,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['change'],
data() {
return {
id: Math.floor(new Date().getTime() * Math.random()),
};
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.toggle-container {
display: inline-block;
cursor: pointer;
&.light {
.toggle {
background: var(--lighten-weak);
}
.toggle-input:checked + .toggle {
background: var(--lighten);
}
}
}
.toggle {
width: 2rem;
height: .9rem;
display: flex;
align-items: center;
position: relative;
background: var(--shadow-hint);
border-radius: 1rem;
cursor: pointer;
&::after {
content: '';
background-color: var(--background-light);
width: 1rem;
height: 1rem;
display: inline-block;
position: absolute;
left: 0;
border-radius: 50%;
box-shadow: 0 0 2px var(--darken-strong);
transition: background-color .2s ease, left .2s ease;
}
}
.toggle-input {
display: none;
&:checked + .toggle {
background: var(--primary-faded);
&::after {
background: var(--primary);
left: calc(100% - 1rem);
}
}
&[disabled] + .toggle {
background: var(--shadow-weak);
&::after {
background: var(--shadow);
}
}
}
</style>

View File

@ -1,441 +0,0 @@
<template>
<header class="header">
<div class="header-nav">
<RouterLink
to="/"
class="logo-link"
><h1 class="header-logo">
<div
class="logo"
v-html="logo"
/>
</h1></RouterLink>
<nav class="nav">
<ul class="nav-list nolist">
<li class="nav-item">
<RouterLink
v-slot="{ href, isActive, navigate }"
:to="{ name: 'actors', params: { pageNumber: 1 } }"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Actors</a>
</RouterLink>
</li>
<li class="nav-item">
<RouterLink
v-slot="{ href, isActive, navigate }"
:to="{ name: 'channels' }"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Channels</a>
</RouterLink>
</li>
<li class="nav-item">
<RouterLink
v-slot="{ href, isActive, navigate }"
:to="{ name: 'tags' }"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Tags</a>
</RouterLink>
</li>
<li class="nav-item">
<RouterLink
v-slot="{ href, isActive, navigate }"
:to="{ name: 'movies', params: { range: 'latest', pageNumber: 1 } }"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Movies</a>
</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="header-section">
<div
class="sidebar-toggle noselect"
@click.stop="events.emit('toggleSidebar')"
><Icon icon="menu" /></div>
<Tooltip v-if="me">
<div
class="header-button header-notifications"
:class="{ unseen: unseenNotificationsCount > 0 }"
>
<Icon icon="bell2" />
<span
v-if="unseenNotificationsCount > 0"
class="notifications-count"
>{{ unseenNotificationsCount }}</span>
</div>
<template #tooltip>
<Notifications
:notifications="notifications"
:unseen-count="unseenNotificationsCount"
@check="fetchNotifications"
@add-alert="showAddAlert = true"
/>
</template>
</Tooltip>
<Tooltip>
<div class="header-button header-account">
<div class="account">
<Icon
icon="user3-long"
class="avatar"
/>
</div>
</div>
<template #tooltip>
<Menu />
</template>
</Tooltip>
<Tooltip
class="search-compact"
:open="searching"
@open="searching = true"
@close="searching = false"
>
<button
type="button"
class="search-button"
><Icon
icon="search"
/></button>
<template #tooltip>
<Search
:searching="searching"
class="compact"
@search="searching = false"
@click.stop
/>
</template>
</Tooltip>
<Search class="search-full" />
</div>
<AddAlert
v-if="showAddAlert"
@close="showAddAlert = false"
>Alert</AddAlert>
</header>
</template>
<script>
import Menu from './menu.vue';
import Notifications from './notifications.vue';
import AddAlert from '../alerts/add.vue';
import Search from './search.vue';
import logo from '../../img/logo.svg';
function me() {
return this.$store.state.auth.user;
}
async function fetchNotifications() {
const { notifications, unseenCount } = await this.$store.dispatch('fetchNotifications');
this.notifications = notifications;
this.unseenNotificationsCount = unseenCount;
}
export default {
components: {
AddAlert,
Menu,
Notifications,
Search,
},
emits: ['toggleSidebar', 'showFilters'],
data() {
return {
logo,
searching: false,
showFilters: false,
showAddAlert: false,
notifications: [],
unseenNotificationsCount: 0,
};
},
computed: {
me,
},
watch: {
me: fetchNotifications,
},
mounted: fetchNotifications,
methods: {
fetchNotifications,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.header {
height: 3rem;
display: flex;
align-items: center;
z-index: 2;
justify-content: space-between;
background: var(--background);
color: var(--primary);
box-shadow: 0 0 3px var(--darken-weak);
font-size: 0;
}
.header-nav {
display: flex;
align-items: center;
height: 100%;
overflow: hidden;
}
.header-section {
height: 100%;
align-items: center;
display: flex;
flex-direction: row;
}
.sidebar-toggle {
display: none;
align-items: center;
height: 100%;
.icon {
display: inline-block;
fill: var(--shadow-modest);
padding: 0 1rem;
width: 1.5rem;
height: 100%;
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
.logo-link {
height: 100%;
display: inline-block;
text-decoration: none;
margin: -.25rem 1rem 0 0;
}
.header-logo {
height: 100%;
display: flex;
align-items: center;
padding: 0 0 0 1rem;
fill: var(--primary);
}
.logo {
width: 6rem;
display: flex;
}
.nav,
.nav-list {
display: inline-block;
height: 100%;
}
.nav-item {
height: 100%;
}
.nav-link {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
color: var(--shadow);
text-decoration: none;
font-size: .9rem;
font-weight: bold;
cursor: pointer;
&.active {
color: var(--primary);
.icon {
fill: var(--primary);
}
}
&:hover:not(.active) {
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.header-button {
padding: 1rem .75rem;
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
.account {
border-color: var(--primary);
}
.icon {
fill: var(--primary);
}
}
}
.header-account {
padding: 1rem 1.25rem 1rem .75rem;
}
.header-notifications {
position: relative;
padding: 1rem .75rem;
&.unseen .icon {
fill: var(--primary);
}
}
.notifications-count {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
bottom: .3rem;
left: 0;
color: var(--primary);
font-size: .6rem;
font-weight: bold;
}
.account {
width: 1.25rem;
height: 1.25rem;
display: flex;
justify-content: center;
border: solid 2px var(--shadow);
border-radius: 1.5rem;
overflow: hidden;
.avatar {
width: 1rem;
height: 1rem;
margin: .3rem 0 0 0;
fill: var(--shadow);
}
}
.search-compact {
display: none;
height: 100%;
}
.search-button {
height: 100%;
padding: .25rem 1rem 0 1rem;
background: none;
border: none;
outline: none;
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--shadow-strong);
}
}
}
@media(max-width: $breakpoint-kilo) {
.search-full {
display: none;
}
.search-compact {
display: flex;
}
.header-account {
padding: 1rem .5rem 1rem 1rem;
}
}
@media(max-width: $breakpoint-small) {
.sidebar-toggle {
display: flex;
}
.logo-link {
margin: -.25rem 1.25rem 0 0;
}
.nav-link {
padding: 0.05rem .75rem 0 .75rem;
}
.search-compact {
display: none;
}
.header-account,
.header-notifications {
display: none;
}
}
</style>

View File

@ -1,189 +0,0 @@
<template>
<div class="menu">
<ul class="menu-items noselect">
<RouterLink
v-if="login && me"
:to="{ name: 'user', params: { username: me.username } }"
class="menu-username"
>{{ me.username }}</RouterLink>
<RouterLink
v-if="me && favorites"
:to="{ name: 'stash', params: { stashId: favorites.id, stashSlug: favorites.slug, username: me.username, range: 'scenes', pageNumber: 1 } }"
class="menu-item"
><Icon icon="heart7" />Favorites</RouterLink>
<RouterLink
v-else-if="login"
:to="{ name: 'login', query: { ref: $route.path } }"
class="menu-item"
@click.stop
>
<Icon icon="enter2" />Log in
</RouterLink>
<li
v-show="!sfw"
class="menu-item"
@click.stop="setSfw(true)"
>
<Icon
icon="flower"
class="toggle noselect"
/>Safe mode
</li>
<li
v-show="sfw"
class="menu-item"
@click.stop="setSfw(false)"
>
<Icon
icon="fire"
class="toggle noselect"
/>Filth mode
</li>
<li
v-show="theme === 'light'"
class="menu-item"
@click.stop="setTheme('dark')"
>
<Icon
icon="moon"
class="toggle noselect"
/>Dark theme
</li>
<li
v-show="theme === 'dark'"
class="menu-item"
@click.stop="setTheme('light')"
>
<Icon
icon="sun"
class="toggle noselect"
/>Light theme
</li>
<li
class="menu-item"
@click="events.emit('toggleSettings', true)"
>
<Icon icon="cog" />Settings
</li>
<li
v-if="login && me"
class="menu-item"
@click.stop="$store.dispatch('logout')"
>
<Icon icon="exit2" />Log out
</li>
</ul>
</div>
</template>
<script>
import { mapState } from 'vuex';
function sfw(state) {
return state.ui.sfw;
}
function theme(state) {
return state.ui.theme;
}
function login(state) {
return state.auth.login;
}
function signup(state) {
return state.auth.signup;
}
function favorites() {
return this.me?.stashes.find((stash) => stash.primary);
}
function me(state) {
return state.auth.user;
}
function setTheme(newTheme) {
this.$store.dispatch('setTheme', newTheme);
}
function setSfw(enabled) {
this.$store.dispatch('setSfw', enabled);
}
export default {
computed: {
...mapState({
login,
signup,
sfw,
theme,
me,
}),
favorites,
},
methods: {
setSfw,
setTheme,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.menu-items {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
display: flex;
padding: .75rem 1rem .75rem .75rem;
color: var(--text);
text-decoration: none;
.icon {
fill: var(--shadow);
margin: 0 1rem 0 0;
}
&.disabled {
color: var(--shadow-weak);
cursor: default;
.icon {
fill: var(--shadow-weak);
}
}
&:hover:not(.disabled) {
cursor: pointer;
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.menu-username {
display: block;
font-weight: bold;
color: var(--shadow-strong);
font-size: .9rem;
padding: .75rem 1rem;
border-bottom: solid 1px var(--shadow-hint);
text-align: center;
text-decoration: none;
}
</style>

View File

@ -1,341 +0,0 @@
<template>
<div class="notifications">
<div class="notifications-header">
<h4 class="notifications-title">Notifications</h4>
<div class="notifications-actions">
<Icon
v-if="unseenCount > 0"
v-tooltip="'Mark all as seen'"
icon="checkmark"
@click="checkNotifications"
/>
<Icon
v-tooltip="'Add alert'"
icon="plus3"
@click="$emit('addAlert')"
/>
</div>
</div>
<div class="notifications-body">
<div
v-if="notifications.length === 0"
class="notifications-empty"
>No notifications</div>
<ul
v-else
class="nolist"
>
<li
v-for="notification in notifications"
:key="`notification-${notification.id}`"
:class="{ unseen: !notification.seen }"
class="notification"
@click="checkNotification(notification.id, true)"
>
<router-link
:to="`/scene/${notification.scene.id}/${notification.scene.slug}`"
class="notification-link"
>
<img
:src="getPath(notification.scene.poster, 'thumbnail')"
class="poster"
>
<div class="notification-body">
<div class="notification-row notification-title">
<img
v-if="notification.scene.entity.type === 'network' || notification.scene.entity.independent"
v-tooltip="notification.scene.entity.name"
:src="`/img/logos/${notification.scene.entity.slug}/favicon_${theme === 'dark' ? 'light' : 'dark'}.png`"
class="notification-favicon"
>
<img
v-else
v-tooltip="notification.scene.entity.name"
:src="`/img/logos/${notification.scene.entity.parent.slug}/favicon_${theme === 'dark' ? 'light' : 'dark'}.png`"
class="notification-favicon"
>
New&nbsp;<ul
v-if="notification.alert?.tags.length > 0"
class="nolist notification-tags"
>
<li
v-for="tag in notification.alert.tags"
:key="`notification-tag-${tag.slug}`"
class="notification-tag"
>{{ tag.name }}</li>&nbsp;
</ul>scene
</div>
<div class="notification-row notification-details">
<span class="notification-date">{{ formatDate(notification.scene.date, 'MMM D') }}</span>
<ul
v-if="notification.scene.actors.length > 0"
class="nolist notification-actors"
>
<li
v-for="actor in notification.scene.actors"
:key="`notification-actor-${actor.slug}`"
class="notification-actor"
>{{ actor.name }}</li>
</ul>
</div>
</div>
<Icon
v-if="!notification.seen"
v-tooltip="'Mark as seen'"
icon="checkmark"
class="notification-check"
@click.prevent.stop="checkNotification(notification.id)"
/>
<Icon
v-if="notification.alert"
v-tooltip="`You set an alert for scenes with <strong>${notification.alert.all ? 'all of' : 'any of'}</strong> <strong>${notification.alert.actors.map(actor => actor.name).join(', ') || 'any actor'}</strong> containing <strong>${notification.alert.tags.map(tag => tag.name).join(', ') || 'any tags'}</strong> from <strong>${notification.alert.entities.map((entity) => entity.name).join(', ') || 'any channel'}</strong> matching <strong>${notification.alert.matches.map((match) => `${match.property}: ${match.expression}`).join(', ') || 'any text'}</strong>`"
icon="question5"
@click.prevent.stop
/>
</router-link>
</li>
</ul>
</div>
<div @click="events.emit('blur')">
<router-link
to="/notifications"
class="notification-link notification-more"
>See all</router-link>
</div>
</div>
</template>
<script>
async function checkNotifications() {
await this.$store.dispatch('checkNotifications');
this.$emit('check');
}
async function checkNotification(notificationId, blur) {
await this.$store.dispatch('checkNotification', notificationId);
this.$emit('check');
if (blur) {
this.events.emit('blur');
}
}
export default {
props: {
notifications: {
type: Array,
default: () => [],
},
unseenCount: {
type: Number,
default: 0,
},
},
emits: ['addAlert'],
data() {
return {
showAddAlert: false,
};
},
methods: {
checkNotifications,
checkNotification,
},
};
</script>
<style lang="scss" scoped>
.notifications {
width: 30rem;
max-height: calc(100vh - 5rem);
display: flex;
flex-direction: column;
}
.notifications-header {
display: flex;
justify-content: space-between;
.icon {
padding: .5rem;
fill: var(--shadow);
&:first-child {
padding: .5rem .5rem .5rem 1.5rem;
}
&:last-child {
padding: .5rem 1.5rem .5rem .5rem;
}
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
}
.notifications-title {
display: inline-block;
padding: .5rem 1rem;
margin: 0;
color: var(--shadow);
font-size: 1rem;
font-weight: bold;
}
.notifications-body {
flex-grow: 1;
overflow-y: auto;
box-shadow: 0 0 3px var(--shadow-weak);
}
.notifications-empty {
padding: .5rem 1rem;
color: var(--shadow);
}
.notification {
display: block;
border-right: solid .5rem var(--shadow-touch);
color: var(--text);
&.unseen {
border-right: solid .5rem var(--primary);
}
.icon {
padding: 1.3rem .5rem;
fill: var(--shadow-weak);
&.notification-check {
padding: 1.3rem .5rem 1.3rem 1rem;
}
&:last-child {
padding: 1.3rem 1rem 1.3rem .5rem;
}
&:hover {
fill: var(--primary);
}
}
&:not(:last-child) {
border-bottom: solid 1px var(--shadow-hint);
margin: 0 0 -1px 0;
}
&:hover {
background: var(--shadow-touch);
&:not(.unseen) {
border-right: solid .5rem var(--shadow-weak);
}
}
}
.notification-link {
display: flex;
align-items: stretch;
color: inherit;
text-decoration: none;
}
.notification-body {
flex-grow: 1;
padding: .4rem 0 0 0;
overflow: hidden;
}
.notification-row {
display: flex;
overflow: hidden;
}
.notification-title {
margin: .15rem .5rem .3rem .5rem;
}
.notification-favicon {
width: 1rem;
height: 1rem;
margin: 0 .5rem 0 0;
}
.notification-tags {
white-space: nowrap;
}
.notification-actors {
padding: 0 .5rem;
height: 1.25rem;
display: inline-block;
overflow: hidden;
}
.notification-date {
width: 3rem;
flex-shrink: 0;
padding: .25rem .25rem .35rem .25rem;
border-right: solid 1px var(--shadow-hint);
border-top: solid 1px var(--shadow-hint);
color: var(--shadow-strong);
font-size: .8rem;
text-align: center;
}
.notification-actor,
.notification-tag {
white-space: nowrap;
&:not(:last-child)::after {
content: ',';
padding: 0 .1rem 0 0;
}
}
.notification-actor {
padding: .25rem .15rem .35rem 0;
color: var(--shadow-strong);
font-size: .9rem;
}
.notification-tag {
font-weight: bold;
}
.notification-more {
display: block;
padding: .5rem 1rem;
color: var(--shadow);
text-align: center;
font-size: .9rem;
font-weight: bold;
&:hover {
color: var(--primary);
}
}
.poster {
width: 6rem;
height: 3.6rem;
object-fit: cover;
object-position: center;
}
</style>

View File

@ -1,154 +0,0 @@
<template>
<form
class="search"
@submit.prevent="search"
>
<input
ref="search"
v-model="query"
type="search"
class="search-input"
placeholder="Search..."
>
<button
type="submit"
class="search-button"
><Icon
icon="search"
/></button>
</form>
</template>
<script>
async function search() {
if (this.query) {
this.$router.push({ name: 'search', query: { q: this.query } });
this.$emit('search');
}
}
function searching(to) {
if (to) {
this.$refs.search.focus();
}
}
function route(to) {
if (to.name !== 'search') {
this.query = null;
}
}
export default {
props: {
searching: {
type: Boolean,
default: false,
},
},
data() {
return {
query: this.$route.query ? this.$route.query.q : null,
};
},
emits: ['search'],
watch: {
$route: route,
searching,
},
methods: {
search,
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.search {
height: 100%;
max-width: 20rem;
display: flex;
flex-grow: 1;
align-items: center;
justify-content: flex-end;
border-left: solid 1px var(--shadow-hint);
margin: 0 .25rem 0 0;
&.compact {
padding: 0;
border: none;
.search-input {
padding: .75rem .5rem .75rem .75rem;
}
.search-button {
padding: 1rem 1rem .75rem .25rem;
margin: 0;
}
}
}
.search-input {
height: 100%;
width: 100%;
padding: .5rem 0 .5rem .75rem;
border: none;
color: var(--text);
background: var(--background);
outline: none;
font-size: 1rem;
outline: none;
&::placeholder {
color: var(--shadow);
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
padding: .5rem;
position: relative;
right: 0;
color: var(--text);
background: url('/img/cancel-circle2.svg');
opacity: .25;
&:hover {
opacity: .5;
cursor: pointer;
}
}
&:focus {
&::placeholder {
color: var(--shadow-weak);
}
& + .search-button:not(:hover) .icon {
fill: var(--shadow);
}
}
}
.search-button {
height: 100%;
padding: 0 1.25rem 0 1rem;
background: none;
border: none;
margin: .3rem 0 0 0;
outline: none;
.icon {
fill: var(--shadow-weak);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
</style>

View File

@ -1,115 +0,0 @@
<template>
<div class="home">
<div class="content-inner">
<div
v-if="config.campaigns.home"
class="campaign-container"
>
<Campaign
:min-ratio="6"
/>
</div>
<FilterBar
ref="filter"
:fetch-releases="fetchReleases"
:show-filters="false"
:items-total="totalCount"
:items-per-page="limit"
:content="$refs.content"
:show-total="false"
/>
<Releases
:releases="releases"
:done="done"
/>
<Pagination
v-if="totalCount > 0"
:items-total="totalCount"
:items-per-page="limit"
class="pagination-bottom"
/>
</div>
<Footer />
</div>
</template>
<script>
import config from 'config';
import FilterBar from '../filters/filter-bar.vue';
import Releases from '../releases/releases.vue';
import Pagination from '../pagination/pagination.vue';
import Campaign from '../campaigns/campaign.vue';
async function fetchReleases(scroll = true) {
this.done = false;
const { releases, totalCount } = await this.$store.dispatch('fetchReleases', {
limit: this.limit,
range: this.$route.params.range,
pageNumber: Number(this.$route.params.pageNumber) || 1,
});
this.totalCount = totalCount;
this.releases = releases;
this.done = true;
if (scroll) {
this.$refs.filter?.$el.scrollIntoView();
}
}
async function mounted() {
this.pageTitle = '';
await this.fetchReleases();
}
export default {
components: {
Campaign,
FilterBar,
Releases,
Pagination,
},
data() {
return {
releases: [],
networks: [],
pageTitle: null,
limit: Number(this.$route.query.limit || 30) - config.campaigns.tiles, // reserve one campaign spot
totalCount: 0,
from: null,
done: false,
};
},
watch: {
$route: fetchReleases,
'$store.state.ui.tagFilter': fetchReleases,
},
mounted,
methods: {
fetchReleases,
},
};
</script>
<style lang="scss" scoped>
.home {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.campaign-container {
display: flex;
align-items: center;
justify-content: center;
padding: .75rem 1rem .25rem 1rem;
background: var(--background-dim);
}
</style>

View File

@ -1,61 +0,0 @@
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<script>
export default {
props: {
icon: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
active: {
type: Boolean,
default: false,
},
},
data() {
return {
svg: null,
};
},
beforeMount() {
this.svg = require(`../../img/icons/${this.icon}.svg`).default; // eslint-disable-line global-require, import/no-dynamic-require
},
};
</script>
<style lang="scss">
@import '../../css/theme';
.icon {
fill: var(--text);
display: inline-block;
flex-shrink: 0;
width: 1rem;
height: 1rem;
svg {
width: 100%;
height: 100%;
}
&.active {
fill: var(--shadow);
&:hover {
fill: var(--text);
cursor: pointer;
}
}
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<div class="load-container">
<div class="load-ellipsis">
<div />
<div />
<div />
<div />
</div>
</div>
</template>
<style lang="scss" scoped>
.load-container {
display: inline-flex;
position: relative;
padding: 1rem;
}
.load-ellipsis {
display: inline-block;
position: relative;
width: 5rem;
height: .75rem;
}
.load-ellipsis div {
position: absolute;
top: 0;
width: .75rem;
height: .75rem;
border-radius: 50%;
background: var(--primary);
}
.load-ellipsis div:nth-child(1) {
left: .5rem;
animation: load-ellipsis1 0.5s infinite linear;
}
.load-ellipsis div:nth-child(2) {
left: .5rem;
animation: load-ellipsis2 0.5s infinite linear;
}
.load-ellipsis div:nth-child(3) {
left: 2rem;
animation: load-ellipsis3 0.5s infinite linear;
}
.load-ellipsis div:nth-child(4) {
left: 3.5rem;
animation: load-ellipsis4 0.5s infinite linear;
}
@keyframes load-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(0.5);
}
}
@keyframes load-ellipsis2 {
0% {
transform: translate(0, 0) scale(0.5);
}
100% {
transform: translate(1.5rem, 0) scale(1);
}
}
@keyframes load-ellipsis3 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(1.5rem, 0) scale(0.5);
}
}
@keyframes load-ellipsis4 {
0% {
transform: scale(0.5);
}
100% {
transform: scale(0);
}
}
</style>

View File

@ -1,205 +0,0 @@
<template>
<div class="networks">
<div class="content-inner">
<SearchBar
:placeholder="`Search ${channelCount} channels in ${entities.length} networks`"
:eager="true"
/>
<span
v-if="done && entities.length === 0"
class="empty"
>No results for "{{ $route.query.query }}"</span>
<template v-else>
<h2 class="heading">Popular</h2>
<div
class="entity-tiles"
>
<Entity
v-for="entity in popularEntities"
:key="entity.parent ? `entity-tile-${entity.parent.slug}-${entity.slug}` : `entity-tile-${entity.slug}`"
:entity="entity"
/>
</div>
<h2 class="heading">All networks</h2>
<div
class="entity-tiles"
>
<Entity
v-for="entity in entities"
:key="entity.parent ? `entity-tile-${entity.parent.slug}-${entity.slug}` : `entity-tile-${entity.slug}`"
:entity="entity"
/>
</div>
</template>
</div>
<Footer />
</div>
</template>
<script>
import Entity from '../entities/tile.vue';
import SearchBar from '../search/bar.vue';
async function fetchEntities() {
if (this.$route.query.query) {
await this.searchEntities();
return;
}
this.done = false;
this.entities = await this.$store.dispatch('fetchEntities', {
type: 'network',
entitySlugs: [],
});
this.done = true;
}
async function searchEntities() {
this.done = false;
this.entities = await this.$store.dispatch('searchEntities', {
query: this.$route.query.query,
limit: 20,
});
this.done = true;
}
function popularEntities() {
const entitiesBySlug = Object.fromEntries(this.entities.map((entity) => [entity.slug, entity]));
return [
'21sextury',
'adulttime',
'amateurallure',
'analvids',
'bamvisions',
'bang',
'bangbros',
'blowpass',
'brazzers',
'burningangel',
'digitalplayground',
'dogfartnetwork',
'dorcel',
'elegantangel',
'evilangel',
'fakehub',
'girlsway',
'hookuphotshot',
'hussiepass',
'insex',
'julesjordan',
'kellymadison',
'kink',
'mofos',
'naughtyamerica',
'newsensations',
'pervcity',
'pornpros',
'private',
'realitykings',
'teamskeet',
'twistys',
'vixen',
'xempire',
].map((slug) => entitiesBySlug[slug]).filter(Boolean);
}
async function mounted() {
this.pageTitle = 'Channels';
await this.fetchEntities();
}
function channelCount() {
return this.entities.reduce((acc, entity) => acc + entity.childrenTotal, 0);
}
export default {
components: {
Entity,
SearchBar,
},
data() {
return {
done: false,
pageTitle: null,
entities: [],
};
},
computed: {
channelCount,
popularEntities,
},
watch: {
$route: fetchEntities,
},
mounted,
methods: {
fetchEntities,
searchEntities,
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.networks {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.content-inner {
padding: 0 1rem;
}
.search {
margin: 1rem 0 0 0;
}
.entity-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-gap: .5rem;
padding: 1rem 0;
.tile {
height: 6rem;
}
}
.empty {
display: block;
margin: 1rem 0;
color: var(--shadow);
font-size: 1.25rem;
font-weight: bold;
}
.heading {
margin: 1rem 0 0 0;
}
@media(max-width: $breakpoint2) {
.entity-tiles {
grid-gap: .5rem;
}
}
@media(max-width: $breakpoint0) {
.entity-tiles {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
}
</style>

View File

@ -1,76 +0,0 @@
<template>
<div
ref="page"
class="notifications-container"
>
<h1 class="heading">Notifications</h1>
<ul class="notifications nolist">
<li
v-for="notification in notifications"
:key="notification.id"
>
<SceneTile :release="notification.scene" />
</li>
</ul>
<Pagination
:items-total="totalCount"
:items-per-page="10"
/>
</div>
</template>
<script>
import Pagination from '../pagination/pagination.vue';
import SceneTile from '../releases/scene-tile.vue';
async function fetchNotifications() {
const { notifications, totalCount, unseenCount } = await this.$store.dispatch('fetchNotifications', {
page: this.$route.params.pageNumber,
limit: this.limit,
});
this.notifications = notifications;
this.unseenNotificationsCount = unseenCount;
this.totalCount = totalCount;
this.$emit('scroll');
}
export default {
components: {
Pagination,
SceneTile,
},
data() {
return {
notifications: [],
limit: 10,
totalCount: 0,
unseenNotificationsCount: 0,
};
},
emits: ['scroll'],
watch: {
$route: fetchNotifications,
},
mounted: fetchNotifications,
};
</script>
<style lang="scss" scoped>
.notifications-container {
padding: 1rem;
}
.notifications {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
grid-gap: .5rem;
}
.pagination {
margin: 1rem 0 0 0;
}
</style>

View File

@ -1,197 +0,0 @@
<template>
<div
v-if="itemsTotal > 0 || !hideEmpty"
class="pagination"
>
<span
v-show="pageNumber > 1"
class="cursors"
>
<router-link
class="pagination-button cursor"
:to="{ name, params: { ...params, pageNumber: 1 }, query: $route.query }"
><Icon icon="first2" /></router-link>
<router-link
class="pagination-button cursor"
:to="{ name, params: { ...params, pageNumber: pageNumber - 1 }, query: $route.query }"
><Icon icon="arrow-left" /></router-link>
</span>
<span
v-show="pageNumber === 1"
class="cursors"
>
<span class="pagination-button cursor disabled"><Icon icon="first2" /></span>
<span class="pagination-button cursor disabled"><Icon icon="arrow-left" /></span>
</span>
<span class="pages pages-before">
<router-link
v-for="pageX in pageNumber - 1"
:key="`page-${pageX}`"
:to="{ name, params: { ...params, pageNumber: pageNumber - pageX }, query: $route.query }"
class="pagination-button page"
> {{ pageNumber - pageX }} </router-link>
</span>
<router-link
:key="`page-${pageNumber}`"
:to="{ name, params: { ...params, pageNumber }, query: $route.query }"
class="pagination-button page active"
> {{ pageNumber }} </router-link>
<span class="pages pages-after">
<router-link
v-for="pageX in (pageCount - pageNumber)"
:key="`page-${pageX + pageNumber}`"
:to="{ name, params: { ...params, pageNumber: pageX + pageNumber }, query: $route.query }"
class="pagination-button page"
> {{ pageX + pageNumber }} </router-link>
</span>
<span
v-show="pageNumber < pageCount"
class="cursors"
>
<router-link
class="pagination-button cursor"
:to="{ name, params: { ...params, pageNumber: pageNumber + 1 }, query: $route.query }"
><Icon icon="arrow-right" /></router-link>
<router-link
class="pagination-button cursor"
:to="{ name, params: { ...params, pageNumber: pageCount }, query: $route.query }"
><Icon icon="last2" /></router-link>
</span>
<span
v-show="pageNumber === pageCount"
class="cursors"
>
<span class="pagination-button cursor disabled"><Icon icon="arrow-right" /></span>
<span class="pagination-button cursor disabled"><Icon icon="last2" /></span>
</span>
</div>
</template>
<script>
function pageNumber() {
return Number(this.$route.params.pageNumber) || 1;
}
function pageCount() {
const count = Math.max(Math.ceil(this.itemsTotal / this.itemsPerPage), 1);
return count;
}
export default {
props: {
itemsTotal: {
type: Number,
default: 0,
},
itemsPerPage: {
type: Number,
default: 10,
},
hideEmpty: {
type: Boolean,
default: true,
},
name: {
type: String,
default: null,
},
params: {
type: Object,
default: null,
},
},
computed: {
pageNumber,
pageCount,
},
};
</script>
<style lang="scss" scoped>
.pagination {
display: flex;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
height: 3rem;
}
.pagination-top {
margin: 0 0 1rem 0;
}
.pagination-bottom {
margin: .5rem 0 1rem 0;
}
.pagination-button {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin: .1rem 0 2rem 0;
background: var(--background);
color: var(--shadow);
font-size: 1rem;
font-weight: bold;
text-decoration: none;
box-shadow: 0 0 3px var(--shadow-hint);
.icon {
width: .8rem;
height: .8rem;
margin: 0 0 .125rem 0;
fill: var(--shadow);
}
&:hover:not(.active):not(.disabled) {
color: var(--shadow-strong);
.icon {
fill: var(--shadow-strong);
}
}
&.active {
color: var(--primary);
}
&.disabled {
color: var(--shadow-weak);
.icon {
fill: var(--shadow-weak);
}
}
}
.pages {
max-width: 10rem;
display: inline-flex;
flex-wrap: wrap;
}
.pages-before {
flex-direction: row-reverse;
}
.cursors {
flex-shrink: 0;
}
.cursors {
margin: 0 .75rem;
font-size: 0;
}
</style>

View File

@ -1,386 +0,0 @@
<template>
<div class="media-container">
<div
class="media"
:class="{ center: (release.photos?.length || 0) + (release.caps?.length || 0) + (release.scenesPhotos?.length || 0) < 2, preview: !me }"
>
<div
v-if="release.trailer || release.teaser"
class="trailer-container"
>
<Player
v-if="release.trailer"
:video="release.trailer"
:poster="poster"
class="item trailer"
@play="playing = true; paused = false;"
@pause="playing = false; paused = true;"
/>
<Player
v-else-if="release.teaser && /^video\//.test(release.teaser.mime)"
:video="release.teaser"
:poster="poster"
:alt="release.title"
:class="{ sfw: sfw && paused }"
class="item trailer"
@play="playing = true; paused = false;"
@pause="playing = false; paused = true;"
/>
<img
v-else-if="release.teaser && /^image\//.test(release.teaser.mime)"
:src="getPath(release.teaser, 'thumbnail', { original: true })"
:alt="release.title"
:width="release.teaser.thumbnailWidth"
:height="release.teaser.thumbnailHeight"
loading="lazy"
class="item trailer"
>
<span
v-if="release.poster || release.teaser"
class="poster-links"
>
<a
v-if="release.teaser"
v-tooltip="'View teaser'"
:href="getPath(release.teaser)"
:class="{ playing }"
target="_blank"
rel="noopener noreferrer"
class="poster-link"
><Icon icon="film4" /></a>
<a
v-if="release.poster"
v-tooltip="'View poster'"
:href="getPath(release.poster)"
:class="{ playing }"
target="_blank"
rel="noopener noreferrer"
class="poster-link"
><Icon icon="image" /></a>
</span>
<span
v-if="sfw && !playing"
class="warning"
>
<Icon icon="warning2" />NSFW
</span>
</div>
<template v-if="release.covers?.length > 0">
<div
v-for="cover in release.covers"
:key="`cover-${cover.id}`"
class="item-container"
>
<a
:href="getPath(cover)"
target="_blank"
rel="noopener noreferrer"
>
<img
:src="getPath(cover, 'thumbnail')"
:style="{ 'background-image': getBgPath(cover, 'lazy') }"
:width="cover.thumbnailWidth"
:height="cover.thumbnailHeight"
class="item cover"
loading="lazy"
@load="$emit('load', $event)"
>
</a>
</div>
</template>
<div
v-for="photo in photos"
:key="`media-${photo.id}`"
class="item-container"
>
<a
:href="getPath(photo)"
:class="{ sfw }"
class="item-link"
target="_blank"
rel="noopener noreferrer"
>
<img
:src="getPath(photo, 'thumbnail')"
:style="{ 'background-image': `url('${getPath(photo, 'lazy')}` }"
:alt="`Photo ${photo.index + 1}`"
:width="photo.thumbnailWidth"
:height="photo.thumbnailHeight"
loading="lazy"
class="item"
@load="$emit('load', $event)"
>
<span
v-if="sfw"
class="warning"
>
<Icon icon="warning2" />NSFW
</span>
</a>
</div>
<div
v-if="!me"
class="item-container item-more"
><router-link
:to="{ name: 'login', query: { ref: $route.path } }"
class="link"
>Log in</router-link>&nbsp;for more photos, trailers and features!</div>
</div>
</div>
</template>
<script>
import Player from '../video/player.vue';
function sfw() {
return this.$store.state.ui.sfw;
}
function me() {
return this.$store.state.auth.user;
}
function poster() {
if (this.release.poster) {
return this.getPath(this.release.poster, 'thumbnail');
}
if (this.release.covers?.length > 0) {
return this.getPath(this.release.covers[0], 'thumbnail');
}
if (this.photos?.length > 0) {
return this.getPath(this.release.photos[0], 'thumbnail');
}
return null;
}
function photos() {
const clips = this.release.clips || [];
const clipPostersById = clips.reduce((acc, clip) => ({ ...acc, [clip.poster.id]: clip.poster }), {});
const uniqueClipPosters = Array.from(new Set(clips.map((clip) => clip.poster.id) || [])).map((posterId) => clipPostersById[posterId]);
const photosWithClipPosters = (this.release.photos || []).concat(this.release.caps || []).concat(this.release.scenesPhotos || []).concat(uniqueClipPosters);
if (this.release.trailer || (this.release.teaser && this.release.teaser.mime !== 'image/gif')) {
// if (this.release.trailer) {
// poster will be on trailer video
return photosWithClipPosters;
}
if (this.release.poster) {
// no trailer, add poster to photos
return [this.release.poster].concat(photosWithClipPosters);
}
// no poster available
return photosWithClipPosters;
}
export default {
components: {
Player,
},
props: {
release: {
type: Object,
default: null,
},
expanded: {
type: Boolean,
default: false,
},
test: {
type: String,
default: null,
},
},
emits: ['load'],
data() {
return {
player: null,
playing: false,
paused: false,
};
},
computed: {
me,
photos,
poster,
sfw,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.media {
flex-shrink: 0;
white-space: nowrap;
font-size: 0;
}
.media.center {
width: 1200px;
max-width: 100%;
display: flex;
margin: 0 auto;
}
.poster-links {
display: flex;
position: absolute;
top: 0;
right: 0;
}
.poster-link {
transition: opacity .1s ease;
padding: .5rem;
.icon {
width: 1.5rem;
height: 1.5rem;
fill: var(--lighten-strong);
filter: drop-shadow(0 0 1px var(--darken-weak));
}
&.playing {
opacity: 0;
}
&:first-child {
padding-left: .75rem;
}
&:last-child {
padding-right: .75rem;
}
&:hover {
cursor: pointer;
opacity: 1;
.icon {
fill: var(--text-light);
}
}
}
.item-container,
.trailer-container {
height: 100%;
max-width: 100%;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.warning {
display: none;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
background: var(--darken-weak);
color: var(--text-light);
font-size: 1.2rem;
font-weight: bold;
text-shadow: 0 0 3px var(--darken-strong);
pointer-events: none;
animation: alert .5s ease infinite .1s;
.icon {
display: block;
fill: var(--text-light);
width: 3rem;
height: 3rem;
margin: 0 0 .25rem 0;
filter: drop-shadow(0 0 3px var(--darken));
animation: alert .5s ease infinite .1s;
}
}
&:hover .warning {
display: inline-flex;
}
}
.item {
max-width: 100%;
width: auto;
height: 18rem;
box-shadow: 0 0 3px var(--shadow-weak);
background-size: cover;
}
.item-more {
height: auto;
flex-grow: 1;
align-items: center;
padding: .5rem 2rem;
color: var(--text-light);
text-shadow: 0 0 3px var(--darken);
font-weight: bold;
font-size: 1rem;
.link {
color: inherit;
text-decoration: underline;
}
}
.trailer-container {
width: 32rem;
max-width: 100%;
}
.trailer {
width: 100%;
max-width: 32rem;
max-height: 100%;
&.sfw {
filter: blur(2rem);
}
}
@keyframes alert {
0% {
color: var(--text-light);
fill: var(--text-light);
}
50% {
color: var(--alert);
fill: var(--alert);
}
}
@media(max-width: $breakpoint-micro) {
.media.center.preview {
flex-direction: column;
}
.item-more {
font-size: .9rem;
}
.media:not(.expanded) .item,
.trailer-container {
height: 56vw; /* 16:9 ratio for full-width video */
}
}
</style>

View File

@ -1,235 +0,0 @@
<template>
<div
v-if="timeline"
class="timeline"
>
<ul class="timeline-items nolist">
<li
v-for="chapter in timeline"
:key="`chapter-${chapter.id}`"
:style="{ left: `${(chapter.time / duration) * 100}%` }"
:title="formatDuration(chapter.time)"
class="timeline-item"
><router-link
:to="`/tag/${chapter.tags[0].slug}`"
class="link"
>{{ chapter.tags[0]?.name || '&nbsp;' }}</router-link></li>
</ul>
</div>
<ul
v-else
class="chapters nolist"
>
<li
v-for="chapter in chapters"
:key="`chapter-${chapter.id}`"
class="chapter"
>
<a
v-if="chapter.poster"
:href="getPath(chapter.poster)"
target="_blank"
rel="noopener noreferrer"
>
<img
:src="getPath(chapter.poster, 'thumbnail')"
class="chapter-poster"
>
</a>
<span class="chapter-details">
<span
v-if="chapter.time"
v-tooltip="'Time in video'"
class="chapter-time"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span
v-if="chapter.duration"
v-tooltip="'Duration'"
class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
</span>
<div class="chapter-info">
<h3
v-if="chapter.title"
class="chapter-row chapter-title"
:title="chapter.title"
>{{ chapter.title }}</h3>
<p
v-if="chapter.description"
class="chapter-row chapter-description"
>{{ chapter.description }}</p>
<Tags
:tags="chapter.tags"
class="chapter-row chapter-tags"
/>
</div>
</li>
</ul>
</template>
<script>
import Tags from './tags.vue';
function timeline() {
if (this.chapters.every(chapter => chapter.time)) {
return this.chapters.filter(chapter => chapter.tags?.length > 0);
}
return null;
}
export default {
components: {
Tags,
},
props: {
chapters: {
type: Array,
default: () => [],
},
},
data() {
const lastChapter = this.chapters[this.chapters.length - 1];
return {
duration: lastChapter.time + lastChapter.duration,
};
},
computed: {
timeline,
},
};
</script>
<style lang="scss">
.chapter-tags.tags-container {
margin: 0 0 .5rem 0;
.tags {
padding: 2px 0 0 2px;
}
}
</style>
<style lang="scss" scoped>
@import 'breakpoints';
.chapters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-gap: 1rem;
}
.chapter {
display: flex;
flex-direction: column;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak);
margin: 0 0 .5rem 0;
font-size: 0;
}
.chapter-poster {
width: 100%;
height: 10rem;
object-fit: cover;
object-position: center;
}
.chapter-details {
height: 1.75rem;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 .5rem;
margin: 0 0 .75rem 0;
color: var(--text-light);
background: var(--profile);
font-size: .9rem;
font-weight: bold;
.icon {
fill: var(--text-light);
margin: -.1rem .5rem 0 0;
}
}
.chapter-duration,
.chapter-time {
display: flex;
align-items: center;
}
.chapter-duration .icon {
/* narrower icon */
margin: -.1rem .3rem 0 0;
}
.chapter-info {
padding: 0 .5rem;
font-size: 1rem;
}
.chapter-row {
margin: 0 0 .5rem 0;
}
.chapter-title {
padding: 0;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-description {
line-height: 1.5;
}
.timeline-items {
position: relative;
height: 5rem;
border-bottom: solid 1px var(--shadow-weak);
}
.timeline-item {
position: absolute;
bottom: -.25rem;
padding: .1rem .5rem;
border-radius: .6rem;
color: var(--primary);
background: var(--background);
transform: rotate(-60deg);
transform-origin: 0 50%;
box-shadow: 0 0 3px var(--shadow-weak);
font-size: .8rem;
font-weight: bold;
.link {
color: inherit;
}
&:before {
content: '';
display: inline-block;
width: 1rem;
height: 2px;
position: absolute;
left: calc(-1rem + 1px);
margin: .3rem .5rem 0 0;
background: var(--primary);
}
}
@media(max-width: $breakpoint-micro) {
.chapters {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
}
</style>

View File

@ -1,227 +0,0 @@
<template>
<div class="details">
<div class="column">
<div class="tidbits">
<a
:title="release.url && `View scene on ${release.entity.name}`"
:href="release.url"
:class="{ link: release.url }"
target="_blank"
rel="noopener noreferrer"
class="tidbit date nolink"
>
<span class="date-compact">{{ release.date ? formatDate(release.date, 'MMM D, YYYY', release.datePrecision) : 'Date N/A' }}</span>
<span class="date-full">{{ release.date ? formatDate(release.date, 'MMMM D, YYYY', release.datePrecision) : 'Date unknown' }}</span>
<Icon
v-if="release.url"
icon="share2"
/>
</a>
</div>
<div class="site">
<template v-if="release.entity.type === 'channel' && release.entity.parent && !release.entity.independent">
<a
v-if="release.entity.parent.hasLogo"
:href="`/network/${release.entity.parent.slug}`"
class="logo-link"
>
<img
:src="`/img/logos/${release.entity.parent.slug}/thumbs/network.png`"
:title="release.entity.parent.name"
:alt="release.entity.parent.name"
class="logo logo-parent"
>
</a>
<a
v-else
:href="`/network/${release.entity.parent.slug}`"
class="logo-link logo-name"
>{{ release.entity.parent.name }}</a>
<span class="chain">presents</span>
<a
v-if="release.entity.hasLogo"
:href="`/${release.entity.type}/${release.entity.slug}`"
class="logo-link"
>
<img
v-if="release.entity.type === 'network'"
:src="`/img/logos/${release.entity.slug}/thumbs/network.png`"
:title="release.entity.name"
class="logo logo-site"
>
<img
v-else
:src="`/img/logos/${release.entity.parent.slug}/thumbs/${release.entity.slug}.png`"
:title="release.entity.name"
class="logo logo-site"
>
</a>
<a
v-else
:href="`/${release.entity.type}/${release.entity.slug}`"
class="logo-link logo-name"
>{{ release.entity.name }}</a>
</template>
<a
v-else
:href="`/${release.entity.type}/${release.entity.slug}`"
>
<img
:src="`/img/logos/${release.entity.slug}/thumbs/network.png`"
:title="release.entity.name"
class="logo logo-site"
>
</a>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
release: {
type: Object,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.details {
background: var(--profile);
color: var(--text-light);
box-shadow: 0 0 3px var(--shadow-weak);
cursor: default;
.column {
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
}
.link {
color: var(--text-light);
.icon {
fill: var(--lighten);
}
&:hover {
color: var(--text-light);
.icon {
fill: var(--text-light);
}
}
}
}
.tidbits {
flex-shrink: 0;
height: 100%;
}
.tidbit {
display: inline-flex;
align-items: center;
height: 100%;
&.date {
flex-shrink: 0;
font-weight: bold;
.icon {
fill: var(--lighten);
margin: -.2rem 0 0 .75rem;
}
}
}
.site {
display: inline-flex;
align-items: center;
max-width: 50%;
padding: .25rem 0;
font-size: 0;
}
.logo {
display: inline-block;
}
.logo-link {
text-decoration: none;
}
.logo-site {
height: 2.25rem;
max-width: 15rem;
margin: .25rem 0;
object-fit: contain;
object-position: 100% 50%;
}
.logo-parent {
height: 1.5rem;
max-width: 10rem;
object-fit: contain;
object-position: 100% 50%;
}
.logo-name {
padding: .5rem 0;
color: var(--text-light);
font-size: 1.25rem;
font-weight: bold;
}
.chain {
color: var(--lighten);
padding: 0 .5rem;
font-weight: bold;
font-size: .8rem;
}
.date-compact {
display: none;
}
@media(max-width: $breakpoint-mega) {
.logo-parent,
.chain {
display: none;
}
.logo-site {
height: 1.5rem;
}
.logo-site {
width: 100%;
}
}
@media(max-width: $breakpoint) {
.date-full {
display: none;
}
.date-compact {
display: inline-block;
}
}
</style>

View File

@ -1,303 +0,0 @@
<template>
<div class="tile">
<Details :release="movie" />
<div class="movie">
<RouterLink
:to="{ name: 'movie', params: { releaseId: movie.id, releaseSlug: movie.slug } }"
class="cover"
>
<img
v-if="movie.covers[0]"
:src="getPath(movie.covers[0], 'thumbnail')"
:style="{ 'background-image': getBgPath(movie.covers[0], 'lazy') }"
loading="lazy"
>
<div
v-else
:title="movie.title"
class="unavailable"
><Icon icon="blocked" /></div>
<Icon
v-show="(!stash || stash.primary) && favorited"
icon="heart7"
class="stash stashed"
@click.prevent.native="unstashMovie"
/>
<Icon
v-show="(!stash || stash.primary) && favorited === false"
icon="heart8"
class="stash unstashed"
@click.prevent.native="stashMovie"
/>
<Icon
v-show="stash && !stash.primary"
icon="cross2"
class="stash unstash"
@click.prevent.native="unstashMovie"
/>
</RouterLink>
<div class="info">
<RouterLink
:to="{ name: 'movie', params: { releaseId: movie.id, releaseSlug: movie.slug } }"
class="title-link"
>
<h3 class="title">{{ movie.title }}</h3>
</RouterLink>
<ul
class="actors nolist"
:title="movie.actors.map(actor => actor.name).join(', ')"
>
<li
v-for="actor in movie.actors"
:key="`tag-${movie.id}-${actor.id}`"
class="actor"
><RouterLink
:to="`/actor/${actor.id}/${actor.slug}`"
class="actor-link"
>{{ actor.name }}</RouterLink></li>
</ul>
<ul
class="tags nolist"
:title="movie.tags.map(tag => tag.name).join(', ')"
>
<li
v-for="tag in movie.tags"
:key="`tag-${movie.id}-${tag.id}`"
class="tag"
><RouterLink
:to="`/tag/${tag.slug}`"
class="tag-link"
>{{ tag.name }}</RouterLink></li>
</ul>
</div>
</div>
</div>
</template>
<script>
import Details from './tile-details.vue';
async function stashMovie() {
this.favorited = true;
try {
await this.$store.dispatch('stashMovie', {
movieId: this.movie.id,
stashId: this.$store.getters.favorites.id,
});
this.$emit('stash', true);
} catch (error) {
this.favorited = false;
}
}
async function unstashMovie() {
if (!this.stash || this.stash.primary) {
this.favorited = false;
}
try {
await this.$store.dispatch('unstashMovie', {
movieId: this.movie.id,
stashId: this.stash?.id || this.$store.getters.favorites.id,
});
this.$emit('stash', false);
} catch (error) {
this.favorited = true;
}
}
function sfw() {
return this.$store.state.ui.sfw;
}
export default {
components: {
Details,
},
props: {
movie: {
type: Object,
default: null,
},
stash: {
type: Object,
default: null,
},
},
data() {
return {
favorited: this.movie.isFavorited,
};
},
computed: {
sfw,
},
methods: {
stashMovie,
unstashMovie,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.tile {
display: flex;
flex-direction: column;
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
font-size: 0;
&:hover .unstashed,
&:hover .unstash {
fill: var(--lighten);
}
}
.movie {
display: flex;
}
.title-link {
color: var(--text);
text-decoration: none;
}
.cover {
height: 16rem;
width: 11.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
box-shadow: 0 0 3px var(--darken-weak);
background-color: var(--shadow-hint);
img {
height: 100%;
width: 100%;
background-position: center;
background-size: cover;
object-fit: cover;
object-position: center;
}
.unavailable .icon {
width: 2rem;
height: 2rem;
fill: var(--shadow-hint);
}
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.title {
box-sizing: border-box;
padding: 1rem;
margin: 0;
font-size: 1rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.actors {
height: 0;
flex-grow: 1;
padding: 0 1rem;
line-height: 1.5;
overflow: hidden;
}
.actor:not(:last-child)::after {
content: ',';
margin: 0 .25rem 0 0;
font-size: 1rem;
}
.actor-link {
font-size: 1rem;
color: var(--link);
text-decoration: none;
&:hover {
color: var(--primary);
}
}
.tags {
padding: .25rem 1rem;
height: 1.75rem;
line-height: 2;
overflow: hidden;
}
.tag {
margin: 0 0 .5rem 0;
}
.tag-link {
background: var(--background);
font-size: .75rem;
padding: .25rem .5rem;
color: var(--shadow);
font-weight: bold;
text-decoration: none;
box-shadow: 0 0 3px var(--shadow-weak);
&:hover {
color: var(--primary);
}
}
.stash {
width: 1.5rem;
height: 1.5rem;
padding: .25rem .5rem .5rem .5rem;
position: absolute;
top: 0;
left: 0;
fill: var(--lighten-weak);
filter: drop-shadow(0 0 2px var(--darken));
&:hover.unstashed,
&.stashed {
fill: var(--primary);
}
&:hover.unstash {
fill: var(--text-light);
filter: drop-shadow(0 0 2px var(--darken-weak));
}
}
@media(max-width: $breakpoint-kilo) {
.cover {
height: 12rem;
width: 8.25rem;
}
/* ensure no half actor names show */
.actors {
margin: 0 0 1rem 0;
}
}
</style>

View File

@ -1,124 +0,0 @@
<template>
<div class="movies">
<div class="content-inner">
<SearchBar :placeholder="`Search ${totalCount} movies`" />
<div
ref="tiles"
class="tiles"
>
<MovieTile
v-for="movie in movies"
:key="`movie-${movie.id}`"
:movie="movie"
/>
</div>
<Pagination
v-if="totalCount > 0"
:items-total="totalCount"
:items-per-page="limit"
class="pagination-bottom"
/>
</div>
<Footer />
</div>
</template>
<script>
import MovieTile from './movie-tile.vue';
import SearchBar from '../search/bar.vue';
import Pagination from '../pagination/pagination.vue';
async function fetchMovies() {
if (this.$route.query.query) {
await this.searchMovies();
return;
}
const { movies, totalCount } = await this.$store.dispatch('fetchMovies', {
limit: this.limit,
range: this.$route.params.range,
pageNumber: this.$route.params.pageNumber,
});
this.movies = movies;
this.totalCount = totalCount;
this.$refs.tiles.scrollIntoView();
}
async function searchMovies() {
const { movies, totalCount } = await this.$store.dispatch('searchMovies', {
query: this.$route.query.query,
limit: this.limit,
pageNumber: this.$route.params.pageNumber,
});
this.movies = movies;
this.totalCount = totalCount;
this.$refs.tiles.scrollIntoView();
}
async function mounted() {
this.pageTitle = 'Movies';
await this.fetchMovies();
}
export default {
components: {
MovieTile,
SearchBar,
Pagination,
},
data() {
return {
movies: [],
totalCount: 0,
limit: 20,
};
},
watch: {
$route: fetchMovies,
},
mounted,
methods: {
fetchMovies,
searchMovies,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.movies {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.content-inner {
padding: 0 1rem;
}
.search {
margin: 1rem 0 0 0;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr));
grid-gap: 1rem;
padding: 1rem 0;
}
@media(max-width: $breakpoint-kilo) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
}
</style>

View File

@ -1,707 +0,0 @@
<template>
<div
v-if="release"
ref="content"
class="content"
@scroll="events.emit('scroll', $event)"
>
<Scroll
v-slot="slotProps"
class="scroll-light banner"
:style="{ 'background-image': bannerBackground }"
:expandable="false"
>
<Banner
:release="release"
class="media"
@load="slotProps.loaded"
/>
</Scroll>
<Details :release="release" />
<button
v-if="showAlbum"
class="album-toggle"
@click="$router.push({ hash: '#album' })"
><Icon icon="grid3" />View album</button>
<Album
v-if="showAlbum && $route.hash === '#album'"
:items="[release.poster, ...(release.photos || []), ...(release.caps || []), ...(release.scenesPhotos || [])]"
:title="release.title"
:path="config.media.mediaPath"
@close="$router.replace({ hash: undefined })"
/>
<div class="info column">
<div
class="row row-title"
:class="{ 'has-alt': release.altTitles?.length > 0 }"
>
<h2
v-if="release.title"
class="title"
>
{{ release.title }}
<template v-if="release.movies?.[0]?.title && /^scene \d+$/i.test(release.title)"><span class="title-composed">&nbsp;from&nbsp;</span>{{ release.movies[0].title }}</template>
</h2>
<h2
v-else-if="release.actors.length > 0"
class="title title-composed"
>
{{ release.actors.map(actor => actor.name).join(', ') }} for {{ release.entity.name }}
<Icon
v-tooltip="`This scene has no known official title`"
icon="question2"
/>
</h2>
<StashButton
:stashed-by="stashedBy"
@stash="(stash) => stashScene(stash)"
@unstash="(stash) => unstashScene(stash)"
/>
</div>
<div
v-if="release.altTitles?.length > 0"
class="row alttitles"
>
<h2
v-for="(altTitle, index) in release.altTitles"
:key="`altitle-${index}`"
class="alttitle"
>
{{ altTitle }}
</h2>
</div>
<Releases
v-if="release.scenes && release.scenes.length > 0"
:releases="release.scenes"
/>
<div class="row associations">
<ul
ref="actors"
class="actors nolist noselect bar-inline"
@mousedown.prevent="startActorsScroll"
>
<li
v-for="actor in release.actors"
:key="actor.id"
>
<Actor
:actor="actor"
:has-scrolled="actorsHasScrolled"
/>
</li>
</ul>
</div>
<Tags
v-if="release.tags.length > 0"
:tags="release.tags"
/>
<div
v-if="release.movies?.length > 0 || release.series?.length > 0"
class="row"
>
<span class="row-label">Part of</span>
<div class="movies">
<RouterLink
v-for="movie in [...release.movies, ...release.series]"
:key="`movie-${movie.id}`"
:to="{ name: movie.type || 'movie', params: { releaseId: movie.id, releaseSlug: movie.slug } }"
class="movie"
>
<span class="movie-title">{{ movie.title }}</span>
<img
v-if="movie.covers.length > 0 || movie.poster"
:src="getPath(movie.covers[0] || movie.poster, 'thumbnail')"
class="movie-cover"
>
</RouterLink>
</div>
</div>
<div
v-if="release.directors && release.directors.length > 0"
class="row"
>
<span class="row-label">Director</span>
<RouterLink
v-for="director in release.directors"
:key="`director-${director.id}`"
class="link director"
:to="`/director/${director.id}/${director.slug}`"
>{{ director.name }}</RouterLink>
</div>
<div
v-if="release.description"
class="row"
>
<span class="row-label">Description</span>
<p class="description">{{ release.description }}</p>
</div>
<div
v-if="release.chapters?.length > 0"
class="row nolist"
>
<span class="row-label">Chapters</span>
<Chapters :chapters="release.chapters" />
</div>
<div class="row row-tidbits">
<div
v-if="release.duration"
class="row-tidbit"
>
<span class="row-label">Duration</span>
<div class="duration">{{ formatDuration(release.duration) }}</div>
</div>
<div
v-if="release.photoCount"
class="row-tidbit"
>
<span class="row-label">Photos</span>
{{ release.photoCount }}
</div>
<div
v-if="release.shootId"
class="row-tidbit"
>
<span class="row-label">Shoot #</span>
{{ release.shootId }}
</div>
<div
v-if="release.studio"
class="row-tidbit"
>
<span class="row-label">Studio
<Icon
v-if="release.studio.showcased === false"
icon="eye-blocked"
title="This studio does not appear on main pages"
/>
</span>
<RouterLink
:to="`/studio/${release.studio.slug}`"
class="link studio"
>{{ release.studio.name }}</RouterLink>
</div>
<div
v-if="release.productionDate"
class="row-tidbit"
>
<span class="row-label">Shoot date</span>
{{ formatDate(release.productionDate, 'MMMM D, YYYY') }}
</div>
<div
v-if="release.productionLocation"
class="row-tidbit"
>
<span class="row-label">Location</span>
<span class="location">
<span
v-if="release.productionLocation.city"
class="location-segment"
>{{ release.productionLocation.city }}, </span>
<span
v-if="release.productionLocation.state"
class="location-segment"
>{{ release.productionLocation.state }}, </span>
<span
v-if="release.productionLocation.country"
class="location-segment"
>{{ release.productionLocation.country.alias || release.productionLocation.country.name }}
<img
class="flag"
:src="`/img/flags/${release.productionLocation.country.alpha2.toLowerCase()}.svg`"
>
</span>
</span>
</div>
</div>
<div
v-if="release.qualities?.length > 0"
class="row"
>
<span class="row-label">Available qualities</span>
<span
v-for="quality in release.qualities"
:key="quality"
class="quality"
>{{ quality }}</span>
</div>
<div
v-if="release.comment"
class="row"
>
<span class="row-label">Comment</span>
<span>{{ release.comment }}</span>
</div>
<div class="row">
<span class="row-label">Added</span>
<RouterLink
:to="`/added/${formatDate(release.createdAt, 'YYYY/MM/DD')}`"
:title="`Added on ${formatDate(release.createdAt, 'MMMM D, YYYY HH:mm')}`"
class="link added"
>{{ release.createdBatchId }}: {{ formatDate(release.createdAt, 'MMMM D, YYYY HH:mm') }}</RouterLink>
</div>
<div class="row">
<span class="row-label">Summary</span>
<div class="summary">
<input
ref="summary"
v-model="summary"
class="input"
@focus="selectSummary"
>
<button
v-if="hasClipboard"
type="button"
class="button button-secondary"
:disabled="summaryCopied"
@focus="copySummary"
>{{ summaryCopied ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import formatSummary from '../../js/utils/format-summary';
import Details from './details.vue';
import Banner from './banner.vue';
import StashButton from '../stashes/button.vue';
import Album from '../album/album.vue';
import Tags from './tags.vue';
import Chapters from './chapters.vue';
import Actor from '../actors/tile.vue';
import Releases from './releases.vue';
import Scroll from '../scroll/scroll.vue';
async function fetchRelease(scroll = true) {
if (this.$route.name === 'scene') {
this.release = await this.$store.dispatch('fetchReleaseById', this.$route.params.releaseId);
}
if (this.$route.name === 'movie') {
this.release = await this.$store.dispatch('fetchMovieById', this.$route.params.releaseId);
}
if (this.$route.name === 'serie') {
this.release = await this.$store.dispatch('fetchSerieById', this.$route.params.releaseId);
}
if (scroll && this.$refs.content) {
this.$refs.content.scrollTop = 0;
}
this.stashedBy = this.release.stashes;
this.setSummary();
}
async function stashScene(stashId) {
this.stashedBy = await this.$store.dispatch(this.$route.name === 'movie' ? 'stashMovie' : 'stashScene', {
sceneId: this.release.id,
movieId: this.release.id,
stashId,
});
}
async function unstashScene(stashId) {
this.stashedBy = await this.$store.dispatch(this.$route.name === 'movie' ? 'unstashMovie' : 'unstashScene', {
sceneId: this.release.id,
movieId: this.release.id,
stashId,
});
}
function startActorsScroll(event) {
event.preventDefault();
this.$refs.actors.addEventListener('mousemove', this.scrollActors);
document.addEventListener('mouseup', this.endActorsScroll);
this.actorsScrollStart = { mouse: event.clientX, scroll: this.$refs.actors.scrollLeft };
this.actorsHasScrolled = false;
}
function scrollActors(event) {
this.$refs.actors.scrollLeft = this.actorsScrollStart.scroll + this.actorsScrollStart.mouse - event.clientX;
if (Math.abs(this.actorsScrollStart.mouse - event.clientX) > 10) {
this.actorsHasScrolled = true;
}
}
function endActorsScroll() {
this.$refs.actors.removeEventListener('mousemove', this.scrollActors);
document.removeEventListener('mouseup', this.endActorsScroll);
}
function me() {
return this.$store.state.auth.user;
}
function setSummary() {
// this.summary = `${this.release.entity.name} - ${this.release.title} (${this.release.actors.map((actor) => actor.name).join(', ')}, ${this.formatDate(this.release.date, 'DD-MM-YYYY')})`;
const simpleRelease = {
channel: this.release.entity.name,
network: this.release.entity.parent?.name || this.release.entity.name,
title: this.release.title,
movie: this.release.movies?.[0]?.title,
actors: this.release.actors.map((actor) => actor.name),
tags: this.release.tags.map((tag) => tag.name),
date: this.release.date,
};
this.summary = formatSummary(simpleRelease, this.$store.state.ui.summaryFormat);
}
async function selectSummary() {
this.$refs.summary.select();
}
async function copySummary() {
const { state } = await navigator.permissions.query({ name: 'clipboard-write' });
if (state === 'granted' || state === 'prompt') {
await navigator.clipboard.writeText(this.summary);
this.summaryCopied = true;
setTimeout(() => { this.summaryCopied = false; }, 1000);
}
}
function bannerBackground() {
return (this.release.poster && this.getBgPath(this.release.poster, 'thumbnail'))
|| (this.release.covers.length > 0 && this.getBgPath(this.release.covers[0], 'thumbnail'));
}
function pageTitle() {
return this.release
&& (this.release.title
|| (this.release.actors.length > 0 ? `${this.release.actors.map((actor) => actor.name).join(', ')} for ${this.release.entity.name}` : null));
}
function showAlbum() {
return this.release.photos?.length > 0 || this.release.caps?.length > 0 || this.release.scenesPhotos?.length > 0;
}
async function mounted() {
this.fetchRelease();
}
export default {
components: {
Actor,
Album,
Banner,
Chapters,
Details,
Releases,
Scroll,
StashButton,
Tags,
},
data() {
return {
release: null,
summary: null,
summaryCopied: false,
stashedBy: [],
actorsScrollStart: 0,
actorsHasScrolled: false,
hasClipboard: !!navigator?.clipboard?.writeText,
};
},
computed: {
pageTitle,
bannerBackground,
me,
showAlbum,
},
watch: {
$route: fetchRelease,
},
mounted,
methods: {
copySummary,
endActorsScroll,
fetchRelease,
scrollActors,
selectSummary,
setSummary,
startActorsScroll,
stashScene,
unstashScene,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.expand-bottom {
border-bottom: solid 1px var(--shadow-hint);
}
.banner {
background-position: center;
background-size: cover;
:deep(.scrollable) {
backdrop-filter: blur(1rem);
}
}
.info {
padding: 1rem 0;
border-left: solid 1px var(--shadow-hint);
border-right: solid 1px var(--shadow-hint);
flex-grow: 1;
}
.row {
padding: 0 1rem;
margin: 0 0 1rem 0;
&.associations {
align-items: start;
}
}
.row-label {
display: flex;
align-items: center;
margin: 0 0 .5rem 0;
color: var(--shadow);
font-weight: bold;
.icon {
margin: 0 .5rem;
fill: var(--shadow);
}
}
.row-tidbit {
display: inline-block;
margin: 0 2rem 0 0;
}
.row-title {
display: flex;
justify-content: space-between;
}
.title {
display: inline-flex;
margin: 0;
font-size: 1.5rem;
line-height: 1.25;
.icon {
fill: var(--shadow);
padding: .25rem;
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
}
.title-composed {
color: var(--shadow);
}
.alttitle {
color: var(--shadow);
font-size: 1rem;
}
.album-toggle {
height: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
padding: .5rem 1rem;
border: none;
border-bottom: solid 1px var(--shadow-hint);
color: var(--shadow);
background: none;
font-size: 1rem;
font-weight: bold;
.icon {
fill: var(--shadow);
margin: -.1rem .5rem 0 0;
}
&:hover {
background: var(--shadow-hint);
cursor: pointer;
}
}
.description {
line-height: 1.5;
margin: -.25rem 0 0 0;
}
.actors {
display: block;
padding-bottom: .25rem;
overflow-x: auto;
white-space: nowrap;
.actor {
width: 10rem;
margin-right: .25rem;
user-select: none;
}
}
.movies {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: .5rem;
flex-grow: 1;
flex-wrap: wrap;
}
.movie {
display: flex;
flex-direction: column;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak);
color: var(--text);
text-decoration: none;
&:hover .movie-title {
color: var(--primary);
}
}
.movie-cover {
width: 100%;
}
.movie-title {
padding: .5rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quality {
&::after {
content: 'p, ';
}
&:last-child::after {
content: 'p',
}
}
.releases {
margin: 0 0 .5rem 0;
}
.flag {
height: 1rem;
margin: 0 0 -.15rem .1rem;
}
.summary {
display: flex;
.input {
flex-grow: 1;
}
.button {
width: 4rem;
}
}
.link {
display: inline-flex;
color: var(--link);
text-decoration: none;
&.director:not(:last-child)::after {
content: ', ';
}
&:hover {
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.showable {
display: none;
}
@media(max-width: $breakpoint-kilo) {
.releases {
padding: .5rem;
}
}
@media(max-width: $breakpoint) {
.hideable {
display: none;
}
.row .showable {
display: block;
}
.tidbit .showable {
display: inline-block;
}
.title {
font-size: 1.25rem;
}
.actors {
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
}
}
</style>

View File

@ -1,185 +0,0 @@
<template>
<div class="releases">
<h3
v-if="context"
class="heading"
><span class="range">{{ range }}</span> releases for '{{ context }}'</h3>
<Ellipsis v-if="!done" />
<ul
v-else-if="releases.length > 0"
:key="sfw"
class="nolist tiles"
>
<template v-for="(item, index) in items">
<li
v-if="item === 'campaign'"
:key="`campaign-${index}`"
class="campaign"
>
<Campaign
v-if="item === 'campaign'"
:entity="entity"
:min-ratio="0.75"
:max-ratio="1.25"
:allow-generic="true"
/>
</li>
<li
v-else
:key="`release-${item.id}`"
>
<SceneTile
:release="item"
:referer="referer"
:index="index"
:stash="stash"
@stash="isStashed => $emit('stash', isStashed)"
/>
</li>
</template>
</ul>
<span
v-else-if="releases.length === 0 && range !== 'all'"
class="empty"
>No {{ range }} releases</span>
<span
v-else-if="releases.length === 0"
class="empty"
>No recent or upcoming releases</span>
</div>
</template>
<script setup>
import config from 'config';
import {
defineProps,
defineEmits,
computed,
} from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import Campaign from '../campaigns/campaign.vue';
import Ellipsis from '../loading/ellipsis.vue';
import SceneTile from './scene-tile.vue';
const router = useRouter();
const store = useStore();
defineEmits(['stash']);
const props = defineProps({
releases: {
type: Array,
default: () => [],
},
entity: {
type: Object,
default: null,
},
context: {
type: String,
default: null,
},
done: {
type: Boolean,
default: true,
},
referer: {
type: String,
default: null,
},
stash: {
type: Object,
default: null,
},
});
const campaignIndex = computed(() => Math.floor(Math.random() * props.releases.length - 5) + 5);
const items = computed(() => props.releases.flatMap((release, index) => (config.campaigns.tiles && props.releases.length > 10 && index === campaignIndex.value ? ['campaign', release] : release)));
const range = computed(() => router.route?.params.range);
const sfw = computed(() => store.state.ui.sfw);
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.heading {
padding: 0;
margin: 0 0 1rem 0;
.range {
text-transform: capitalize;
}
}
.releases {
flex-grow: 1;
border-top: solid 1px var(--crease);
&.embedded {
border: none;
.tiles {
padding: 0;
}
}
}
.tiles {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
grid-gap: .5rem;
box-sizing: border-box;
padding: 1rem;
}
.empty {
display: inline-block;
padding: 1rem;
color: var(--shadow-strong);
font-weight: bold;
}
.campaign {
display: flex;
align-items: flex-start;
justify-content: center;
}
@media(max-width: $breakpoint-mega) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}
}
@media(max-width: $breakpoint-kilo) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(18.5rem, 1fr));
grid-gap: .5rem;
padding: .5rem;
}
}
@media(max-width: $breakpoint) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
}
@media(max-width: $breakpoint-micro) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
}
}
</style>

View File

@ -1,118 +0,0 @@
<template>
<a
:href="`/scene/${scene.id}/${scene.slug || ''}`"
target="_blank"
rel="noopener noreferrer"
class="scene nolink"
>
<img
:src="getPath(scene.poster, 'thumbnail')"
class="scene-poster"
>
<div class="scene-header">
<span class="scene-actors nolist">{{ scene.actors.map(actor => actor.name).join(', ') }}</span>
</div>
<div class="scene-footer">
<img
v-if="scene.entity.parent"
:src="`/img/logos/${scene.entity.parent.slug}/favicon_light.png`"
class="scene-favicon"
>
<img
v-else
:src="`/img/logos/${scene.entity.slug}/favicon_light.png`"
class="scene-favicon"
>
<span class="scene-title">{{ scene.title }}</span>
</div>
</a>
</template>
<script>
export default {
props: {
scene: {
type: Object,
default: null,
},
stash: {
type: Object,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
.scene {
width: 14rem;
height: 100%;
position: relative;
font-size: 0;
}
.scene-poster {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 0;
}
.scene-header,
.scene-footer {
width: 100%;
height: 1.25rem;
display: flex;
align-items: center;
position: absolute;
left: 0;
background: var(--darken-weak);
color: var(--text-light);
font-size: .7rem;
font-weight: bold;
overflow: hidden;
text-shadow: 0 0 2px var(--text-dark);
}
.scene-header {
top: 0;
}
.scene-footer {
bottom: 0;
}
.scene-title {
padding: .25rem .5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scene-actors {
padding: 0 .5rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.scene-unstash {
fill: var(--lighten-strong);
padding: .25rem;
filter: drop-shadow(0 0 1px var(--shadow));
&:hover {
fill: var(--text-light);
}
}
.scene-favicon {
width: 1rem;
height: 1rem;
padding: .1rem 0 0 .25rem;
}
</style>

View File

@ -1,498 +0,0 @@
<template>
<div
:id="`scene-${release.id}`"
:class="{ new: release.isNew }"
class="tile"
>
<Details
:release="release"
class="details-compact"
/>
<div class="tile-body">
<span class="poster">
<a
:href="`/scene/${release.id}/${release.slug || ''}`"
target="_blank"
rel="noopener noreferrer"
class="link"
>
<img
v-if="release.poster"
:src="getPath(release.poster, 'thumbnail')"
:style="{ 'background-image': getBgPath(release.poster, 'lazy') }"
:alt="release.title"
:width="release.poster.thumbnailWidth"
:height="release.poster.thumbnailHeight"
class="thumbnail"
loading="lazy"
>
<img
v-else-if="release.photos && release.photos.length > 0"
:src="getPath(release.photos[0], 'thumbnail')"
:style="{ 'background-image': getBgPath(release.photos[0], 'lazy') } "
:alt="release.title"
:width="release.photos[0].thumbnailWidth"
:height="release.photos[0].thumbnailHeight"
class="thumbnail"
loading="lazy"
>
<div
v-else
:title="release.title"
class="thumbnail unavailable"
><Icon icon="blocked" />No thumbnail available</div>
<Icon
v-show="(!stash || stash.primary) && favorited"
icon="heart7"
class="stash stashed"
@click.prevent.native="unstashScene"
/>
<Icon
v-show="(!stash || stash.primary) && favorited === false"
icon="heart8"
class="stash unstashed"
@click.prevent.native="stashScene"
/>
<Icon
v-show="stash && !stash.primary"
icon="cross2"
class="stash unstash"
@click.prevent.native="unstashScene"
/>
</a>
</span>
<div class="info">
<Details
:release="release"
class="details-wide"
/>
<a
:href="`/scene/${release.id}/${release.slug || ''}`"
target="_blank"
rel="noopener noreferrer"
class="row link"
>
<h3
v-if="release.title"
v-tooltip.bottom="release.title"
:title="release.title"
class="title"
>
{{ release.title }}<template v-if="release.movies?.[0]?.title && /^scene \d+$/i.test(release.title)"><span class="title-composed">&nbsp;from&nbsp;</span>{{ release.movies[0].title }}</template>
</h3>
<h3
v-else-if="release.actors.length > 0"
class="title title-composed"
>{{ release.actors[0].name }} for {{ release.entity.name }}</h3>
<h3
v-else
class="title title-empty"
>{{ release.entity.name }}</h3>
</a>
<span class="row">
<ul
class="actors nolist"
:title="release.actors.map(actor => actor.name).join(', ')"
>
<li
v-for="actor in release.actors"
:key="actor.id"
class="actor"
>
<RouterLink
:to="{ name: 'actor', params: { actorId: actor.id, actorSlug: actor.slug } }"
:class="{ [actor.gender]: !!actor.gender }"
class="actor-link"
>{{ actor.name }}</RouterLink>
</li>
</ul>
</span>
<div class="labels">
<RouterLink
v-if="release.shootId && release.studio"
:to="`/studio/${release.studio.slug}`"
:title="release.studio && release.studio.name"
class="shoot nolink"
>{{ release.shootId }}</RouterLink>
<span
v-else-if="release.shootId"
:title="release.studio && release.studio.name"
class="shoot nolink"
>{{ release.shootId }}</span>
<ul
v-if="release.tags?.length > 0"
:title="release.tags.map(tag => tag.name).join(', ')"
class="tags nolist"
>
<li
v-for="tag in release.tags"
:key="`tag-${tag.slug}`"
class="tag"
>
<RouterLink
:to="`/tag/${tag.slug}`"
class="tag-link"
>{{ tag.name }}</RouterLink>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import Details from './tile-details.vue';
async function stashScene() {
this.favorited = true;
try {
await this.$store.dispatch('stashScene', {
sceneId: this.release.id,
stashId: this.$store.getters.favorites.id,
});
this.$emit('stash', true);
} catch (error) {
this.favorited = false;
}
}
async function unstashScene() {
if (!this.stash || this.stash.primary) {
this.favorited = false;
}
try {
await this.$store.dispatch('unstashScene', {
sceneId: this.release.id,
stashId: this.stash?.id || this.$store.getters.favorites.id,
});
this.$emit('stash', false);
} catch (error) {
this.favorited = true;
}
}
export default {
components: {
Details,
},
props: {
release: {
type: Object,
default: null,
},
stash: {
type: Object,
default: null,
},
},
emits: ['stash'],
data() {
return {
favorited: this.release.isFavorited,
};
},
methods: {
stashScene,
unstashScene,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.tile {
background: var(--background);
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
height: 100%;
box-shadow: 0 0 3px var(--darken-weak);
&.new .poster::after {
content: 'new';
position: absolute;
display: flex;
align-items: center;
justify-content: center;
bottom: 0;
right: .5rem;
width: 2rem;
box-sizing: border-box;
padding: .1rem 0 .05rem 0;
border-radius: .25rem .25rem 0 0;
background: var(--info);
color: var(--text-light);
font-size: .7rem;
font-weight: bold;
line-height: 1;
box-shadow: 0 0 3px var(--shadow);
}
&:hover .unstashed,
&:hover .unstash {
fill: var(--lighten);
}
}
.tile-body {
display: flex;
flex-direction: column;
}
.poster {
position: relative;
}
.covers {
background: var(--profile);
display: flex;
.cover {
width: 50%;
}
}
.thumbnail {
width: 100%;
height: 14rem;
display: flex;
justify-content: center;
align-items: center;
object-fit: cover;
background-position: center;
background-size: cover;
background-color: var(--shadow-hint);
color: var(--shadow);
text-shadow: 1px 1px 0 var(--highlight);
.icon {
display: none;
width: 2rem;
height: 2rem;
fill: var(--shadow-hint);
}
}
.stash {
width: 1.5rem;
height: 1.5rem;
position: absolute;
top: 0;
right: 0;
padding: .25rem .25rem .5rem .5rem;
filter: drop-shadow(0 0 2px var(--darken));
fill: var(--lighten-weak);
&:hover.unstashed,
&.stashed {
fill: var(--primary);
filter: drop-shadow(0 0 2px var(--darken-weak));
}
&:hover.unstash {
fill: var(--text-light);
filter: drop-shadow(0 0 2px var(--darken-weak));
}
}
.row {
max-width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: 0 .5rem;
}
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
overflow: hidden;
}
.link {
text-decoration: none;
}
.title {
margin: 0;
color: var(--text);
font-size: .9rem;
line-height: 1.5;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.title-composed,
.title-empty {
color: var(--shadow);
}
.actors {
height: 1.5rem;
word-wrap: break-word;
overflow: hidden;
line-height: 1.5rem;
margin: 0 0 .25rem 0;
}
.actor:not(:last-of-type)::after {
content: ",";
margin: 0 .25rem 0 0;
}
.actor-link {
color: var(--text);
text-decoration: none;
font-size: .9rem;
&:hover {
color: var(--primary);
}
}
.labels {
padding: 0 .5rem 1.25rem .5rem;
max-height: .5rem;
overflow-y: hidden;
font-size: 0;
line-height: 2;
}
.shoot {
display: inline;
padding: .25rem .5rem;
background: var(--primary);
color: var(--text-light);
font-size: 0.75rem;
font-weight: bold;
box-shadow: inset 0 0 3px var(--shadow-weak);
}
.tags {
display: inline;
word-wrap: break-word;
}
.tag {
margin: 0 0 1rem 0;
&:not(:first-child) .tag-link {
border-left: none;
}
}
.tag-link {
background: var(--shadow-touch);
color: var(--shadow);
display: inline-block;
padding: .25rem .5rem;
margin: 0 1px 0 0;
font-size: .75rem;
font-weight: bold;
text-decoration: none;
line-height: 1;
&:hover {
color: var(--primary);
}
}
.details-wide {
margin: 0 0 .4rem 0;
}
.details-compact {
display: none;
}
@media(max-width: $breakpoint) {
.thumbnail {
height: 11rem;
}
}
@media(max-width: $breakpoint-micro) {
.tile-body {
flex-direction: row;
}
.poster {
margin: 0;
}
.thumbnail {
width: 9rem;
height: 6rem;
font-size: 0;
box-shadow: 0 0 3px var(--shadow-weak);
.icon {
display: block;
}
}
.info {
padding: .5rem .25rem 0 .25rem;
}
.row {
margin: 0 0 .15rem 0;
}
.title,
.actor-link {
font-size: .9rem;
}
.details-wide {
display: none;
}
.details-compact {
display: flex;
}
.shoot {
display: none;
}
.tile.new .poster::after {
top: 0;
right: .25rem;
bottom: auto;
border-radius: 0 0 .25rem .25rem;
padding: .05rem 0 .1rem 0;
}
.stash {
left: 0;
padding: .25rem .5rem .5rem .25rem;
}
}
</style>

View File

@ -1,136 +0,0 @@
<template>
<div
class="tags-container"
:class="{ overflowing }"
>
<ul
ref="tags"
class="tags nolist"
:class="{ expanded }"
>
<li
v-for="tag in tags"
:key="`tag-${tag.slug}`"
class="tag"
>
<a
:href="`/tag/${tag.slug}`"
class="link"
>{{ tag.name }}</a>
</li>
</ul>
<button
v-if="overflowing && !expanded"
class="tags-more"
@click="expanded = true"
>more tags</button>
<button
v-if="expanded"
class="tags-more"
@click="expanded = false"
>fewer tags</button>
</div>
</template>
<script>
import { nextTick } from 'vue';
function updateOverflowing() {
nextTick(() => {
const containerBoundaries = this.$refs.tags.getBoundingClientRect();
const containerBottom = containerBoundaries.top + containerBoundaries.height;
this.overflowing = Array.from(this.$refs.tags.querySelectorAll('.tag')).some((tag) => {
const tagBoundaries = tag.getBoundingClientRect();
return tagBoundaries.top > containerBottom;
});
});
}
function mounted() {
window.addEventListener('resize', this.updateOverflowing);
this.updateOverflowing();
}
function beforeUnmount() {
window.removeEventListener('resize', this.updateOverflowing);
}
export default {
props: {
tags: {
type: Array,
default: () => [],
},
},
data() {
return {
overflowing: false,
expanded: false,
};
},
watch: {
expanded: updateOverflowing,
},
mounted,
beforeUnmount,
methods: {
updateOverflowing,
},
};
</script>
<style lang="scss" scoped>
.tags-container {
margin: 0 0 1.5rem 0;
&.overflowing {
margin: 0 0 .5rem 0;
}
}
.tags {
max-height: 4.7rem;
padding: 2px 1rem 0 1rem;
text-align: left;
overflow: hidden;
&.expanded {
max-height: unset;
}
}
.tag .link {
color: var(--link);
background: var(--background);
display: inline-block;
padding: .5rem;
margin: 0 .25rem .25rem 0;
box-shadow: 0 0 2px var(--shadow-weak);
text-decoration: none;
text-transform: capitalize;
&:hover {
color: var(--primary);
}
}
.tags-more {
background: var(--shadow-touch);
padding: .5rem 1rem;
border: none;
margin: .25rem 0 .5rem 1rem;
color: var(--shadow);
font-size: .8rem;
font-weight: bold;
&:hover {
background: var(--shadow-hint);
color: var(--shadow-strong);
cursor: pointer;
}
}
</style>

View File

@ -1,171 +0,0 @@
<template>
<span
class="details"
:class="{ new: release.isNew }"
>
<span
v-if="release.entity && release.entity.type !== 'network' && !release.entity.independent && release.entity.parent"
class="site"
>
<router-link
v-tooltip.bottom="`Part of ${release.entity.parent.name}`"
:title="`Part of ${release.entity.parent.name}`"
:to="`/${release.entity.parent.type}/${release.entity.parent.slug}`"
class="site-link"
>
<img
:src="`/img/logos/${release.entity.parent.slug}/favicon_light.png`"
class="favicon favicon-light"
>
<img
:src="`/img/logos/${release.entity.parent.slug}/favicon_dark.png`"
class="favicon favicon-dark"
>
</router-link>
<router-link
v-tooltip.bottom="`More from ${release.entity.name}`"
:title="`More from ${release.entity.name}`"
:to="`/${release.entity.type}/${release.entity.slug}`"
class="site-link"
>{{ release.entity.name }}</router-link>
</span>
<router-link
v-else-if="release.entity"
:to="`/${release.entity.type}/${release.entity.slug}`"
class="site site-link"
>
<img
:src="`/img/logos/${release.entity.slug}/favicon_light.png`"
class="favicon favicon-light"
>
<img
:src="`/img/logos/${release.entity.slug}/favicon_dark.png`"
class="favicon favicon-dark"
>{{ release.entity.name }}
</router-link>
<a
v-if="release.date"
v-tooltip.bottom="release.url && `View release on ${release.entity.name}`"
:title="release.url && `View release on ${release.entity.name}`"
:href="release.url"
:class="{ upcoming: isAfter(release.date, new Date()) }"
target="_blank"
rel="noopener noreferrer"
class="date"
>{{ formatDate(release.date, 'MMMM D, YYYY', release.datePrecision) }}</a>
<a
v-else
:href="release.url"
title="Scene date N/A, showing date added"
target="_blank"
rel="noopener noreferrer"
class="date"
>{{ `(${formatDate(release.createdAt, 'MMMM D, YYYY')})` }}</a>
</span>
</template>
<script>
export default {
props: {
release: {
type: Object,
default: null,
},
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.details {
width: 100%;
display: flex;
justify-content: space-between;
z-index: 1;
white-space: nowrap;
background: var(--info);
font-size: 0;
font-weight: bold;
.favicon {
width: 2rem;
box-sizing: border-box;
padding: .4rem .5rem;
}
}
.site,
.date {
display: flex;
align-items: center;
position: relative;
font-size: .8rem;
padding: .4rem .5rem;
color: var(--text-light);
text-decoration: none;
.icon {
fill: var(--lighten-weak);
margin: 0 .25rem 0 0;
}
&:hover .icon {
fill: var(--text-light);
}
}
.site {
padding: 0 .5rem 0 0;
}
.site-link {
display: flex;
color: var(--text-light);
text-decoration: none;
}
.favicon-dark {
display: none;
}
@media(max-width: $breakpoint-kilo) {
/* light details bar
.details {
background: var(--background);
box-shadow: none;
.favicon-dark {
display: inline-block;
}
.favicon-light {
display: none;
}
}
.site-link,
.date {
color: var(--text);
}
*/
.details .favicon {
padding: .35rem .5rem .35rem .5rem;
}
.date {
padding: .35rem .5rem;
}
.site {
padding: 0 .5rem 0 0;
}
}
</style>

View File

@ -1,251 +0,0 @@
<template>
<div class="scroll">
<Expand
v-if="expanded"
:expanded="expanded"
class="expand-light"
@expand="(state) => $emit('expand', state)"
/>
<Expand
v-if="expanded"
:expanded="expanded"
class="expand-dark"
@expand="(state) => $emit('expand', state)"
/>
<div class="scrollable">
<div
v-show="enabled && !expanded"
class="scroll-button scroll-left noselect"
:class="{ 'scroll-start': scrollAtStart }"
@click="scroll('left')"
><Icon icon="arrow-left3" /></div>
<div
ref="content"
class="scroll-content"
>
<slot :loaded="loaded" />
</div>
<div
v-show="enabled && !expanded"
class="scroll-button scroll-right noselect"
:class="{ 'scroll-end': scrollAtEnd }"
@click="scroll('right')"
><Icon icon="arrow-right3" /></div>
</div>
<Expand
v-if="expanded || (expandable && scrollable)"
:expanded="expanded"
class="expand-light"
@expand="(state) => $emit('expand', state)"
/>
<Expand
v-if="expanded || (expandable && scrollable)"
:expanded="expanded"
class="expand-dark"
@expand="(state) => $emit('expand', state)"
/>
<button
v-if="album && items && items.length > 0 && scrollable"
class="album-toggle"
@click="showAlbum = true"
><Icon icon="grid3" />View album</button>
</div>
</template>
<script>
import Expand from '../expand/expand.vue';
function updateScroll() {
this.scrollable = this.$refs.content.scrollWidth > this.$refs.content.clientWidth;
this.scrollAtStart = this.$refs.content.scrollLeft === 0;
this.scrollAtEnd = this.$refs.content.scrollWidth - this.$refs.content.clientWidth === this.$refs.content.scrollLeft;
}
function scroll(direction) {
if (direction === 'right') {
this.$refs.content.scrollLeft = this.$refs.content.scrollLeft + this.$refs.content.clientWidth - 100;
}
if (direction === 'left') {
this.$refs.content.scrollLeft = this.$refs.content.scrollLeft - this.$refs.content.clientWidth + 100;
}
}
function loaded(_event) {
// typically triggered by slotted component when an image loads, affecting scrollWidth
this.updateScroll();
}
function mounted() {
this.$refs.content.addEventListener('scroll', () => this.updateScroll());
window.addEventListener('resize', this.updateScroll);
this.updateScroll();
}
function beforeUnmount() {
this.$refs.content.removeEventListener('scroll', this.updateScroll);
window.removeEventListener('resize', this.updateScroll);
}
function updated() {
this.updateScroll();
}
export default {
components: {
Expand,
},
props: {
enabled: {
type: Boolean,
default: true,
},
expandable: {
type: Boolean,
default: false,
},
expanded: {
type: Boolean,
default: false,
},
album: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: null,
},
},
data() {
return {
scrollable: true,
scrollAtStart: true,
scrollAtEnd: false,
};
},
mounted,
updated,
beforeUnmount,
methods: {
scroll,
loaded,
updateScroll,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.scroll.expanded {
padding: 0;
.scroll {
display: none;
}
}
.scrollable {
position: relative;
}
.scroll-content {
overflow-x: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.scroll-button {
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
position: absolute;
top: 0;
bottom: 0;
z-index: 10;
&.scroll-start,
&.scroll-end {
/* use over v-show so button stays visible while still hovered */
display: none;
}
&.scroll-start {
left: 0;
}
&.scroll-end {
right: 0;
}
.icon {
width: 1.5rem;
height: 1.5rem;
fill: var(--lighten);
}
&.scroll-start .icon,
&.scroll-end .icon {
fill: var(--lighten-weak);
}
&:hover:not(.scroll-start):not(.scroll-end) .icon {
fill: var(--lighten-strong);
}
&:hover {
display: flex;
cursor: pointer;
}
}
.scroll-left {
left: 0;
padding: 1rem 2rem 1rem .5rem;
}
.scroll-right {
right: 0;
padding: 1rem .5rem 1rem 2rem;
}
.scroll .expand-light {
display: none;
}
.scroll-light {
.expand-light {
display: block;
}
.expand-dark {
display: none;
}
}
.scroll-dark .expand-light {
display: none;
}
@media(max-width: $breakpoint-micro) {
/* buttons block swiping motion */
.scroll-button {
display: none;
}
}
</style>

View File

@ -1,101 +0,0 @@
<template>
<form
class="search"
@submit.prevent="() => search()"
>
<input
v-model="query"
:placeholder="placeholder || 'Search'"
class="query"
@input="() => search(true)"
>
<button
type="submit"
class="search-button"
><Icon icon="search" /></button>
</form>
</template>
<script>
function search(typing) {
if (!typing || this.eager) {
this.$router.replace({
query: { query: this.query || undefined },
params: { ...this.$route.params, pageNumber: 1 },
});
}
}
function resetQuery() {
this.query = this.$route.query.query || null;
}
export default {
props: {
placeholder: {
type: String,
default: null,
},
eager: {
type: Boolean,
default: false,
},
},
data() {
return {
query: this.$route.query.query || null,
};
},
watch: {
$route: resetQuery,
},
methods: {
search,
},
};
</script>
<style lang="scss" scoped>
.search {
display: flex;
width: 100%;
}
.query {
max-width: 40rem;
min-width: 10rem;
color: var(--text);
background: var(--background);
flex-grow: 1;
box-sizing: border-box;
padding: 1rem;
border: none;
box-sizing: border-box;
box-shadow: 0 0 3px var(--darken-weak);
font-size: 1rem;
outline: none;
&:focus {
box-shadow: 0 0 3px var(--primary);
}
}
.search-button {
padding: 1rem;
background: none;
border: none;
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
</style>

View File

@ -1,134 +0,0 @@
<template>
<div class="content-inner">
<span
v-if="loading"
class="summary"
>Searching...</span>
<template v-if="actors.length > 0">
<span class="summary">Found {{ actors.length }} actors for '{{ query }}'</span>
<div class="tiles">
<Actor
v-for="actor in actors"
:key="`actor-${actor.id}`"
:actor="actor.aliasFor || actor"
:alias="actor.aliasFor && actor"
/>
</div>
</template>
<template v-if="releases.length > 0">
<span class="summary">Found {{ releases.length }} releases for '{{ query }}'</span>
<Releases
class="embedded"
:releases="releases"
/>
</template>
<span
v-if="error"
class="error summary"
>{{ error }}</span>
<span
v-else-if="!loading && actors.length === 0 && releases.length === 0"
class="summary"
>No results</span>
</div>
</template>
<script>
import Actor from '../actors/tile.vue';
import Releases from '../releases/releases.vue';
async function search() {
try {
const results = await this.$store.dispatch('search', {
query: this.query,
limit: 10,
});
this.loading = false;
if (results) {
this.actors = results.actors;
this.releases = results.releases;
}
} catch (error) {
this.loading = false;
this.error = 'Failed to retrieve search results, sorry about that.';
console.error(error);
}
}
function query() {
return this.$route.query.query || this.$route.query.q;
}
async function mounted() {
await this.search();
}
async function watchQuery() {
await this.search();
}
export default {
components: {
Actor,
Releases,
},
data() {
return {
loading: true,
actors: [],
releases: [],
};
},
computed: {
query,
},
watch: {
query: watchQuery,
},
mounted,
methods: {
search,
},
};
</script>
<style lang="scss" scoped>
@import 'theme';
.content-inner {
padding: 1rem;
}
.summary {
display: block;
margin: 0 0 1rem 0;
color: var(--shadow);
font-weight: bold;
&.error {
color: var(--error);
}
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
grid-gap: 0 .5rem;
margin: 0 0 1rem 0;
}
@media(max-width: $breakpoint0) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}
}
</style>

View File

@ -1,83 +0,0 @@
<template>
<div class="dialog-section">
<h3 class="form-heading">Show me</h3>
<ul class="tags nolist">
<li
v-for="tag in tags"
:key="tag"
class="tags-item"
>
<Checkbox
:checked="!tagFilter.includes(tag)"
:label="tag"
class="tag"
@change="(state) => filterTag(tag, state)"
/>
</li>
</ul>
<p class="disclaimer">You may still incidentally see filtered out content</p>
</div>
</template>
<script>
import Checkbox from '../form/checkbox.vue';
function tagFilter() {
return this.$store.state.ui.tagFilter;
}
function filterTag(tag, isChecked) {
if (isChecked) {
this.$store.dispatch('setTagFilter', this.tagFilter.filter((filteredTag) => filteredTag !== tag));
} else {
this.$store.dispatch('setTagFilter', this.tagFilter.concat(tag));
}
}
export default {
components: {
Checkbox,
},
data() {
return {
tags: ['gay', 'transsexual', 'bisexual', 'anal', 'anal prolapse', 'pissing'],
};
},
computed: {
tagFilter,
},
methods: {
filterTag,
},
};
</script>
<style lang="scss" scoped>
.dialog-body {
width: 40rem;
max-width: 100%;
}
.filters {
width: 20rem;
max-width: 100%;
}
.tags-item {
display: block;
}
.tag {
padding: .5rem 0;
}
.disclaimer {
margin: 1rem 0 0 0;
line-height: 1.5;
text-align: center;
font-size: .9rem;
color: var(--shadow);
}
</style>

View File

@ -1,81 +0,0 @@
<template>
<Dialog
title="Settings"
@close="$emit('close')"
>
<nav class="tabs">
<button
class="tab"
:class="{ selected: section === 'filters' }"
@click="section = 'filters'"
>Filters</button>
<button
class="tab"
:class="{ selected: section === 'summary' }"
@click="section = 'summary'"
>Summary</button>
</nav>
<div class="dialog-body">
<component :is="sections[section]" />
</div>
</Dialog>
</template>
<script>
import { shallowRef } from 'vue';
import Filters from './filters.vue';
import Summary from './summary.vue';
export default {
emits: ['close'],
data() {
return {
sections: {
filters: shallowRef(Filters),
summary: shallowRef(Summary),
},
section: 'filters',
};
},
};
</script>
<style lang="scss" scoped>
.dialog-body {
width: 40rem;
max-width: 100%;
}
.tabs {
display: flex;
}
.tab {
flex-grow: 1;
padding: .75rem 1rem;
background: var(--shadow-touch);
border: none;
border-bottom: solid 1px var(--shadow-hint);
color: var(--shadow);
font-size: 1rem;
font-weight: bold;
&:not(:first-child) {
border-left: solid 1px var(--shadow-hint);
}
&.selected {
background: none;
color: var(--primary);
border-bottom: none;
}
&:hover {
color: var(--shadow-strong);
cursor: pointer;
}
}
</style>

View File

@ -1,327 +0,0 @@
<template>
<div>
<div class="dialog-section">
<h3 class="form-heading">Summary format</h3>
<ul class="summary nolist">
<li
v-for="(group, groupIndex) in summaryFormat"
:key="groupIndex"
class="summary-group"
>
<div class="summary-options">
<label class="summary-option">Delimiter
<input
class="input summary-delimiter"
:value="group.delimiter"
@change="setSummaryGroupValue(groupIndex, 'delimiter', $event.target.value)"
>
</label>
<label class="summary-option">Brackets
<select
class="select summary-delimiter"
:value="group.bracket"
@change="setSummaryGroupValue(groupIndex, 'bracket', $event.target.value)"
>
<option
v-for="bracket in brackets"
:key="bracket"
>{{ bracket }}</option>
</select>
</label>
</div>
<ul class="summary-segments nolist">
<li
v-for="(segment, segmentIndex) in group.segments"
:key="segmentIndex"
class="summary-segment"
>
<select
class="select summary-prop"
:value="segment.prop"
@change="setSummarySegmentValue(groupIndex, segmentIndex, 'prop', $event.target.value)"
>
<option>channel</option>
<option>network</option>
<option>title</option>
<option>movie</option>
<option>tags</option>
<option>actors</option>
<option>date</option>
</select>
<input
v-if="delimitedProps.includes(segment.prop)"
class="input summary-delimiter"
:value="segment.delimiter"
@change="setSummarySegmentValue(groupIndex, segmentIndex, 'delimiter', $event.target.value)"
>
<select
v-if="segment.prop === 'date'"
class="select summary-format"
:value="segment.format"
@change="setSummarySegmentValue(groupIndex, segmentIndex, 'format', $event.target.value)"
>
<option>YYYY-MM-DD</option>
<option>DD-MM-YYYY</option>
<option>MM/DD/YYYY</option>
</select>
<Icon
icon="bin"
class="active"
@click="removeSummarySegment(groupIndex, segmentIndex)"
/>
</li>
<li class="summary-actions">
<button
type="button"
class="button button-secondary"
@click="addSummarySegment(groupIndex)"
>Add segment</button>
<button
type="button"
class="button button-secondary"
@click="removeSummaryGroup(groupIndex)"
>Remove group</button>
</li>
</ul>
</li>
<li class="summary summary-actions">
<button
type="button"
class="button button-secondary"
@click="addSummaryGroup"
>Add group</button>
<button
type="button"
class="button button-secondary"
@click="resetSummaryFormat"
>Reset to default</button>
</li>
</ul>
</div>
<div class="dialog-section">
<h3 class="form-heading">Preview</h3>
<input
class="input summary-preview"
:value="summary"
:title="summary"
disabled
>
</div>
</div>
</template>
<script>
import formatSummary from '../../js/utils/format-summary';
function summary() {
return formatSummary(this.scene, this.summaryFormat);
}
function summaryFormat() {
return this.$store.state.ui.summaryFormat;
}
function setSummaryGroupValue(targetGroupIndex, target, value) {
const newFormat = this.summaryFormat.map((group, groupIndex) => {
if (groupIndex === targetGroupIndex) {
return {
...group,
[target]: value,
};
}
return group;
});
this.$store.dispatch('setSummaryFormat', newFormat);
}
function setSummarySegmentValue(targetGroupIndex, targetSegmentIndex, target, value) {
const newFormat = this.summaryFormat.map((group, groupIndex) => {
if (groupIndex === targetGroupIndex) {
return {
...group,
segments: group.segments.map((segment, segmentIndex) => {
if (segmentIndex === targetSegmentIndex) {
return {
...segment,
[target]: value,
};
}
return segment;
}),
};
}
return group;
});
this.$store.dispatch('setSummaryFormat', newFormat);
}
function addSummarySegment(targetGroupIndex) {
const newFormat = this.summaryFormat.map((group, groupIndex) => {
if (groupIndex === targetGroupIndex) {
return {
...group,
segments: group.segments.concat({
prop: 'title',
delimiter: ',', // default delimiter for when prop is changed to iterabte like actors or tags
format: 'YYYY-MM-DD', // default format for when prop is changed to date
}),
};
}
return group;
});
this.$store.dispatch('setSummaryFormat', newFormat);
}
function addSummaryGroup() {
const newFormat = this.summaryFormat.concat({
delimiter: ' - ',
segments: [{ prop: 'title' }],
});
this.$store.dispatch('setSummaryFormat', newFormat);
}
function removeSummaryGroup(groupIndex) {
const newFormat = this.summaryFormat.filter((group, index) => index !== groupIndex);
this.$store.dispatch('setSummaryFormat', newFormat);
}
function removeSummarySegment(targetGroupIndex, targetSegmentIndex) {
const newFormat = this.summaryFormat.map((group, groupIndex) => ({
...group,
segments: groupIndex === targetGroupIndex
? group.segments.filter((segment, index) => index !== targetSegmentIndex)
: group.segments,
}));
this.$store.dispatch('setSummaryFormat', newFormat);
}
function resetSummaryFormat() {
this.$store.dispatch('setSummaryFormat', this.$store.state.ui.defaultSummaryFormat);
}
export default {
data() {
return {
delimiters: [null, '-', '_', ',', '.'],
brackets: [null, '()', '[]', '{}', '<>'],
delimitedProps: ['actors', 'tags'],
scene: {
channel: 'Channel',
network: 'Network',
title: 'Title',
movie: 'Movie',
scene: 1,
actors: ['Jane Doe', 'John Doe'],
date: new Date(),
},
};
},
computed: {
summary,
summaryFormat,
},
methods: {
addSummaryGroup,
setSummaryGroupValue,
addSummarySegment,
setSummarySegmentValue,
removeSummaryGroup,
removeSummarySegment,
resetSummaryFormat,
},
};
</script>
<style lang="scss" scoped>
.summary {
.icon {
padding: .5rem;
}
}
.summary-group,
.summary-segment {
display: flex;
}
.summary-group {
display: flex;
flex-direction: column;
margin-bottom: .5rem;
.button {
margin-top: .5rem;
}
&:not(:last-child) {
padding-bottom: .5rem;
border-bottom: solid 1px var(--shadow-hint);
margin-bottom: .5rem;
}
}
.summary-segments {
width: 100%;
display: flex;
flex-direction: column;
}
.summary-segment {
display: flex;
align-items: center;
margin-bottom: .25rem;
}
.summary-delimiter {
width: 5rem;
}
.summary-format {
width: 9rem;
}
.summary-prop {
flex-grow: 1;
}
.summary-options {
margin-bottom: .5rem;
}
.summary-option:not(:last-child) {
margin-right: 1rem;
}
.summary-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-preview {
width: 100%;
margin-bottom: 1rem;
}
</style>

View File

@ -1,414 +0,0 @@
<template>
<div
class="sidebar-container"
@click="events.emit('toggleSidebar', false)"
>
<div
class="sidebar"
@click.stop
>
<div class="sidebar-section">
<div class="sidebar-header">
<RouterLink
to="/updates"
class="logo-link"
@click="events.emit('toggleSidebar', false)"
>
<h1 class="sidebar-logo">
<div
class="logo"
v-html="logo"
/>
</h1>
</RouterLink>
<Icon
icon="cross2"
class="sidebar-close noselect"
@click.native="events.emit('toggleSidebar', false)"
/>
</div>
<Search
class="search"
@search="events.emit('toggleSidebar', false)"
/>
<nav class="nav">
<ul class="nolist">
<li
class="nav-item"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
v-slot="{ href, isActive, navigate }"
to="/updates"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Home</a>
</RouterLink>
</li>
<li
class="nav-item"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
v-slot="{ href, isActive, navigate }"
to="/actors"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Actors</a>
</RouterLink>
</li>
<li
class="nav-item"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
v-slot="{ href, isActive, navigate }"
to="/channels"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Channels</a>
</RouterLink>
</li>
<li
class="nav-item"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
v-slot="{ href, isActive, navigate }"
to="/movies"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Movies</a>
</RouterLink>
</li>
<li
class="nav-item"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
v-slot="{ href, isActive, navigate }"
to="/tags"
custom
>
<a
class="nav-link"
:href="href"
:class="{ active: isActive }"
@click="navigate"
>Tags</a>
</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="sidebar-section controls noselect">
<label
v-if="login && me"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
:to="{ name: 'user', params: { username: me.username } }"
class="toggle username nolink"
>{{ me.username }}</RouterLink>
</label>
<div class="toggles noselect">
<label
v-if="login && !me"
@click="events.emit('toggleSidebar', false)"
>
<RouterLink
:to="{ name: 'login', query: { ref: $route.path } }"
class="toggle nolink"
><Icon icon="enter2" />Log in</RouterLink>
</label>
<label
v-if="login && me"
class="toggle"
@click.stop="$store.dispatch('logout')"
><Icon icon="exit2" />Log out</label>
<label
v-show="sfw"
class="toggle"
@click="setSfw(false)"
><Icon icon="fire" />Disable safe mode</label>
<label
v-show="!sfw"
class="toggle"
@click="setSfw(true)"
><Icon icon="flower" />Enable safe mode</label>
<label
v-show="theme === 'dark'"
class="toggle"
@click="setTheme('light')"
><Icon icon="sun" />Use light theme</label>
<label
v-show="theme === 'light'"
class="toggle"
@click="setTheme('dark')"
><Icon icon="moon" />Use dark theme</label>
<label
class="toggle"
@click="events.emit('toggleSettings', true)"
><Icon icon="cog" />Settings</label>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Search from '../header/search.vue';
import logo from '../../img/logo.svg';
function sfw(state) {
return state.ui.sfw;
}
function theme(state) {
return state.ui.theme;
}
function login(state) {
return state.auth.login;
}
function signup(state) {
return state.auth.signup;
}
function me(state) {
return state.auth.user;
}
function setTheme(newTheme) {
this.$store.dispatch('setTheme', newTheme);
}
function setSfw(enabled) {
this.$store.dispatch('setSfw', enabled);
}
export default {
components: {
Search,
},
data() {
return {
logo,
};
},
computed: {
...mapState({
login,
signup,
me,
sfw,
theme,
}),
},
methods: {
setTheme,
setSfw,
},
};
</script>
<style lang="scss" scoped>
.sidebar-container {
height: 100%;
width: 100%;
position: absolute;
z-index: 10;
background: var(--darken-weak);
}
.sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 15rem;
height: 100%;
margin: 0 0 0 auto;
color: var(--text);
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
}
.sidebar-header {
display: flex;
justify-content: space-between;
height: 3rem;
border-bottom: solid 1px var(--shadow-hint);
}
.sidebar-close {
width: 1.25rem;
height: 100%;
padding: 0 1.125rem;
fill: var(--shadow-modest);
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
.sidebar-logo {
height: 100%;
display: flex;
align-items: center;
margin: 0;
}
.sidebar-section {
display: flex;
flex-direction: column;
overflow: hidden;
}
.logo-link {
display: block;
height: 100%;
padding: 0 1rem;
}
.logo {
width: 6rem;
display: flex;
align-items: center;
margin: 0;
fill: var(--primary);
}
:deep(.search) {
height: 3rem;
border-bottom: solid 1px var(--shadow-hint);
padding: 0;
margin: 0 0 .5rem 0;
.search-input {
padding: .5rem 0 .5rem 1rem;
}
}
.nav {
flex-grow: 1;
overflow-x: auto;
}
.nav-item {
display: block;
}
.nav-link {
color: var(--shadow);
display: block;
padding: 1rem;
text-decoration: none;
font-weight: bold;
&:hover {
color: var(--shadow-strong);
}
&.active {
color: var(--primary);
}
}
.controls {
margin: .5rem 0 0 0;
}
.toggles {
flex-shrink: 0;
border-top: solid 1px var(--shadow-hint);
}
.toggle {
display: flex;
padding: 1rem;
color: var(--shadow);
font-weight: bold;
&.username {
justify-content: center;
}
.icon {
fill: var(--shadow);
margin: 0 1rem 0 0;
}
&.active .icon {
fill: var(--primary);
}
&:hover {
cursor: pointer;
color: var(--shadow-strong);
&:not(.active) .icon {
fill: var(--shadow-strong);
}
}
}
.dark .sidebar {
background: var(--profile);
.nav-link {
color: var(--shadow);
&.active {
color: var(--text-light);
}
}
.sidebar-close {
fill: var(--lighten);
&:hover {
fill: var(--text-light);
}
}
}
</style>

View File

@ -1,71 +0,0 @@
<template>
<Dialog
title="Add stash"
@close="$emit('close', false)"
>
<form
class="dialog-body"
@submit.prevent="addStash"
>
<div
v-if="errorMsg"
class="form-error"
>{{ errorMsg }}</div>
<input
ref="name"
v-model="name"
type="input"
placeholder="Name"
class="input"
>
<div class="dialog-actions right">
<button
type="submit"
class="button button-primary"
>Add</button>
</div>
</form>
</Dialog>
</template>
<script>
async function addStash() {
this.errorMsg = null;
try {
await this.$store.dispatch('createStash', {
name: this.name,
});
this.$emit('close', true);
} catch (error) {
this.errorMsg = error.message;
}
}
function mounted() {
this.$refs.name.focus();
}
export default {
emits: ['close'],
data() {
return {
errorMsg: null,
name: null,
};
},
mounted,
methods: {
addStash,
},
};
</script>
<style lang="scss" scoped>
.input {
width: 100%;
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<span class="stash-container">
<Tooltip class="stash-trigger">
<Icon
v-show="me"
icon="menu"
class="stash noselect"
:class="{ stashed }"
/>
<template v-slot:tooltip>
<StashMenu
:stashed-by="stashedBy"
@stash="(stashId) => $emit('stash', stashId)"
@unstash="(stashId) => $emit('unstash', stashId)"
/>
</template>
</Tooltip>
<Icon
v-show="me && favorited"
icon="heart7"
class="stash stashed noselect"
@click.native="() => $emit('unstash', favorites.id)"
/>
<Icon
v-show="me && !favorited"
icon="heart8"
class="stash unstashed noselect"
@click.native="() => $emit('stash', favorites.id)"
/>
</span>
</template>
<script>
import StashMenu from './menu.vue';
function favorited() {
return this.stashedBy.some(stash => stash.primary);
}
function stashed() {
return this.stashedBy.some(stash => !stash.primary);
}
function favorites() {
return this.$store.getters.favorites;
}
function me() {
return this.$store.state.auth.user;
}
export default {
components: {
StashMenu,
},
props: {
stashedBy: {
type: Array,
default: () => [],
},
},
emits: ['stash', 'unstash'],
computed: {
me,
favorites,
favorited,
stashed,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.stash-container {
flex-shrink: 0;
&.light .icon {
fill: var(--lighten);
}
}
.stash.icon {
width: 1.5rem;
height: 1.5rem;
padding: 0 .5rem;
fill: var(--shadow);
&.stashed {
fill: var(--primary);
}
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
.stash-trigger {
display: inline-block;
}
@media(max-width: $breakpoint) {
.stash.icon {
width: 1.25rem;
height: 1.25rem;
}
}
</style>

View File

@ -1,71 +0,0 @@
<template>
<ul class="menu nolist">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="menu-item"
>
<label class="menu-stash noselect">
<Checkbox
:checked="stashedByIds.has(stash.id)"
class="menu-check"
@change="(checked) => checked ? $emit('stash', stash.id) : $emit('unstash', stash.id)"
/>{{ stash.name }}
</label>
</li>
</ul>
</template>
<script>
import Checkbox from '../form/checkbox.vue';
function stashes() {
return this.$store.state.auth.user?.stashes || [];
}
export default {
components: {
Checkbox,
},
props: {
stashedBy: {
type: Array,
default: () => [],
},
},
emits: ['stash', 'unstash'],
data() {
const stashedByIds = new Set(this.stashedBy.map(stash => stash.id));
return {
stashedByIds,
};
},
computed: {
stashes,
},
};
</script>
<style lang="scss" scoped>
.menu-item {
color: var(--text);
display: block;
}
.menu-stash {
display: flex;
align-items: center;
padding: .5rem 1rem .5rem .5rem;
&:hover {
color: var(--primary);
cursor: pointer;
}
}
.menu-check {
display: inline-block;
margin: 0 .75rem 0 0;
}
</style>

View File

@ -1,41 +0,0 @@
<template>
<Dialog
title="Remove stash"
@close="$emit('close', false)"
>
<form
class="dialog-body"
@submit.prevent="removeStash"
>
Are you sure you want to remove stash "{{ stash.name }}"?
<div class="dialog-actions right">
<button
type="submit"
class="button button-primary"
>Remove</button>
</div>
</form>
</Dialog>
</template>
<script>
async function removeStash() {
await this.$store.dispatch('removeStash', this.stash.id);
this.$emit('close', true);
}
export default {
props: {
stash: {
type: Object,
default: null,
},
},
emits: ['close'],
methods: {
removeStash,
},
};
</script>

View File

@ -1,302 +0,0 @@
<template>
<div
v-if="stash"
class="stash content"
>
<div class="stash-header">
<h2
:title="stash.name"
class="stash-name"
>{{ stash.name }}</h2>
<span class="header-section">
<RouterLink
v-if="stash.user"
:to="{ name: 'user', params: { username: stash.user.username } }"
class="header-item stash-username nolink"
><Icon icon="user3" /><span class="username-name">{{ stash.user.username }}</span></RouterLink>
<label
v-if="isMine"
v-tooltip="'Public'"
:class="{ public: stash.public }"
class="header-item stash-public"
>
<Icon
v-show="stash.public"
icon="eye"
/>
<Icon
v-show="!stash.public"
icon="eye-blocked"
/>
<Toggle
:checked="stash.public"
class="light"
@change="checked => publishStash(checked)"
/>
</label>
<Icon
v-if="isMine && !stash.primary"
icon="bin"
class="stash-remove"
@click.native="showRemoveStash = true"
/>
<RemoveStash
v-if="showRemoveStash"
:stash="stash"
@close="removeStash"
/>
</span>
</div>
<div class="content-inner">
<FilterBar :ranges="['scenes', 'actors', 'movies']" />
<Releases
v-if="$route.params.range === 'scenes' && stash.scenes?.length > 0"
:releases="stash.scenes.map(item => item.scene)"
:stash="stash"
class="stash-section stash-scenes"
@stash="fetchStash"
/>
<ul
v-if="$route.params.range === 'actors'"
class="stash-section stash-actors nolist"
>
<li
v-for="item in stash.actors"
:key="item.id"
><Actor
:actor="item.actor"
:stash="stash"
@stash="fetchStash"
/></li>
</ul>
<div
v-if="$route.params.range === 'movies'"
class="stash-movies"
>
<Movie
v-for="item in stash.movies"
:key="`movie-${item.id}`"
:movie="item.movie"
:stash="stash"
@stash="fetchStash"
/>
</div>
<Pagination
:items-total="totalCount"
:items-per-page="limit"
class="pagination-bottom"
/>
<Footer />
</div>
</div>
</template>
<script>
import Actor from '../actors/tile.vue';
import Releases from '../releases/releases.vue';
import Movie from '../releases/movie-tile.vue';
import RemoveStash from './remove.vue';
import Toggle from '../form/toggle.vue';
import FilterBar from '../filters/filter-bar.vue';
import Pagination from '../pagination/pagination.vue';
async function fetchStash() {
this.stash = await this.$store.dispatch('fetchStash', {
stashId: this.$route.params.stashId,
stashSlug: this.$route.params.stashSlug,
username: this.$route.params.username,
section: this.$route.params.range,
pageNumber: this.$route.params.pageNumber || 1,
limit: this.limit,
});
this.isMine = this.stash.user?.id === this.$store.state.auth.user?.id;
if (this.$route.params.range === 'scenes') {
this.totalCount = this.stash.sceneTotal;
}
if (this.$route.params.range === 'actors') {
this.totalCount = this.stash.actorTotal;
}
if (this.$route.params.range === 'movies') {
this.totalCount = this.stash.movieTotal;
}
this.pageTitle = this.stash.name;
}
async function publishStash(isPublic) {
await this.$store.dispatch('updateStash', {
stashId: this.stash.id,
stash: { public: isPublic },
});
this.fetchStash();
}
async function removeStash(removed) {
this.showRemoveStash = false;
if (removed && this.stash.user) {
this.$router.replace({ name: 'user', params: { username: this.stash.user.username } });
return;
}
if (removed) {
this.$router.replace({ name: 'home' });
}
}
async function mounted() {
await this.fetchStash();
}
export default {
components: {
Actor,
Movie,
Releases,
RemoveStash,
Pagination,
FilterBar,
Toggle,
},
data() {
return {
stash: null,
limit: Number(this.$route.query.limit) || 20,
pageTitle: null,
showRemoveStash: false,
isMine: false,
totalCount: 0,
};
},
watch: {
$route: fetchStash,
},
mounted,
methods: {
fetchStash,
publishStash,
removeStash,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.stash-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--profile);
color: var(--text-light);
.icon {
fill: var(--text-light);
margin: -.1rem .5rem 0 0;
}
}
.header-section,
.header-item {
height: 100%;
display: flex;
align-items: center;
}
.stash-name,
.stash-username {
box-sizing: border-box;
padding: .5rem 1rem;
font-weight: bold;
}
.stash-username {
display: inline-flex;
align-items: center;
margin: 0 .5rem 0 0;
}
.stash-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
}
.stash-public {
cursor: pointer;
margin: 0 .5rem 0 0;
.icon {
margin: 0 .75rem 0 0;
cursor: pointer;
}
}
.stash-remove.icon {
height: 100%;
padding: 0 1rem;
fill: var(--lighten-strong);
&:hover {
fill: var(--text-light);
cursor: pointer;
}
}
.stash-actors {
display: grid;
grid-gap: .5rem;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-template-rows: min-content;
flex-grow: 1;
padding: 1rem;
border-top: solid 1px var(--shadow-hint);
}
.stash-movies {
display: grid;
flex-grow: 1;
grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr));
grid-template-rows: min-content;
grid-gap: 1rem;
padding: 1rem;
border-top: solid 1px var(--shadow-hint);
}
.stash-scenes .tiles {
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
grid-template-rows: min-content;
}
@media(max-width: $breakpoint-small) {
.stash-name {
font-size: 1.25rem;
}
.username-name {
display: none;
}
.stash-username {
margin: 0;
}
}
</style>

View File

@ -1,127 +0,0 @@
<template>
<div class="stats">
<div class="content-inner">
<h1 class="heading">Stats</h1>
<dl class="stat-table">
<div class="stat-row">
<dt class="stat-label">Version</dt>
<dd class="stat-value">{{ version }}</dd>
</div>
</dl>
<template v-if="loaded">
<dl class="stat-table">
<div class="stat-row">
<dt class="stat-label">Content updated</dt>
<dd
class="stat-value"
:title="format(lastScrape, 'yyyy-MM-dd HH:mm')"
>{{ formatDistance(lastScrape, new Date(), { includeSeconds: true }) }} ago</dd>
</div>
</dl>
<dl class="stat-table">
<div class="stat-row">
<dt class="stat-label">Networks</dt>
<dd class="stat-value">{{ totalNetworks.toLocaleString() }}</dd>
</div>
<div class="stat-row">
<dt class="stat-label">Channels</dt>
<dd class="stat-value">{{ totalChannels.toLocaleString() }}</dd>
</div>
<div class="stat-row">
<dt class="stat-label">Scenes</dt>
<dd class="stat-value">{{ totalScenes.toLocaleString() }}</dd>
</div>
<div class="stat-row">
<dt class="stat-label">Movies</dt>
<dd class="stat-value">{{ totalMovies.toLocaleString() }}</dd>
</div>
<div class="stat-row">
<dt class="stat-label">Actors</dt>
<dd class="stat-value">{{ totalActors.toLocaleString() }}</dd>
</div>
</dl>
</template>
<Ellipsis v-else />
</div>
<Footer />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import { format, formatDistance } from 'date-fns';
import Ellipsis from '../loading/ellipsis.vue';
const store = useStore();
const version = VERSION; // eslint-disable-line no-undef
const loaded = ref(false);
const totalScenes = ref(0);
const totalMovies = ref(0);
const totalActors = ref(0);
const totalNetworks = ref(0);
const totalChannels = ref(0);
const lastScrape = ref(null);
onMounted(async () => {
const stats = await store.dispatch('fetchStats');
totalScenes.value = stats.totalScenes;
totalMovies.value = stats.totalMovies;
totalActors.value = stats.totalActors;
totalNetworks.value = stats.totalNetworks;
totalChannels.value = stats.totalChannels;
lastScrape.value = stats.lastScrape;
loaded.value = true;
});
</script>
<style lang="scss" scoped>
.stats {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.content-inner {
padding: 1rem;
}
.stat-table {
margin: 0 0 1rem 0;
}
.stat-row {
width: 20rem;
max-width: 100%;
display: flex;
padding: .5rem 0;
justify-content: space-between;
&:not(:last-child) {
border-bottom: solid 1px var(--shadow-hint);
}
}
.stat-label {
display: inline-block;
font-weight: bold;
color: var(--shadow-strong);
}
.stat-value {
display: inline-block;
}
</style>

View File

@ -1,168 +0,0 @@
<template>
<div class="photos">
<Campaign
:tag="tag"
:min-height="240"
:max-ratio="1.5"
class="photo-link photo"
@campaign="campaign => $emit('campaign', campaign)"
/>
<a
v-for="photo in photos"
:key="`photo-${photo.id}`"
:title="photo.comment"
:href="`/img/${photo.path}`"
target="_blank"
rel="noopener noreferrer"
class="photo-link"
>
<img
:src="getPath(photo, 'thumbnail', { local: true })"
:style="{ 'background-image': getBgPath(photo, 'lazy', { local: true }) }"
:alt="photo.comment"
:width="photo.thumbnailWidth"
:height="photo.thumbnailHeight"
class="photo"
@load="$emit('load', $event)"
>
<Logo :photo="photo" />
<router-link
v-if="photo.comment && photo.entity"
:to="`/${photo.entity.type}/${photo.entity.slug}`"
class="photo-comment"
>{{ photo.comment }} for {{ photo.entity.name }}</router-link>
<span
v-else-if="photo.comment"
class="photo-comment"
>{{ photo.comment }}</span>
</a>
</div>
</template>
<script>
import Logo from '../album/logo.vue';
import Campaign from '../campaigns/campaign.vue';
function photos() {
if (this.tag.poster && this.$store.state.ui.sfw) {
return [this.tag.poster].concat(this.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return this.tag.photos.map((photo) => photo.sfw);
}
if (this.tag.poster) {
return [this.tag.poster].concat(this.tag.photos);
}
return this.tag.photos;
}
export default {
components: {
Logo,
Campaign,
},
props: {
tag: {
type: Object,
default: null,
},
},
emits: ['load', 'campaign'],
computed: {
photos,
},
};
</script>
<style lang="scss" scoped>
.photos {
width: 100%;
padding: .5rem 1rem;
box-sizing: border-box;
white-space: nowrap;
font-size: 0;
border-bottom: solid 1px var(--shadow-hint);
&::-webkit-scrollbar {
display: none;
}
&.expanded {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 0 0 0 1rem;
.photo-link {
margin: 0 .5rem .5rem 0;
}
.poster,
.photo {
max-height: 18rem;
}
}
}
.photo-link {
display: inline-block;
position: relative;
overflow: hidden;
margin: 0 .5rem 0 0;
&:last-child {
margin: 0 1rem 0 0;
}
&:hover {
.photo-comment {
transform: translateY(0);
}
::v-deep(.album-logo) {
opacity: 0;
}
}
}
.poster,
.photo {
width: auto;
max-height: 15rem;
max-width: 100%;
box-shadow: 0 0 3px var(--shadow-weak);
object-fit: cover;
background-position: center;
background-size: cover;
}
.photo-comment {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
box-sizing: border-box;
padding: .5rem;
color: var(--text-light);
background: var(--shadow);
font-size: .9rem;
text-shadow: 0 0 3px var(--shadow);
text-decoration: none;
white-space: normal;
line-height: 1.25;
transform: translateY(100%);
transition: transform .25s ease;
}
::v-deep(.campaign) .campaign-banner {
max-height: 15rem;
width: auto;
}
</style>

View File

@ -1,240 +0,0 @@
<template>
<div
v-if="tag"
class="tag content"
>
<div class="header">
<h2 class="title">
<Icon icon="price-tag4" />
{{ tag.name }}
</h2>
</div>
<div
class="content-inner"
@scroll="events.emit('scroll', $event)"
>
<div
v-if="description"
class="description header-description"
v-html="description"
/>
<Scroll
v-if="hasMedia"
v-slot="scroll"
class="scroll-dark"
>
<Photos
:tag="tag"
:class="{ expanded }"
@load="scroll.loaded"
@campaign="campaign => showBannerCampaign = !campaign"
/>
</Scroll>
<button
v-if="tag.photos && tag.photos.length > 2"
class="album-toggle"
@click="$router.push({ hash: '#album' })"
><Icon icon="grid3" />View album</button>
<Album
v-if="showAlbum"
:items="[tag.poster, ...tag.photos]"
:title="tag.name"
:local="true"
:tag="tag"
class="portrait"
@close="$router.replace({ hash: undefined })"
/>
<div
v-if="showBannerCampaign"
class="campaign-container"
>
<Campaign
:tag="tag"
:min-ratio="3"
/>
</div>
<FilterBar
ref="filter"
:fetch-releases="fetchReleases"
/>
<Releases
:releases="releases"
:done="done"
/>
<Pagination
:items-total="totalCount"
:items-per-page="limit"
class="pagination-bottom"
/>
<Footer />
</div>
</div>
</template>
<script>
import config from 'config';
import { Converter } from 'showdown';
import escapeHtml from '../../../src/utils/escape-html';
import FilterBar from '../filters/filter-bar.vue';
import Photos from './photos.vue';
import Album from '../album/album.vue';
import Releases from '../releases/releases.vue';
import Pagination from '../pagination/pagination.vue';
import Scroll from '../scroll/scroll.vue';
import Campaign from '../campaigns/campaign.vue';
const converter = new Converter();
async function fetchReleases(scroll = true) {
this.done = false;
const { tag, releases, totalCount } = await this.$store.dispatch('fetchTagBySlug', {
tagSlug: this.$route.params.tagSlug,
pageNumber: Number(this.$route.params.pageNumber),
limit: this.limit,
range: this.$route.params.range,
});
this.tag = tag;
this.releases = releases;
this.totalCount = totalCount;
this.hasMedia = this.tag.poster || this.tag.photos.length > 0;
this.description = this.tag.description && converter.makeHtml(escapeHtml(this.tag.description));
this.done = true;
if (this.hasMedia) {
this.showBannerCampaign = true;
}
if (scroll && this.$refs.filter) {
this.$refs.filter.$el.scrollIntoView();
}
}
function showAlbum() {
return this.tag.photos?.length > 0 && this.$route.hash === '#album';
}
async function watchRoute(to, from) {
if (to.hash !== '#album' && from.hash !== '#album') {
await this.fetchReleases();
}
}
async function mounted() {
await this.fetchReleases();
this.pageTitle = this.tag.name;
}
export default {
components: {
FilterBar,
Releases,
Album,
Photos,
Pagination,
Scroll,
Campaign,
},
data() {
return {
tag: null,
description: null,
releases: null,
done: false,
totalCount: 0,
limit: Number(this.$route.query.limit || 30) - config.campaigns.tiles, // reserve one campaign spot
pageTitle: null,
hasMedia: false,
expanded: false,
showBannerCampaign: false, // only show if photo campaign is not available
};
},
computed: {
showAlbum,
},
watch: {
$route: watchRoute,
'$store.state.ui.tagFilter': fetchReleases,
},
mounted,
methods: {
fetchReleases,
},
};
</script>
<style lang="scss">
@import 'theme';
.description a {
color: var(--link);
text-decoration: inherit;
&:hover {
color: var(--primary);
}
}
.description,
.description p {
padding: 0;
margin: 0;
}
</style>
<style lang="scss" scoped>
@import 'theme';
.header {
display: flex;
background: var(--profile);
color: var(--text-light);
justify-content: space-between;
}
.title {
display: inline-block;
padding: .5rem 1rem;
margin: 0;
flex-shrink: 0;
text-transform: capitalize;
.icon {
fill: var(--text-light);
width: 1.25rem;
height: 1.25rem;
}
}
.description {
padding: 0 1rem .5rem 1rem;
line-height: 1.5;
color: var(--text-light);
background: var(--profile);
}
.scroll {
background: var(--background-dim);
}
.campaign-container {
max-height: 90px;
padding: .5rem 1rem 0 1rem;
background: var(--background-dim);
text-align: center;
}
</style>

View File

@ -1,338 +0,0 @@
<template>
<div class="tags">
<div class="content-inner">
<SearchBar
:placeholder="'Search tags'"
class="search"
/>
<div
v-for="(tags, category) in categories"
:key="category"
class="category"
>
<h3 class="heading">{{ category }}</h3>
<div
:key="sfw"
class="tiles"
>
<Tag
v-for="tag in tags"
:key="`tag-${tag.id}`"
:tag="tag"
:lazy="true"
/>
</div>
</div>
</div>
<Footer />
</div>
</template>
<script>
import Tag from './tile.vue';
import SearchBar from '../search/bar.vue';
const tagSlugsByCategory = {
popular: [
'anal',
'lesbian',
'interracial',
'mff',
'mfm',
'teen',
'milf',
'blowjob',
'gay',
'transsexual',
'dp',
'gangbang',
'facial',
'creampie',
'squirting',
],
appearance: [
'asian',
'black',
'latina',
'white',
'natural-boobs',
'enhanced-boobs',
'blonde',
'brunette',
'redhead',
'tattoos',
'piercings',
],
sexuality: [
'gay',
'bisexual',
'transsexual',
],
oral: [
'blowjob',
'pussy-eating',
'ass-eating',
'deepthroat',
'facefucking',
'69',
'atm',
],
manual: [
'handjob',
'fingering',
'anal-fingering',
'titty-fucking',
'fisting',
'anal-fisting',
],
group: [
'mfm',
'mff',
'orgy',
'gangbang',
'blowbang',
],
cumshot: [
'facial',
'creampie',
'cum-in-mouth',
'cum-on-boobs',
'cum-on-butt',
'cum-on-pussy',
'anal-creampie',
'oral-creampie',
'bukkake',
'fake-cum',
],
roleplay: [
'family',
'parody',
'schoolgirl',
'nurse',
'maid',
'nun',
],
extreme: [
'dp',
'airtight',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
],
fetish: [
'bdsm',
'femdom',
'bondage',
'free-use',
'latex',
'blindfold',
],
toys: [
'toys',
'toy-anal',
'toy-dp',
'double-dildo',
'double-dildo-blowjob',
'double-dildo-kiss',
'double-dildo-anal',
'double-dildo-dp',
],
misc: [
'gaping',
'squirting',
'oil',
'vr',
'bts',
],
};
function sfw() {
return this.$store.state.ui.sfw;
}
async function fetchTags() {
if (this.$route.query.query) {
await this.searchTags();
return;
}
const tags = await this.$store.dispatch('fetchTags', {
slugs: Object.values(tagSlugsByCategory).flat(),
});
const tagsBySlug = tags.reduce((acc, tag) => ({ ...acc, [tag.slug]: tag }), {});
this.categories = Object.entries(tagSlugsByCategory).reduce((acc, [category, tagSlugs]) => {
const categoryTags = tagSlugs.map((tagSlug) => tagsBySlug[tagSlug]).filter(Boolean);
if (categoryTags.length === 0) {
return acc;
}
return {
...acc,
[category]: categoryTags,
};
}, {});
}
async function searchTags() {
const tags = await this.$store.dispatch('searchTags', {
minLength: 1,
query: this.$route.query.query,
limit: 20,
});
this.categories = {
results: tags,
};
}
async function mounted() {
this.pageTitle = 'Tags';
await this.fetchTags();
}
export default {
components: {
Tag,
SearchBar,
},
data() {
return {
categories: {},
pageTitle: null,
};
},
computed: {
sfw,
},
watch: {
$route: fetchTags,
'$store.state.ui.tagFilter': fetchTags,
},
mounted,
methods: {
fetchTags,
searchTags,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.tags {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.content-inner {
padding: 1rem 1rem 0 1rem;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, .33fr));
grid-gap: 1rem;
}
.heading {
text-transform: capitalize;
padding: 0 0 0 .5rem;
margin: 1.25rem 0 1rem 0;
}
.category:first-child .heading {
margin: .5rem 0 1rem 0;
}
.search {
margin: 0 0 .25rem 0;
}
@media(max-width: $breakpoint-mega) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(17.75rem, .5fr));
}
::v-deep(.poster),
::v-deep(.blank) {
height: 12rem;
}
}
@media(max-width: $breakpoint-kilo) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: .5rem;
}
::v-deep(.poster),
::v-deep(.blank) {
height: 14rem;
}
}
@media(max-width: $breakpoint) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr));
grid-gap: .5rem;
}
::v-deep(.poster),
::v-deep(.blank) {
height: 11rem;
}
}
@media(max-width: $breakpoint-small) {
::v-deep(.poster),
::v-deep(.blank) {
height: 10rem;
}
}
@media(max-width: $breakpoint-micro) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
::v-deep(.poster),
::v-deep(.blank) {
height: 7.5rem;
}
}
@media(max-width: $breakpoint-mini) {
.tiles {
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
}
::v-deep(.poster),
::v-deep(.blank) {
height: 12rem;
}
}
@media(max-width: $breakpoint-nano) {
::v-deep(.poster),
::v-deep(.blank) {
height: 10rem;
}
}
@media(max-width: $breakpoint-pico) {
::v-deep(.poster),
::v-deep(.blank) {
height: 8rem;
}
}
</style>

View File

@ -1,166 +0,0 @@
<template>
<div
class="tile"
>
<router-link
:to="`/tag/${tag.slug}`"
:title="tag.name"
:class="{ blank: !tag.poster }"
class="poster-link"
>
<template v-if="tag.poster">
<img
v-if="!lazy && !sfw"
:src="`/img/${tag.poster.thumbnail}`"
:style="{ 'background-image': `url(/img/${tag.poster.lazy})` }"
:title="comment"
:alt="tag.name"
class="poster"
>
<img
v-if="!lazy && sfw"
:src="`/img/${tag.poster.sfw.thumbnail}`"
:style="{ 'background-image': `url(/img/${tag.poster.sfw.lazy})` }"
:title="tag.poster.sfw.comment"
:alt="tag.name"
class="poster"
>
<img
v-if="lazy && !sfw"
:src="`/img/${tag.poster.thumbnail}`"
:style="{ 'background-image': `url(/img/${tag.poster.lazy})` }"
:title="comment"
:alt="tag.name"
loading="lazy"
class="poster"
>
<img
v-if="lazy && sfw"
:src="`/img/${tag.poster.sfw.thumbnail}`"
:style="{ 'background-image': `url(/img/${tag.poster.sfw.lazy})` }"
:title="tag.poster.sfw.comment"
:alt="tag.name"
loading="lazy"
class="poster"
>
<Logo
v-if="!sfw"
:photo="tag.poster"
favicon
/>
</template>
<Icon
v-else
icon="price-tag2"
/>
</router-link>
<router-link
class="title"
:to="`/tag/${tag.slug}`"
:title="tag.name"
>{{ tag.name }}</router-link>
</div>
</template>
<script>
import Logo from '../album/logo.vue';
function sfw() {
return this.$store.state.ui.sfw;
}
export default {
components: {
Logo,
},
props: {
tag: {
type: Object,
default: null,
},
lazy: {
type: Boolean,
default: false,
},
},
data() {
return {
comment: this.tag.poster?.entity ? `${this.tag.poster.comment} for ${this.tag.poster.entity.name}` : this.tag.poster?.comment,
};
},
computed: {
sfw,
},
};
</script>
<style lang="scss" scoped>
.tile {
display: flex;
flex-direction: column;
box-sizing: border-box;
position: relative;
text-decoration: none;
font-size: 0;
&:hover {
.poster,
.blank {
box-shadow: 0 0 2px var(--darken);
}
.title {
color: var(--primary);
}
}
}
.poster {
display: inline-block;
width: 100%;
object-fit: cover;
background-size: cover;
background-position: center;
box-shadow: 0 0 3px var(--darken-weak);
}
.poster,
.blank {
height: 13.5rem;
}
.poster-link {
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-grow: 1;
background: var(--shadow-hint);
.icon {
width: 2rem;
height: 2rem;
fill: var(--shadow-weak);
}
}
.title {
display: block;
box-sizing: border-box;
padding: .5rem;
overflow: hidden;
white-space: nowrap;
color: var(--shadow-strong);
text-decoration: none;
font-size: .9rem;
font-weight: bold;
text-transform: capitalize;
text-overflow: ellipsis;
}
</style>

View File

@ -1,184 +0,0 @@
<template>
<div class="tooltip-container">
<div
ref="trigger"
class="trigger noselect"
@click.stop="toggle"
>
<slot />
</div>
<teleport to="body">
<div
v-if="opened"
ref="tooltip"
class="tooltip-wrapper"
:style="{ transform: `translate3d(${tooltipX}px, ${tooltipY}px, 0)` }"
@click.stop
>
<div
class="tooltip-inner"
:style="{ 'max-height': `calc(100vh - ${tooltipY}px - 1rem)` }"
>
<div class="tooltip">
<slot name="tooltip" />
</div>
</div>
<div
class="tooltip-arrow"
:style="{ transform: `translate3d(${arrowOffset}px, 0, 0)` }"
/>
</div>
</teleport>
</div>
</template>
<script>
import { nextTick } from 'vue';
function getX(triggerBoundary, tooltipBoundary) {
const idealPosition = triggerBoundary.left + (triggerBoundary.width / 2) - (tooltipBoundary.width / 2);
const rightEdgeOverflow = Math.max((idealPosition + tooltipBoundary.width) - window.innerWidth, 0);
// don't overflow left edge
if (idealPosition < 0) {
return {
tooltipX: 0,
arrowOffset: idealPosition,
};
}
// don't overflow right edge
if (rightEdgeOverflow > 0) {
return {
tooltipX: window.innerWidth - tooltipBoundary.width,
arrowOffset: rightEdgeOverflow,
};
}
// position at the center of trigger
return {
tooltipX: idealPosition,
arrowOffset: 0,
};
}
async function calculate() {
if (!this.opened) {
return;
}
const triggerBoundary = this.$refs.trigger.getBoundingClientRect();
const tooltipBoundary = this.$refs.tooltip.getBoundingClientRect();
const { tooltipX, arrowOffset } = this.getX(triggerBoundary, tooltipBoundary);
this.tooltipY = triggerBoundary.top + triggerBoundary.height + 5;
this.tooltipX = tooltipX;
this.arrowOffset = arrowOffset;
}
async function open() {
this.events.emit('blur');
await nextTick();
this.opened = true;
await nextTick();
this.calculate();
this.$emit('open');
}
function close() {
this.opened = false;
this.tooltipY = 0;
this.tooltipX = 0;
this.arrowOffset = 0;
this.$emit('close');
}
function toggle() {
if (this.opened) {
this.close();
return;
}
this.open();
}
function mounted() {
this.events.on('blur', () => {
this.close();
});
this.events.on('resize', () => {
this.calculate();
});
this.events.on('scroll', () => {
this.calculate();
});
}
export default {
emits: ['open', 'close'],
data() {
return {
opened: false,
tooltipX: 0,
tooltipY: 0,
arrowOffset: 0,
};
},
mounted,
methods: {
calculate,
getX,
open,
close,
toggle,
},
};
</script>
<style lang="scss" scoped>
.tooltip-wrapper {
display: flex;
top: 0;
left: 0;
flex-direction: column;
justify-content: center;
position: absolute;
z-index: 10;
}
.tooltip-inner {
position: relative;
overflow-y: auto;
background: var(--background);
box-shadow: 0 0 .5rem var(--darken);
}
.tooltip {
position: relative;
background: var(--background);
}
.tooltip-arrow {
content: '';
width: 0;
height: 0;
position: absolute;
top: -.5rem;
left: calc(50% - .5rem);
border-left: .5rem solid transparent;
border-right: .5rem solid transparent;
border-bottom: .5rem solid var(--background);
margin: 0 auto;
filter: drop-shadow(0 0 3px var(--darken-weak));
}
</style>

View File

@ -1,286 +0,0 @@
<template>
<div class="alert">
<div class="alert-section alert-header">
<div class="alert-targets">
<Icon
v-if="alert.notify"
icon="bell2"
class="alert-action"
/>
<Icon
v-if="alert.email"
icon="envelop"
class="alert-action"
/>
<Icon
v-if="alert.stashes?.length > 0"
v-tooltip="alert.stashes.map(stash => stash.name).join()"
icon="heart7"
class="alert-action"
/>
</div>
<span class="header-actions noselect">
<Icon
v-if="isMe"
icon="bin"
class="alert-remove"
@click.native="showRemoveAlert = true"
/>
<RemoveAlert
v-if="showRemoveAlert"
:alert="alert"
@close="removeAlert"
/>
</span>
</div>
<div class="alert-triggers">
<div
v-if="alert.actors?.length > 0"
class="alert-section alert-trigger"
>
<h4
v-if="alert.actors.length > 1"
class="alert-heading"
>Actors</h4>
<h4
v-else
class="alert-heading"
>Actor</h4>
<ul class="alert-actors nolist">
<li
v-for="actor in alert.actors"
:key="`actor-${actor.id}`"
class="alert-actor"
>
<ActorPreview
:actor="actor"
:alert="alert"
/>
</li>
</ul>
</div>
<div
v-if="alert.tags?.length > 0"
class="alert-section alert-trigger"
>
<h4
v-if="alert.tags.length > 1"
class="alert-heading"
>Tags</h4>
<h4
v-else
class="alert-heading"
>Tag</h4>
<ul class="alert-tags nolist">
<li
v-for="tag in alert.tags"
:key="`tag-${tag.id}`"
><router-link
:to="`/tag/${tag.slug}`"
class="tag nolink"
>{{ tag.name }}</router-link></li>
</ul>
</div>
<div
v-if="alert.entities.length > 0"
class="alert-section alert-trigger"
>
<h4 class="alert-heading">{{ alert.entities.length > 1 ? 'Channels' : 'Channel' }}</h4>
<Entity
v-for="entity in alert.entities"
:key="`${alert.id}${entity.id}`"
:entity="entity"
class="entity"
/>
</div>
<div
v-if="alert.matches.length > 0"
class="alert-section alert-trigger"
>
<h4 class="alert-heading">Matches</h4>
<ul class="alert-matches nolist">
<li
v-for="match in alert.matches"
:key="`match-${match.id}`"
class="match"
>
<span
v-if="match.expression.slice(0, 1) === '/' && match.expression.slice(-1) === '/'"
class="match-expression"
><span class="match-slash">/</span>{{ match.expression.slice(1, -1) }}<span class="match-slash">/</span></span>
<span
v-else
class="match-expression"
>{{ match.expression }}</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import ActorPreview from '../actors/preview.vue';
import RemoveAlert from '../alerts/remove.vue';
import Entity from '../entities/tile.vue';
async function removeAlert(removed) {
this.showRemoveAlert = false;
if (removed) {
this.$emit('remove');
}
}
export default {
components: {
ActorPreview,
Entity,
RemoveAlert,
},
props: {
alert: {
type: Object,
default: null,
},
isMe: {
type: Boolean,
default: false,
},
},
emits: ['remove'],
data() {
return {
showRemoveAlert: false,
};
},
methods: {
removeAlert,
},
};
</script>
<style lang="scss" scoped>
.alert {
min-width: 0;
height: 100%;
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
}
.alert-header {
border-bottom: solid 1px var(--shadow-hint);
}
.alert-section {
display: inline-block;
padding: .5rem;
}
.alert-header {
display: flex;
justify-content: space-between;
align-items: stretch;
padding: 0;
.alert-action {
padding: .5rem;
fill: var(--shadow);
}
}
.alert-triggers {
display: flex;
}
.alert-heading {
display: block;
padding: 0 .5rem .5rem 0;
margin: 0;
color: var(--shadow-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: .9rem;
}
.alert-more {
flex-shrink: 0;
margin: 0 0 0 .5rem;
color: var(--shadow);
font-size: .9rem;
}
.header-actions {
display: flex;
align-items: stretch;
}
.alert-remove {
height: auto;
padding: 0 .5rem 0 .75rem;
fill: var(--shadow);
&:hover {
cursor: pointer;
fill: var(--shadow-strong);
}
}
.alert-triggers {
dislay: flex;
flex-wrap: wrap;
}
.alert-trigger {
flex-shrink: 0;
flex-grow: 1;
}
.alert-actors,
.alert-tags,
.alert-matches {
display: flex;
grid-gap: .5rem;
}
.tag,
.match {
color: var(--shadow-strong);
padding: .5rem;
border: solid 1px var(--shadow-hint);
font-size: .9rem;
font-weight: bold;
}
.tag:hover {
cursor: pointer;
border: solid 1px var(--primary);
}
.entity {
width: 10rem;
height: 2.5rem;
}
.match-slash {
padding: 0 .1rem;
color: var(--primary);
font-weight: bold;
}
</style>

View File

@ -1,247 +0,0 @@
<template>
<div class="stash">
<div class="stash-section stash-header">
<RouterLink
:to="{ name: 'stash', params: { stashId: stash.id, stashSlug: stash.slug, range: 'scenes', pageNumber: 1 } }"
class="stash-link nolink"
>
<h4 class="stash-name">{{ stash.name }}</h4>
<span class="stash-more">Browse</span>
</RouterLink>
<span class="header-actions noselect">
<label
v-if="isMe"
v-tooltip="'Public'"
:class="{ public: stash.public }"
class="stash-public"
>
<Icon
v-show="stash.public"
icon="eye"
/>
<Icon
v-show="!stash.public"
icon="eye-blocked"
/>
<Toggle
:checked="stash.public"
@change="checked => publishStash(checked)"
/>
</label>
<Icon
v-if="isMe && !stash.primary"
icon="bin"
class="stash-remove"
@click.native="showRemoveStash = true"
/>
<RemoveStash
v-if="showRemoveStash"
:stash="stash"
@close="removeStash"
/>
</span>
</div>
<ul
v-if="stash.scenes?.length > 0"
class="stash-section stash-scenes nolist"
>
<li
v-for="{ scene } in stash.scenes"
:key="scene.id"
class="stash-scene"
>
<ScenePreview
:scene="scene"
:stash="stash"
/>
</li>
</ul>
<ul
v-if="stash.actors?.length > 0"
class="stash-section stash-actors nolist"
>
<li
v-for="{ actor } in stash.actors"
:key="actor.id"
class="stash-actor"
>
<ActorPreview
:actor="actor"
:stash="stash"
/>
</li>
</ul>
</div>
</template>
<script>
import ActorPreview from '../actors/preview.vue';
import ScenePreview from '../releases/scene-preview.vue';
import RemoveStash from '../stashes/remove.vue';
import Toggle from '../form/toggle.vue';
async function publishStash(isPublic) {
await this.$store.dispatch('updateStash', {
stashId: this.stash.id,
stash: { public: isPublic },
});
this.$emit('publish', isPublic);
}
async function removeStash(removed) {
this.showRemoveStash = false;
if (removed) {
this.$emit('remove');
}
}
export default {
components: {
ActorPreview,
ScenePreview,
RemoveStash,
Toggle,
},
props: {
stash: {
type: Object,
default: null,
},
isMe: {
type: Boolean,
default: false,
},
},
emits: ['publish', 'remove'],
data() {
return {
showRemoveStash: false,
};
},
methods: {
publishStash,
removeStash,
},
};
</script>
<style lang="scss" scoped>
.stash {
min-width: 0;
height: 100%;
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
}
.stash-section {
display: flex;
align-items: center;
padding: .5rem;
&:not(:last-child),
&.stash-header {
border-bottom: solid 1px var(--shadow-hint);
}
}
.stash-header {
justify-content: space-between;
align-items: stretch;
padding: 0;
}
.stash-link {
display: inline-flex;
align-items: center;
flex-grow: 1;
text-decoration: none;
overflow: hidden;
&:hover .stash-more {
color: var(--primary);
}
}
.stash-name {
display: inline-block;
padding: .5rem;
margin: 0;
color: var(--shadow-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stash-more {
flex-shrink: 0;
margin: 0 0 0 .5rem;
color: var(--shadow);
font-size: .9rem;
}
.header-actions {
display: flex;
align-items: stretch;
}
.stash-public {
display: inline-flex;
align-items: center;
padding: .5rem;
cursor: pointer;
.icon {
fill: var(--shadow-strong);
margin: 0 .5rem 0 0;
}
}
.stash-remove {
height: auto;
padding: 0 .5rem 0 .75rem;
fill: var(--shadow);
&:hover {
cursor: pointer;
fill: var(--shadow-strong);
}
}
.stash-actors,
.stash-scenes {
display: flex;
overflow-x: auto;
grid-gap: .5rem;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.stash-scenes {
height: 8rem;
}
.stash-actor,
.stash-scene {
height: 100%;
flex-shrink: 0;
font-size: 0;
&:last-child {
padding: 0 .5rem 0 0;
}
}
</style>

View File

@ -1,250 +0,0 @@
<template>
<div
v-if="user"
class="user"
>
<div class="header">
<h2 class="username">{{ user.username }}</h2>
</div>
<section
v-if="user.stashes?.length > 0"
class="section"
>
<div class="section-header">
<h3 class="section-heading">Stashes</h3>
<button
v-if="isMe"
class="button button-secondary header-add"
@click="showAddStash = true"
>
<Icon icon="plus3" />Add stash
</button>
</div>
<ul class="section-body stashes nolist">
<li
v-for="stash in user.stashes"
:key="stash.id"
class="stashes-stash"
>
<Stash
:stash="stash"
:is-me="isMe"
@publish="() => fetchUser()"
@remove="() => fetchUser()"
/>
</li>
<!--
<li
v-if="isMe"
class="stashes-stash stashes-add"
@click="showAddStash = true"
>
<Icon icon="plus2" />
</li>
-->
</ul>
<AddStash
v-if="showAddStash"
@close="closeAddStash"
/>
</section>
<section class="section">
<div class="section-header">
<h3 class="section-heading">Alerts</h3>
<button
v-if="isMe"
class="button button-secondary header-add"
@click="showAddAlert = true"
>
<Icon icon="plus3" />Set alert
</button>
</div>
<ul class="section-body alerts nolist">
<li
v-for="alert in user.alerts"
:key="`alert-${alert.id}`"
class="alert"
>
<Alert
:alert="alert"
:is-me="isMe"
@remove="() => fetchUser()"
/>
</li>
<!--
<li
class="alerts-add"
@click="showAddAlert = true"
>
<Icon icon="plus2" />
</li>
-->
</ul>
<AddAlert
v-if="showAddAlert"
@close="closeAddAlert"
>Alert</AddAlert>
</section>
</div>
</template>
<script>
import Stash from './stash.vue';
import Alert from './alert.vue';
import AddStash from '../stashes/add.vue';
import AddAlert from '../alerts/add.vue';
async function fetchUser() {
this.user = await this.$store.dispatch('fetchUser', this.$route.params.username);
this.isMe = this.user?.id === this.$store.state.auth.user?.id;
this.pageTitle = this.user?.username;
}
async function closeAddStash(addedStash) {
this.showAddStash = false;
if (addedStash) {
await this.fetchUser();
}
}
async function closeAddAlert(addedAlert) {
this.showAddAlert = false;
if (addedAlert) {
await this.fetchUser();
}
}
async function mounted() {
await this.fetchUser();
}
export default {
components: {
AddAlert,
AddStash,
Alert,
Stash,
},
data() {
return {
user: this.$route.params.username === this.$store.state.auth.user?.username
? this.$store.state.auth.user
: null,
isMe: false,
pageTitle: null,
showAddStash: false,
showAddAlert: false,
};
},
mounted,
methods: {
closeAddAlert,
closeAddStash,
fetchUser,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.header {
display: flex;
justify-content: space-between;
background: var(--profile);
}
.username {
padding: .5rem 1rem;
margin: 0;
font-size: 1.5rem;
color: var(--text-light);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.section {
padding: 1rem 0;
margin: 0 0 1rem 0;
}
.stashes,
.alerts {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: 15fr;
grid-gap: 1rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 1rem 1rem 0;
}
.section-body {
padding: 0 1rem;
}
.section-heading {
color: var(--primary);
padding: 0 1rem;
margin: 0;
font-size: 1.25rem;
}
.header-add {
}
.stashes-stash {
min-width: 0;
}
.stashes-add,
.alerts-add {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 1rem;
background: var(--shadow-touch);
.icon {
width: 1.5rem;
height: 1.5rem;
fill: var(--shadow-hint);
}
&:hover {
background: var(--shadow-hint);
cursor: pointer;
.icon {
fill: var(--shadow-weak);
}
}
}
@media(max-width: $breakpoint-kilo) {
.stashes,
.alerts {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,143 +0,0 @@
<template>
<video
ref="player"
:poster="poster"
crossorigin="anonymous"
class="player video-js vjs-big-play-centered"
@playing="$emit('play')"
@pause="$emit('pause')"
/>
</template>
<script>
import videoJs from 'video.js';
import 'videojs-vr/dist/videojs-vr.min';
function updatePlayer() {
this.player.src(this.getPath(this.video));
this.player.poster(this.poster);
}
function initPlayer() {
this.player = videoJs(this.$refs.player, {
controls: true,
inactivityTimeout: 1000,
controlBar: {
pictureInPictureToggle: false,
volumePanel: {
inline: false,
},
},
}, () => {
this.player.src(this.getPath(this.video));
if (this.video.isVr) {
this.player.vr({ projection: '180' });
}
});
}
export default {
props: {
video: {
type: Object,
default: null,
},
poster: {
type: String,
default: null,
},
},
data() {
return {
player: null,
};
},
emits: ['play', 'pause'],
watch: {
video: updatePlayer,
poster: updatePlayer,
},
mounted: initPlayer,
};
</script>
<style lang="scss">
.player.video-js {
.vjs-poster {
background-size: cover;
}
.vjs-control-bar {
background-color: var(--darken);
}
.vjs-slider {
background-color: var(--darken-weak);
}
.vjs-play-progress {
background-color: var(--primary);
.vjs-time-tooltip {
color: var(--darken-strong);
}
}
.vjs-load-progress {
background-color: var(--lighten-weak);
div {
background-color: var(--darken-weak);
}
}
.vjs-mouse-display .vjs-time-tooltip {
background-color: var(--darken-strong);
}
.vjs-remaining-time {
line-height: 2rem;
font-size: .75rem;
font-weight: bold;
}
.vjs-big-play-button {
background-color: var(--darken);
width: 4rem;
height: 4rem;
margin: -2rem 0 0 -2rem;
border: none;
border-radius: 50%;
.vjs-icon-placeholder {
font-size: 2.5rem;
line-height: 4rem;
}
&:hover {
background-color: var(--primary);
}
}
.vjs-control:focus::before,
.vjs-control:hover::before,
.vjs-control:focus {
text-shadow: 0 0 .5rem var(--lighten-strong);
}
.vjs-error-display {
overflow: hidden;
}
.vjs-modal-dialog-content {
white-space: normal;
}
&:hover {
.vjs-big-play-button {
background-color: var(--primary);
}
}
}
</style>

View File

@ -1,9 +0,0 @@
$breakpoint-pico: 270px;
$breakpoint-nano: 320px;
$breakpoint-mini: 400px;
$breakpoint-micro: 540px;
$breakpoint-small: 620px;
$breakpoint: 720px;
$breakpoint-kilo: 900px;
$breakpoint-mega: 1200px;
$breakpoint-giga: 1500px;

View File

@ -1,13 +0,0 @@
.form-heading {
margin: 0 0 .5rem 0;
font-size: 1rem;
font-weight: bold;
}
.form-error {
padding: .5rem;
margin-bottom: .5rem;
color: var(--text-light);
background: var(--error);
font-weight: bold;
}

View File

@ -1,107 +0,0 @@
.input {
box-sizing: border-box;
padding: .5rem;
border: solid 1px var(--shadow-hint);
color: var(--shadow-strong);
background: var(--background);
font-size: 1rem;
font-family: inherit;
&:focus {
border: solid 1px var(--primary);
}
&::-webkit-calendar-picker-indicator {
opacity: .5;
}
}
.select {
color: var(--shadow-strong);
background: var(--background);
padding: .5rem;
font-size: 1rem;
border: solid 1px var(--shadow-weak);
cursor: pointer;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: none;
padding: .5rem;
font-size: .9rem;
font-weight: bold;
&:hover {
cursor: pointer;
}
}
.button-primary {
color: var(--text-light);
background: var(--primary);
&:hover {
background: var(--primary-strong);
}
&:disabled {
background: var(--shadow-weak);
cursor: default;
}
.icon {
fill: var(--text-light);
margin-right: .5rem;
}
}
.button-secondary {
color: var(--primary);
&:hover:not(:disabled) {
color: var(--text-light);
background: var(--primary);
.icon {
fill: var(--text-light);
}
}
&:disabled {
color: var(--shadow-strong);
cursor: default;
}
.icon {
fill: var(--primary);
margin-right: .5rem;
}
}
.album-toggle {
height: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
padding: .5rem 1rem;
border: none;
border-bottom: solid 1px var(--shadow-hint);
color: var(--shadow);
background: var(--background-dim);
font-size: 1rem;
font-weight: bold;
.icon {
fill: var(--shadow);
margin: -.1rem .5rem 0 0;
}
&:hover {
background: var(--shadow-hint);
cursor: pointer;
}
}

View File

@ -1,73 +0,0 @@
.noselect {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.nolist {
list-style: none;
padding: 0;
margin: 0;
li {
display: inline-block;
padding: 0;
margin: 0;
}
}
.nolink {
display: inline-block;
color: inherit;
text-decoration: none;
}
.nobar {
scrollbar-width: none;
-mis-overflow-style: none;
&::-webkit-scrollbar {
background: transparent;
width: 0px;
height: 0px;
}
}
::-webkit-scrollbar {
width: 14px;
height: 14px;
}
::-webkit-scrollbar-thumb {
background-color: var(--shadow-weak);
border: none;
}
::-webkit-scrollbar-track {
box-shadow: inset 0 0 3px var(--shadow-hint);
border: solid 1px var(--shadow-hint);
background-color: var(--shadow-touch);
}
.bar-inline::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.bar-inline::-webkit-scrollbar-thumb {
background-color: var(--shadow-hint);
}
.bar-strong::-webkit-scrollbar-thumb {
background-color: var(--primary);
}
:focus {
outline: none;
}
::-moz-focus-inner {
border: 0;
}

View File

@ -1,133 +0,0 @@
$breakpoint0: 540px;
$breakpoint: 720px;
$breakpoint2: 900px;
$breakpoint3: 1200px;
$breakpoint4: 1500px;
:root {
--primary: #f65596;
--primary-strong: #f90071;
--primary-faded: #ffcce4;
/*
--primary: #f04288;
--primary: #ff6c88;
--primary-strong: #ff4166;
--primary-faded: #ffdfee;
--primary: #f28;
--primary-strong: #f90071;
--primary-faded: #ffcce4;
*/
--text-dark: #222;
--text-light: #fff;
--background-light: #fff;
--background-dark: #222;
--darken: rgba(0, 0, 0, .5);
--darken-censor: rgba(0, 0, 0, .95);
--darken-extreme: rgba(0, 0, 0, .9);
--darken-strong: rgba(0, 0, 0, .7);
--darken-weak: rgba(0, 0, 0, .2);
--darken-hint: rgba(0, 0, 0, .1);
--darken-touch: rgba(0, 0, 0, .05);
--lighten-censor: rgba(255, 255, 255, .95);
--lighten-extreme: rgba(255, 255, 255, .9);
--lighten-strong: rgba(255, 255, 255, .7);
--lighten: rgba(255, 255, 255, .5);
--lighten-weak: rgba(255, 255, 255, .2);
--lighten-hint: rgba(255, 255, 255, .05);
--lighten-touch: rgba(255, 255, 255, .03);
--logo-shadow: drop-shadow(1px 0 0 $shadow-weak) drop-shadow(-1px 0 0 $shadow-weak) drop-shadow(0 1px 0 $shadow-weak) drop-shadow(0 -1px 0 $shadow-weak);
--logo-highlight: drop-shadow(0 0 1px $highlight);
--info: #321b24;
--male: #0af;
--female: #f0a;
--alert: #f00;
--error: #fd5555;
--warn: #fa0;
--success: #5c2;
--notice: #25c;
--enabled: #5c2;
--enabled-background: rgba(0, 255, 0, .1);
--disabled: #c20;
--disabled-background: rgba(255, 0, 0, .1);
--disabled-handle: #aaa;
}
.light {
--text: #222;
--text-contrast: #fff;
--background: var(--background-light);
--background-censor: rgba(255, 255, 255, .95);
--background-dim: #f5f5f5;
--background-soft: #fdfdfd;
--profile: #222;
--tile: #2a2a2a;
--link: #dd6688;
--link-external: #48f;
--empty: #333;
--crease: #eaeaea;
--shadow: rgba(0, 0, 0, .5);
--shadow-censor: rgba(0, 0, 0, .95);
--shadow-extreme: rgba(0, 0, 0, .9);
--shadow-strong: rgba(0, 0, 0, .7);
--shadow-modest: rgba(0, 0, 0, .3);
--shadow-weak: rgba(0, 0, 0, .2);
--shadow-hint: rgba(0, 0, 0, .1);
--shadow-touch: rgba(0, 0, 0, .05);
--highlight: rgba(255, 255, 255, .5);
--highlight-extreme: rgba(255, 255, 255, .9);
--highlight-strong: rgba(255, 255, 255, .7);
--highlight-modest: rgba(255, 255, 255, .3);
--highlight-weak: rgba(255, 255, 255, .2);
--highlight-hint: rgba(255, 255, 255, .075);
}
.dark {
--text: #fff;
--text-contrast: #222;
--background: #181818;
--background-censor: rgba(0, 0, 0, .95);
--background-dim: #111;
--background-soft: #000;
--profile: #0a0a0a;
--tile: #2a2a2a;
--link: #dd6688;
--empty: #333;
--crease: #222;
--shadow: rgba(255, 255, 255, .5);
--shadow-extreme: rgba(255, 255, 255, .9);
--shadow-strong: rgba(255, 255, 255, .7);
--shadow-modest: rgba(255, 255, 255, .3);
--shadow-weak: rgba(255, 255, 255, .2);
--shadow-hint: rgba(255, 255, 255, .075);
--shadow-touch: rgba(255, 255, 255, .05);
--highlight: rgba(0, 0, 0, .5);
--highlight-extreme: rgba(0, 0, 0, .9);
--highlight-strong: rgba(0, 0, 0, .7);
--highlight-modest: rgba(0, 0, 0, .3);
--highlight-weak: rgba(0, 0, 0, .2);
--highlight-hint: rgba(0, 0, 0, .1);
}

View File

@ -1,112 +0,0 @@
.tooltip {
display: block !important;
z-index: 10000;
.tooltip-inner {
max-width: 20rem;
background: #222;
color: white;
border-radius: 16px;
}
&:not(.popover) .tooltip-inner {
padding: .5rem 1rem;
}
.tooltip-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: #222;
z-index: 1;
}
&[x-placement^="top"] {
margin-bottom: 5px;
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: 5px;
.tooltip-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
margin-left: 5px;
.tooltip-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[x-placement^="left"] {
margin-right: 5px;
.tooltip-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&.popover {
$color: #fff;
.popover-inner {
background: $color;
color: black;
border-radius: 5px;
box-shadow: 0 5px 30px rgba(black, .1);
}
.popover-arrow {
border-color: $color;
}
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
/* transition: opacity .15s, visibility .15s; */
}
&[aria-hidden='false'] {
visibility: visible;
opacity: 1;
/* transition: opacity .15s; */
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,55 +0,0 @@
@import 'theme';
@import 'forms';
@import 'inputs';
@import 'states';
@import 'video';
@import 'tooltip';
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
overflow: hidden;
}
#container {
height: 100%;
}
.nolist {
list-style: none;
padding: 0;
margin: 0;
li {
display: inline-block;
}
}
.heading {
color: var(--shadow);
padding: 0;
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.icon.icon-href {
fill: var(--shadow);
:hover {
fill: var(--primary);
}
}
.link {
color: var(--link);
text-decoration: none;
}
.link-external {
color: var(--link-external);
}

View File

@ -1,441 +0,0 @@
import config from 'config';
import dayjs from 'dayjs';
import { graphql, get } from '../api';
import { curateActor, curateRelease } from '../curate';
import getDateRange from '../get-date-range';
import {
releaseFields,
actorStashesFields,
getIncludedEntities,
getIncludedActors,
batchFragment,
} from '../fragments';
function initActorActions(store, router) {
async function fetchActorById({ _commit }, {
actorId,
limit = 10,
pageNumber = 1,
range = 'latest',
}) {
if (!actorId) {
return null;
}
const { before, after, orderBy } = getDateRange(range);
const includedTags = router.currentRoute.value.query.tags ? router.currentRoute.value.query.tags.split(',') : [];
const mode = router.currentRoute.value.query.mode || 'all';
const { actor, batches: [lastBatch] } = await graphql(`
query Actor(
$actorId: Int!
$userId: Int,
$hasAuth: Boolean!,
$limit:Int = 10,
$offset:Int = 0,
$after:Datetime = "1900-01-01",
$before:Datetime = "2100-01-01",
$orderBy:[ReleasesOrderBy!]
$selectableTags: [String],
$includedTags: [String!],
$exclude: [String!],
$mode: String!,
$includedEntities: [ReleaseFilter!],
$includedActors: [ReleaseFilter!]
) {
actor(id: $actorId) {
id
name
slug
realName
gender
orientation
dateOfBirth
dateOfDeath
age
dateOfBirth
ageFromBirth
ageAtDeath
ethnicity
cup
bust
waist
hip
naturalBoobs
penisLengthMetric: penisLength(units:METRIC)
penisLengthImperial: penisLength(units:IMPERIAL)
penisGirthMetric: penisGirth(units:METRIC)
penisGirthImperial: penisGirth(units:IMPERIAL)
circumcised
heightMetric: height(units:METRIC)
heightImperial: height(units:IMPERIAL)
weightMetric: weight(units:METRIC)
weightImperial: weight(units:IMPERIAL)
hairColor
hairLength
eyes
hasTattoos
hasPiercings
tattoos
piercings
description
createdAt
updatedAt
entity {
id
name
slug
type
parent {
id
name
slug
type
}
}
avatar: avatarMedia {
id
path
thumbnail
lazy
width
height
thumbnailWidth
thumbnailHeight
hash
comment
credit
isS3
sfw: sfwMedia {
id
thumbnail
path
comment
}
}
profiles: actorsProfiles(orderBy: PRIORITY_DESC) {
description
descriptionHash
entity {
id
name
slug
type
independent
parent {
id
name
slug
type
}
}
avatar: avatarMedia {
id
path
thumbnail
lazy
width
height
thumbnailWidth
thumbnailHeight
hash
isS3
comment
credit
entropy
sharpness
sfw: sfwMedia {
id
thumbnail
path
comment
}
}
}
birthCity
birthState
birthCountry: countryByBirthCountryAlpha2 {
alpha2
name
alias
}
residenceCity
residenceState
residenceCountry: countryByResidenceCountryAlpha2 {
alpha2
name
alias
}
social: actorsSocials {
id
url
platform
}
aliases: actorsByAliasFor {
id
name
slug
}
tags(selectableTags: $selectableTags) {
id
name
slug
priority
}
channels {
id
name
slug
type
independent
parent {
id
name
slug
type
}
}
actors {
id
name
slug
}
scenesConnection(
filter: {
effectiveDate: {
lessThan: $before,
greaterThan: $after
}
and: [
{
or: $includedEntities
}
{
or: $includedActors
}
{
releasesTagsConnection: {
none: {
tag: {
slug: {
in: $exclude
}
}
}
}
}
]
}
selectedTags: $includedTags
mode: $mode
first: $limit
offset: $offset
orderBy: $orderBy
) {
releases: nodes {
${releaseFields}
}
totalCount
}
${actorStashesFields}
}
${batchFragment}
}
`, {
actorId,
limit,
offset: Math.max(0, (pageNumber - 1)) * limit,
after,
before,
selectableTags: config.selectableTags,
orderBy,
exclude: store.state.ui.tagFilter,
includedTags,
includedEntities: getIncludedEntities(router),
includedActors: getIncludedActors(router),
mode,
hasAuth: !!store.state.auth.user,
userId: store.state.auth.user?.id,
});
if (!actor) {
router.replace('/not-found');
return null;
}
return {
actor: curateActor(actor, null, curateRelease),
releases: actor.scenesConnection.releases.map((release) => curateRelease(release, 'scene', { lastBatch: lastBatch.id })),
totalCount: actor.scenesConnection.totalCount,
};
}
async function fetchActors({ _commit }, {
limit = 10,
pageNumber = 1,
query,
gender,
age,
country,
dob,
naturalBoobs,
boobSize,
height,
weight,
}) {
const now = dayjs();
const genderFilter = (gender === null && 'gender: { isNull: true }')
|| (gender === 'all' && ' ')
|| (gender && `gender: { equalTo: "${gender}" }`)
|| '';
const ageFilter = age ? `
or: [
{
dateOfBirth: {
greaterThanOrEqualTo: "${now.subtract(age[1], 'year').format('YYYY-MM-DD')}",
lessThanOrEqualTo: "${now.subtract(age[0], 'year').format('YYYY-MM-DD')}"
}
},
{
dateOfBirth: {
isNull: true
}
age: {
greaterThanOrEqualTo: ${age[0]}
lessThanOrEqualTo: ${age[1]}
}
}
]
` : '';
const dobFilter = dob ? `dateOfBirth: { equalTo: "${dob}" }` : '';
const countryFilter = country ? `countryByBirthCountryAlpha2: { alpha2: { equalTo: "${country}" } }` : '';
const heightFilter = height ? `height: { greaterThanOrEqualTo: ${height[0]}, lessThanOrEqualTo: ${height[1]} }` : '';
const weightFilter = weight ? `weight: { greaterThanOrEqualTo: ${weight[0]}, lessThanOrEqualTo: ${weight[1]} }` : '';
const cupFilter = boobSize ? `cup: { greaterThanOrEqualTo: "${boobSize[0]}", lessThanOrEqualTo: "${boobSize[1]}" }` : '';
const { connection: { actors, totalCount }, countries } = await graphql(`
query Actors(
$query: String
$limit: Int,
$offset: Int = 0,
$naturalBoobs: Boolean,
$userId: Int,
$hasAuth: Boolean!,
) {
connection: searchActorsConnection(
query: $query
first: $limit,
offset: $offset
orderBy: NAME_ASC
minLength: 0
filter: {
aliasFor: {
isNull: true
}
${genderFilter}
${ageFilter}
${dobFilter}
${heightFilter}
${weightFilter}
${cupFilter}
${countryFilter}
naturalBoobs: {
equalTo: $naturalBoobs
}
}
) {
totalCount
actors: nodes {
id
name
slug
age
dateOfBirth
ageFromBirth
ageAtDeath
dateOfBirth
dateOfDeath
gender
entity {
id
name
slug
type
parent {
id
name
slug
type
}
}
avatar: avatarMedia {
id
path
thumbnail
lazy
comment
credit
isS3
sfw: sfwMedia {
id
thumbnail
path
comment
}
}
birthCountry: countryByBirthCountryAlpha2 {
alpha2
name
alias
}
${actorStashesFields}
}
}
countries(
orderBy: NAME_ASC,
) {
alpha2
name
alias
}
}
`, {
query,
offset: Math.max(0, (pageNumber - 1)) * limit,
limit,
naturalBoobs: naturalBoobs || null,
hasAuth: !!store.state.auth.user,
userId: store.state.auth.user?.id,
});
return {
actors: actors.map((actor) => curateActor(actor)),
totalCount,
countries,
};
}
async function fetchActorReleases({ _commit }, actorId) {
const releases = await get(`/actors/${actorId}/releases`, {
filter: store.state.ui.filter,
after: store.getters.after,
before: store.getters.before,
});
return releases;
}
return {
fetchActorById,
fetchActors,
fetchActorReleases,
};
}
export default initActorActions;

View File

@ -1,13 +0,0 @@
import state from './state';
import mutations from './mutations';
import actions from './actions';
function initActorsStore(store, router) {
return {
state,
mutations,
actions: actions(store, router),
};
}
export default initActorsStore;

View File

@ -1 +0,0 @@
export default {};

View File

@ -1 +0,0 @@
export default {};

View File

@ -1,116 +0,0 @@
import config from 'config';
async function get(endpoint, query = {}) {
const curatedQuery = Object.entries(query).reduce((acc, [key, value]) => (value ? { ...acc, [key]: value } : acc), {}); // remove empty values
const q = new URLSearchParams(curatedQuery).toString();
const res = await fetch(`${config.api.url}${endpoint}?${q}`, {
method: 'GET',
mode: 'cors',
credentials: 'same-origin',
});
const contentTypes = res.headers.get('content-type');
if (res.ok && contentTypes?.includes('application/json')) {
return res.json();
}
if (res.ok) {
return null;
}
const errorMsg = await res.text();
throw new Error(errorMsg);
}
async function post(endpoint, data, method = 'POST') {
const res = await fetch(`${config.api.url}${endpoint}`, {
method,
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(data),
});
const contentTypes = res.headers.get('content-type');
if (res.ok && contentTypes?.includes('application/json')) {
return res.json();
}
if (res.ok) {
return null;
}
const errorMsg = await res.text();
throw new Error(errorMsg);
}
async function patch(endpoint, data) {
return post(endpoint, data, 'PATCH');
}
async function put(endpoint, data) {
return post(endpoint, data, 'PUT');
}
async function del(endpoint) {
const res = await fetch(`${config.api.url}${endpoint}`, {
method: 'DELETE',
mode: 'cors',
credentials: 'same-origin',
});
const contentTypes = res.headers.get('content-type');
if (res.ok && contentTypes?.includes('application/json')) {
return res.json();
}
if (res.ok) {
return true;
}
const errorMsg = await res.text();
throw new Error(errorMsg);
}
async function graphql(query, variables = null) {
const res = await fetch('/graphql', {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
query,
variables,
}),
});
if (res.ok) {
const { data } = await res.json();
return data;
}
const errorMsg = await res.text();
throw new Error(errorMsg);
}
export {
get,
post,
del,
graphql,
patch,
put,
};

View File

@ -1,48 +0,0 @@
import { get, post, del } from '../api';
function initAuthActions(store, _router) {
async function fetchMe({ commit }) {
try {
const user = await get('/session');
commit('setUser', user);
return user;
} catch (error) {
// continue as guest
return null;
}
}
async function login({ commit }, credentials) {
const user = await post('/session', credentials);
commit('setUser', user);
await store.dispatch('fetchNotifications');
return user;
}
async function signup({ commit }, credentials) {
const user = await post('/users', credentials);
commit('setUser', user);
return user;
}
async function logout({ commit }) {
await del('/session');
commit('setUser', null);
}
return {
fetchMe,
login,
logout,
signup,
};
}
export default initAuthActions;

View File

@ -1,15 +0,0 @@
import state from './state';
import mutations from './mutations';
import getters from './getters';
import actions from './actions';
function initAuthStore(store, router) {
return {
state,
mutations,
getters,
actions: actions(store, router),
};
}
export default initAuthStore;

View File

@ -1,8 +0,0 @@
function favoritesStash(state) {
return state.user.stashes.find((stash) => stash.slug === 'favorites');
}
module.exports = {
favoritesStash,
favorites: favoritesStash,
};

View File

@ -1,7 +0,0 @@
function setUser(state, user) {
state.user = user;
}
export default {
setUser,
};

View File

@ -1,5 +0,0 @@
async function initAuthObserver(store, _router) {
await store.dispatch('fetchMe');
}
export default initAuthObserver;

View File

@ -1,5 +0,0 @@
export default {
login: window.env.login,
signup: window.env.signup,
user: null,
};

View File

@ -1,45 +0,0 @@
export default {
api: {
url: `${window.location.origin}/api`,
},
media: {
assetPath: '/img',
mediaPath: '/media',
s3Path: 'https://s3.wasabisys.com',
},
campaigns: {
home: true,
entity: true,
tiles: 0,
},
showDisclaimer: false,
disclaimer: 'This site is in early development, and content may occasionally disappear. Please stay tuned, you will be able to use traxxx to its full potential in the near future!',
selectableTags: [
'airtight',
'anal',
'bdsm',
'blowbang',
'blowjob',
'creampie',
'deepthroat',
'dap',
'dp',
'dvp',
'facefucking',
'facial',
'fisting',
'gaping',
'gangbang',
'interracial',
'lesbian',
'threesome',
'mff',
'mfm',
'orgy',
'pov',
'solo',
'squirting',
'swallowing',
],
discord: 'https://discord.gg/6mjHTYG',
};

View File

@ -1,243 +0,0 @@
import dayjs from 'dayjs';
function curateActor(actor, release) {
if (!actor) {
return null;
}
const curatedActor = {
...actor,
height: actor.heightMetric && {
metric: actor.heightMetric,
imperial: actor.heightImperial,
},
weight: actor.weightMetric && {
metric: actor.weightMetric,
imperial: actor.weightImperial,
},
origin: actor.birthCountry && {
city: actor.birthCity,
state: actor.birthState,
country: actor.birthCountry,
},
residence: actor.residenceCountry && {
city: actor.residenceCity,
state: actor.residenceState,
country: actor.residenceCountry,
},
scrapedAt: new Date(actor.createdAt),
updatedAt: new Date(actor.updatedAt),
};
if (actor.profiles) {
const photos = actor.profiles
.map((profile) => ({ entity: profile.entity, ...profile.avatar }))
.filter((avatar) => avatar.id && (!curatedActor.avatar || avatar.hash !== curatedActor.avatar.hash));
const descriptions = actor.profiles.reduce((acc, profile) => ({
...acc,
...(profile.description && {
[profile.descriptionHash]: {
text: profile.description,
entity: profile.entity,
},
}),
}), {});
curatedActor.photos = Object.values(photos.reduce((acc, photo) => ({ ...acc, [photo.hash]: photo }), {}));
curatedActor.descriptions = Object.values(descriptions);
}
if (release && release.date && curatedActor.birthdate) {
curatedActor.ageThen = dayjs(release.date).diff(actor.birthdate, 'year');
}
if (actor.aliasFor) {
curatedActor.aliasFor = curateActor(curatedActor.aliasFor);
}
if (actor.stashes) {
curatedActor.stashes = actor.stashes.filter(Boolean).map((stash) => curateStash(stash.stash || stash)); // eslint-disable-line no-use-before-define
}
curatedActor.stashes = actor.stashes?.map((stash) => stash.stash || stash) || [];
return curatedActor;
}
function curateRelease(release, type = 'scene', context = {}) {
const curatedRelease = {
...release,
type: release.type || type,
actors: [],
poster: release.poster && release.poster.media,
tags: release.tags ? release.tags.map((tag) => tag.tag || tag) : [],
isNew: release.createdBatchId === context.lastBatch,
};
curatedRelease.scenes = release.scenes?.filter(Boolean).map(({ scene }) => curateRelease(scene, 'scene', context)) || [];
curatedRelease.movies = release.movies?.filter(Boolean).map(({ movie }) => curateRelease(movie, 'movie', context)) || [];
curatedRelease.series = release.series?.filter(Boolean).map(({ serie }) => curateRelease(serie, 'serie', context)) || [];
curatedRelease.chapters = release.chapters?.filter(Boolean).map((chapter) => curateRelease(chapter, 'chapter', context)) || [];
curatedRelease.photos = release.photos?.filter(Boolean).map((photo) => photo.media || photo) || [];
curatedRelease.caps = release.caps?.filter(Boolean).map((cap) => cap.media || cap) || [];
curatedRelease.scenesPhotos = release.scenesPhotos?.filter(Boolean).map((photo) => photo.media || photo) || [];
curatedRelease.covers = release.covers?.filter(Boolean).map(({ media }) => media) || [];
if (release.trailer) curatedRelease.trailer = release.trailer.media;
if (release.teaser) curatedRelease.teaser = release.teaser.media;
if (release.actors) curatedRelease.actors = release.actors.filter(Boolean).map((actor) => curateActor(actor.actor || actor, curatedRelease));
if (release.directors) curatedRelease.directors = release.directors.filter(Boolean).map((director) => curateActor(director.director || director, curatedRelease));
if (release.movieTags && release.movieTags.length > 0) curatedRelease.tags = release.movieTags.filter(Boolean).map(({ tag }) => tag);
if (release.movieActors && release.movieActors.length > 0) curatedRelease.actors = release.movieActors.filter(Boolean).map(({ actor }) => curateActor(actor, curatedRelease));
if (release.stashes) curatedRelease.stashes = release.stashes.filter(Boolean).map((stash) => curateStash(stash.stash || stash)); // eslint-disable-line no-use-before-define
if (release.productionLocation) {
curatedRelease.productionLocation = {
raw: release.productionLocation,
city: release.productionCity,
state: release.productionState,
country: release.productionCountry,
};
}
return curatedRelease;
}
function curateEntity(entity, parent, releases, context) {
const curatedEntity = {
...entity,
children: [],
};
if (entity.tags) curatedEntity.tags = entity.tags.map(({ tag }) => tag);
if (entity.sceneTags) curatedEntity.sceneTags = entity.sceneTags;
if (entity.children) {
if (entity.children.nodes) {
curatedEntity.children = entity.children.nodes.map((childEntity) => curateEntity(childEntity, curatedEntity));
}
curatedEntity.childrenTotal = entity.children.totalCount;
}
if (entity.parent || parent) curatedEntity.parent = curateEntity(entity.parent || parent);
if (releases) curatedEntity.releases = releases.map((release) => curateRelease(release, 'scene', context));
curatedEntity.sceneTotal = entity.sceneTotal;
return curatedEntity;
}
function curateTag(tag, context) {
const curatedTag = {
...tag,
};
if (tag.releases) curatedTag.releases = tag.releases.map(({ release }) => curateRelease(release, 'scene', context));
if (tag.banners) curatedTag.banners = tag.banners.map(({ banner }) => banner);
if (tag.photos) curatedTag.photos = tag.photos.map(({ media }) => media);
if (tag.poster) curatedTag.poster = tag.poster.media;
return curatedTag;
}
function curateStash(stash, context) {
const curatedStash = stash;
if (stash.scenes || stash.scenesConnection?.scenes) {
curatedStash.sceneTotal = stash.scenesConnection?.totalCount || null;
curatedStash.scenes = (stash.scenesConnection?.scenes || stash.scenes).map((item) => ({
...item,
scene: curateRelease(item.scene, 'scene', context),
}));
}
if (stash.actors || stash.actorsConnection?.actors) {
curatedStash.actorTotal = stash.actorsConnection?.totalCount || null;
curatedStash.actors = (stash.actorsConnection?.actors || stash.actors).map((item) => ({
...item,
actor: curateActor(item.actor),
}));
}
if (stash.movies || stash.moviesConnection?.movies) {
curatedStash.movieTotal = stash.moviesConnection?.totalCount || null;
curatedStash.movies = (stash.moviesConnection?.movies || stash.movies).map((item) => ({
...item,
movie: curateRelease(item.movie, 'movie', context),
}));
}
return curatedStash;
}
function curateAlert(alert) {
if (!alert) {
return null;
}
const curatedAlert = alert;
if (alert.actors) {
curatedAlert.actors = alert.actors.map((actor) => curateActor(actor.actor || actor));
}
if (alert.tags) {
curatedAlert.tags = alert.tags.map((tag) => curateTag(tag.tag || tag));
}
if (alert.entity) {
curatedAlert.entity = curateEntity(alert.entity.entity || alert.entity);
}
if (alert.entities) {
curatedAlert.entities = alert.entities.map((entity) => curateEntity(entity.entity || entity));
}
if (alert.stashes) {
curatedAlert.stashes = alert.stashes.map((stash) => curateStash(stash.stash || stash));
}
return curatedAlert;
}
function curateUser(user) {
if (!user) {
return null;
}
const curatedUser = user;
if (user.stashes) {
curatedUser.stashes = user.stashes.map((stash) => curateStash(stash.stash || stash));
}
if (user.alerts) {
curatedUser.alerts = user.alerts.map((alert) => curateAlert(alert.alert || alert));
}
return curatedUser;
}
function curateNotification(notification) {
const curatedNotification = notification;
curatedNotification.scene = curateRelease(notification.scene);
if (notification.alert) {
curatedNotification.alert = curateAlert(notification.alert.alert || notification.alert);
}
return curatedNotification;
}
export {
curateActor,
curateEntity,
curateRelease,
curateNotification,
curateTag,
curateStash,
curateUser,
};

View File

@ -1,261 +0,0 @@
import { graphql } from '../api';
// import { sitesFragment, releaseFields } from '../fragments';
import { releaseFields, batchFragment, campaignsFragment } from '../fragments';
import { curateEntity } from '../curate';
import getDateRange from '../get-date-range';
function initEntitiesActions(store, router) {
async function fetchEntityBySlugAndType({ _commit }, {
entitySlug,
entityType,
limit = 10,
pageNumber = 1,
range = 'latest',
}) {
const { before, after, orderBy } = getDateRange(range);
const { entity, connection, batches: [lastBatch] } = await graphql(`
query Entity(
$entitySlug: String!
$entityType: String! = "channel"
$limit: Int = 10,
$offset: Int = 0,
$after: Datetime = "1900-01-01",
$before: Datetime = "2100-01-01",
$orderBy: [ReleasesSummariesOrderBy!]
$exclude: [String!]
$hasAuth: Boolean!
$userId: Int
) {
entity: entityBySlugAndType(slug: $entitySlug, type: $entityType) {
id
name
slug
url
independent
hasLogo
tags: entitiesTags {
tag {
id
name
slug
}
}
sceneTags {
id
name
slug
}
children: childEntitiesConnection(
orderBy: [PRIORITY_DESC, NAME_ASC],
filter: {
type: {
notEqualTo: "info"
}
visible: {
equalTo: true
}
}
) {
nodes {
id
name
slug
url
type
priority
independent
hasLogo
${campaignsFragment}
children: childEntitiesConnection {
totalCount
}
}
}
${campaignsFragment}
parent {
id
name
slug
type
url
independent
hasLogo
${campaignsFragment}
}
}
connection: releasesSummariesConnection(
first: $limit
offset: $offset
orderBy: $orderBy
filter: {
effectiveDate: { lessThan: $before, greaterThan: $after }
showcased: { equalTo: true }
and: [
{
or: [
{
not: { tags: { overlaps: $exclude } }
}
{
tags: { isNull: true }
}
]
}
{
or: [
{
channelSlug: { equalTo: $entitySlug }
channelType: { equalTo: $entityType }
}
{
networkSlug: { equalTo: $entitySlug }
networkType: { equalTo: $entityType }
}
{
parentNetworkSlug: { equalTo: $entitySlug }
parentNetworkType: { equalTo: $entityType }
}
]
}
]
}
) {
releases: nodes {
release {
${releaseFields}
}
}
totalCount
}
${batchFragment}
}
`, {
entitySlug,
entityType,
limit,
offset: Math.max(0, (pageNumber - 1)) * limit,
after,
before,
orderBy,
exclude: store.state.ui.tagFilter,
hasAuth: !!store.state.auth.user,
userId: store.state.auth.user?.id,
});
if (!entity) {
router.replace('/not-found');
return null;
}
return {
entity: curateEntity(entity, null, connection.releases.map(({ release }) => release), { lastBatch: lastBatch?.id }),
totalCount: connection.totalCount,
};
}
async function fetchEntities({ _commit }, { type, entitySlugs }) {
const { entities } = await graphql(`
query Entities(
$entitySlugs: [String!] = []
) {
entities(
orderBy: SLUG_ASC
filter: {
or: [
{
type: {
equalTo: "network"
}
childEntitiesConnection: {
some: {
type: {
equalTo: "channel"
}
independent: {
notEqualTo: true
}
}
}
}
{
independent: {
equalTo: true
}
}
{
type: {
equalTo: "channel"
}
parentExists: false
}
{
slug: {
in: $entitySlugs
}
}
],
}
) {
id
name
slug
type
url
independent
hasLogo
children: childEntitiesConnection {
totalCount
}
}
}
`, {
type,
entitySlugs,
});
return entities.map((entity) => curateEntity(entity));
}
async function searchEntities({ _commit }, { query, limit = 20 }) {
const { entities } = await graphql(`
query SearchEntities(
$query: String!
$limit: Int = 20,
) {
entities: searchEntities(
search: $query
first: $limit
) {
id
name
slug
type
url
independent
hasLogo
parent {
name
slug
type
url
hasLogo
}
}
}
`, {
query,
limit,
});
return entities.map((entity) => curateEntity(entity));
}
return {
fetchEntityBySlugAndType,
fetchEntities,
searchEntities,
};
}
export default initEntitiesActions;

Some files were not shown because too many files have changed in this diff Show More