Compare commits

...

64 Commits

Author SHA1 Message Date
d745b10d24 Upgraded dependencies, bumped to Node 24. 2026-07-12 05:27:14 +02:00
2d4786a4fd 0.51.18 2026-07-08 03:47:08 +02:00
2d0983f4d1 Optionally showing aliases in actors admin. 2026-07-08 03:47:06 +02:00
dd344ecc3f 0.51.17 2026-07-08 03:25:33 +02:00
93c31888b2 Fixed merge ignoring assigned scene tags. 2026-07-08 03:25:31 +02:00
6779caccef 0.51.16 2026-07-08 02:49:11 +02:00
bf69b5acd1 Fixed actors URL not updating on pagination. 2026-07-08 02:49:09 +02:00
0424f27264 0.51.15 2026-07-07 03:10:56 +02:00
5ae102071b Added some missing bio data to actor GraphQL endpoint. Changed enhanced boobs to fake tits on tag overview. 2026-07-07 03:10:54 +02:00
8b716aeb97 0.51.14 2026-07-07 01:05:21 +02:00
1f5bbb6ce5 Redirecting aliased actors and tags. 2026-07-07 01:05:18 +02:00
a94c24581f 0.51.13 2026-07-06 22:08:57 +02:00
5383f9bfc4 Added aliases and stashes to GraphQL actor. 2026-07-06 22:08:56 +02:00
beddc9c7ef 0.51.12 2026-07-06 17:44:38 +02:00
0e81334c5f Fixed actor tags with just one actor showing 'and'. 2026-07-06 17:44:35 +02:00
dbd269c43b 0.51.11 2026-07-06 14:53:04 +02:00
5c3d7d77fc Fixed menu breaking when logged out. 2026-07-06 14:53:02 +02:00
0c48b8f7bf 0.51.10 2026-07-06 01:48:13 +02:00
8c09fb4cda Showing tooltips on filter options. 2026-07-06 01:48:11 +02:00
0a84598fbe 0.51.9 2026-07-06 01:36:43 +02:00
ee1e347405 Fixed search inputs not working in Firefox/Safari. Added Stashes and Alerts to header menu. Indexing compilation tags as assigned tags in Manticore. 2026-07-06 01:36:30 +02:00
6f8d3b1ad2 Hiding merge from actor edit page if not privileged. 2026-07-05 05:20:42 +02:00
a91350bcab 0.51.8 2026-07-05 05:18:41 +02:00
b00425e9c0 Moved actor merge from bio to edit. 2026-07-05 05:18:38 +02:00
c82a3477bd 0.51.7 2026-07-05 04:57:00 +02:00
040dca3829 Added URL as an editable scene field. Fixed plain URL permissions on scene page. 2026-07-05 04:56:57 +02:00
8674c1d361 Tweaked actor tag avatar margins. 2026-07-05 04:37:18 +02:00
6c9cb4201a 0.51.6 2026-07-05 04:33:14 +02:00
7c32a5e824 Improved tag actor avatar size calculation. 2026-07-05 04:33:12 +02:00
6c2f9cb62e Slightly bumped tag actor avatar scale. 2026-07-05 03:57:19 +02:00
3cece877f7 Fixed actor tag star styling. 2026-07-05 03:54:02 +02:00
8dd700f62e 0.51.5 2026-07-05 03:42:45 +02:00
804cdcbf59 Using biometric data for actor tag face positioning if available. 2026-07-05 03:42:40 +02:00
885fe7c9e9 0.51.4 2026-07-03 17:59:47 +02:00
930cc52373 Showing avatars on actor tags instead of sections. Slightly sized down scene page actor tiles, shrinking font size on long names. 2026-07-03 17:59:44 +02:00
4636a213b3 0.51.3 2026-07-03 03:59:16 +02:00
469954f613 Sorting actor tag sections by average tag weight. 2026-07-03 03:59:14 +02:00
5bdcd65d42 0.51.2 2026-07-03 03:18:44 +02:00
cb91cd4cc7 Applying global vs actor tag toggle to scene results. 2026-07-03 03:18:33 +02:00
e04ddaed9b 0.51.1 2026-07-03 02:23:15 +02:00
605da5e46c Allowing actor tag filter on stash page. 2026-07-03 02:23:13 +02:00
360e8ece85 0.51.0 2026-07-03 02:15:08 +02:00
1543bf9d03 Using packed keys instead of actor-tags table. 2026-07-03 02:15:04 +02:00
287932d9d7 0.50.23 2026-07-01 21:12:21 +02:00
7c4de31c12 Mapping all actor aliases to manticore scenes. 2026-07-01 21:11:56 +02:00
497c6150f7 0.50.22 2026-07-01 20:45:59 +02:00
514f51f111 Fixed actor merge failing if multiple source actors have profiles for same entity. 2026-07-01 20:45:57 +02:00
244dc4fff6 0.50.21 2026-06-29 00:37:21 +02:00
4b39f787c9 Including scene actor aliases in manticore. 2026-06-29 00:37:18 +02:00
fb92b9c973 0.50.20 2026-06-28 06:24:21 +02:00
181358db7d Fixed scene actors without alias returning empty actor object instead of null. 2026-06-28 06:24:19 +02:00
3790567d44 0.50.19 2026-06-28 06:18:41 +02:00
b3af993236 Fixed actor edit avatar zoom selecting avatar, showing media date. 2026-06-28 06:18:38 +02:00
7ae2bb7635 0.50.18 2026-06-28 06:05:20 +02:00
a75f0662ad Showing alias on scene actor tile. 2026-06-28 06:05:15 +02:00
ffd68d5037 0.50.17 2026-06-28 05:35:53 +02:00
adf9e2334c Fixed actor merge failing if scene has multiple source actors assigned, merging stashed actors. 2026-06-28 05:35:51 +02:00
4125811017 0.50.16 2026-06-19 02:18:05 +02:00
4e8356b072 Fixed actor merge failing if target actor is already assigned to some scenes. Linked ID in actors admin to actor page. 2026-06-19 02:18:02 +02:00
bad116cdc0 Fixed actor edit negative labia boolean ignored by UI. 2026-06-18 00:33:38 +02:00
9eac6871a4 0.50.15 2026-06-18 00:33:26 +02:00
3b694689f3 Fixed actor edit negative boolean ignored by UI. 2026-06-18 00:33:24 +02:00
1604ddaa78 0.50.14 2026-06-18 00:25:06 +02:00
16181923b6 Fixed actor page breaking if no aliases are found. 2026-06-18 00:25:03 +02:00
183 changed files with 19002 additions and 22783 deletions

View File

@@ -5,7 +5,6 @@
] ]
], ],
"plugins": [ "plugins": [
"@babel/plugin-transform-optional-chaining", "@babel/plugin-transform-optional-chaining"
"@babel/plugin-syntax-import-attributes"
], ],
} }

View File

@@ -1,52 +0,0 @@
{
"root": true,
"extends": [
"airbnb-base",
"plugin:import/errors",
"plugin:vue/recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser",
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"default-param-last": 0,
"import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
"import/prefer-default-export": 0,
"no-underscore-dangle": 0,
"import/extensions": [2, "always"],
"indent": ["error", "tab"],
"max-len": 0,
"no-console": 0,
"no-param-reassign": ["error", {
"props": true,
"ignorePropertyModificationsFor": ["state", "acc", "req"]
}],
"vue/multi-word-component-names": 0,
"vue/no-reserved-component-names": 0,
"no-tabs": "off",
"no-unused-vars": ["error", {"argsIgnorePattern": "^_"}],
"prefer-destructuring": 0,
"template-curly-spacing": "off",
"vue/html-indent": ["error", "tab"],
"vue/multiline-html-element-content-newline": 0,
"vue/no-v-html": 0,
"vue/singleline-html-element-content-newline": 0,
"vue/comment-directive": 0,
},
"settings": {
"import/resolver": {
"alias": {
"extensions": [".js"],
"map": [
["#", "."],
["#root", "."]
]
}
}
},
"globals": {
"CONFIG": true
}
}

2
.nvmrc
View File

@@ -1 +1 @@
21.2.0 24.18.0

View File

@@ -310,3 +310,23 @@
box-shadow: 0 0 3px var(--shadow-weak-10); box-shadow: 0 0 3px var(--shadow-weak-10);
} }
} }
.search-cancel {
width: 2rem;
height: 2rem;
padding: .5rem;
.icon {
width: 1rem;
height: 1rem;
fill: var(--glass);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--error);
}
}
}

View File

@@ -1,3 +1,132 @@
<script setup>
import { format, subYears } from 'date-fns';
import { parse } from 'path-to-regexp';
import { inject, ref } from 'vue';
import ActorTile from '#/components/actors/tile.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import CountryFilter from '#/components/filters/country.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Pagination from '#/components/pagination/pagination.vue';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const pageStash = pageProps.stash;
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.actorTotal || pageProps.total));
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
});
function getPath(preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'page') {
return `${segment.prefix}${currentPage.value || 1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (q.value) {
order.value = 'results.desc';
}
if (!q.value && order.value.includes('results')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
stashId: pageStash?.id,
};
navigate(getPath(false), query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script>
<template> <template>
<div class="page"> <div class="page">
<Filters :results="total"> <Filters :results="total">
@@ -5,12 +134,19 @@
<!-- onsearch not compatible with FF and Safari --> <!-- onsearch not compatible with FF and Safari -->
<input <input
v-model="q" v-model="q"
type="search" type="input"
placeholder="Search actors" placeholder="Search actors"
class="input search" class="input search"
@search="search" @keydown.enter="search"
> >
<Icon
v-if="q"
icon="cross2"
class="search-button"
@click="q = ''; search()"
/>
<Icon <Icon
icon="search" icon="search"
class="search-button" class="search-button"
@@ -104,135 +240,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { format, subYears } from 'date-fns';
import { parse } from 'path-to-regexp';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import ActorTile from '#/components/actors/tile.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import CountryFilter from '#/components/filters/country.vue';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const pageStash = pageProps.stash;
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.actorTotal || pageProps.total));
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
});
function getPath(preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (q.value) {
order.value = 'results.desc';
}
if (!q.value && order.value.includes('results')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
stashId: pageStash?.id,
};
navigate(getPath(false), query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script>
<style> <style>
.gender-button { .gender-button {
&.selected .gender .icon { &.selected .gender .icon {

View File

@@ -1,3 +1,62 @@
<script setup>
import { onMounted, ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { post } from '#/src/api.js';
import events from '#/src/events.js';
const emit = defineEmits(['close']);
const nameInput = ref(null);
const isSubmitting = ref(false);
const isSubmitted = ref(false);
const name = ref(null);
const gender = ref('female');
const allowGlobalMatch = ref(true);
const newActor = ref(null);
onMounted(() => {
nameInput.value.focus();
});
async function addActor() {
isSubmitting.value = true;
try {
const { actor } = await post('/actors', {
actor: {
name: name.value,
gender: gender.value,
allowGlobalMatch: allowGlobalMatch.value,
},
}, {
successFeedback: 'Actor has been added.',
appendErrorMessage: true,
});
name.value = null;
gender.value = null;
allowGlobalMatch.value = true;
newActor.value = actor;
}
catch (error) {
events.emit('feedback', {
type: 'error',
message: error.message,
});
}
isSubmitting.value = false;
isSubmitted.value = true;
}
</script>
<template> <template>
<Dialog <Dialog
title="Add actor" title="Add actor"
@@ -80,64 +139,6 @@
</Dialog> </Dialog>
</template> </template>
<script setup>
import { ref, onMounted } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import Checkbox from '#/components/form/checkbox.vue';
import { post } from '#/src/api.js';
import events from '#/src/events.js';
const emit = defineEmits(['close']);
const nameInput = ref(null);
const isSubmitting = ref(false);
const isSubmitted = ref(false);
const name = ref(null);
const gender = ref('female');
const allowGlobalMatch = ref(true);
const newActor = ref(null);
onMounted(() => {
nameInput.value.focus();
});
async function addActor() {
isSubmitting.value = true;
try {
const { actor } = await post('/actors', {
actor: {
name: name.value,
gender: gender.value,
allowGlobalMatch: allowGlobalMatch.value,
},
}, {
successFeedback: 'Actor has been added.',
appendErrorMessage: true,
});
name.value = null;
gender.value = null;
allowGlobalMatch.value = true;
newActor.value = actor;
} catch (error) {
events.emit('feedback', {
type: 'error',
message: error.message,
});
}
isSubmitting.value = false;
isSubmitted.value = true;
}
</script>
<style scoped> <style scoped>
.dialog-body { .dialog-body {
width: 20rem; width: 20rem;

View File

@@ -1,3 +1,99 @@
<script setup>
import formatTemplate from 'template-format';
import { inject, ref, useId } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const expanded = ref(false);
const enhancedTooltipId = useId();
const pageContext = inject('pageContext');
const { user, env } = pageContext;
const iconMap = {
twitter: 'twitter-x',
};
// if the profile is empty, the expand button overlaps the header
const showExpand = [
'age',
'bust',
'cup',
'eyes',
'ethnicity',
'hairColor',
'hasPiercings',
'hasTattoos',
'height',
'hip',
'naturalBoobs',
'origin',
'residence',
'waist',
'weight',
].some((attribute) => !!props.actor[attribute]);
const augmentationMap = {
bbl: 'BBL',
fat: 'fat transfer',
lift: 'direct lift',
lipo: 'lipo without BBL',
filler: 'filler',
mms: 'MMS',
over: 'over-muscle',
under: 'under-muscle',
mammary: 'under breast',
areolar: 'areolar',
crescent: 'crescent areolar',
lollipop: 'lollipop',
axillary: 'armpit',
umbilical: 'navel',
};
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
.filter((profile) => !!profile.description)
.map((profile) => [profile.descriptionHash, {
text: profile.description,
entity: profile.entity,
}])));
function getSocialUrl(social) {
if (social.url) {
return social.url;
}
if (pageContext.env.socials.urls[social.platform]) {
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
const socials = props.actor.socials.slice(0, 10).map((social) => ({
...social,
handle: social.url
? new URL(social.url).hostname
: social.handle,
}));
const aliases = Object
.entries(props.actor.aliases.reduce((acc, alias) => {
acc[alias.name] = (props.actor[alias.name] || 0) + 1;
return acc;
}, {}))
.toSorted(([, countA], [, countB]) => countB - countA)
.map(([alias]) => alias)
.filter((alias) => alias !== props.actor.name);
</script>
<template> <template>
<div <div
class="profile" class="profile"
@@ -53,7 +149,7 @@
<dfn class="bio-label"><Icon icon="cake" />Age</dfn> <dfn class="bio-label"><Icon icon="cake" />Age</dfn>
<span <span
:title="'Exact date of birth or age unknown'" title="Exact date of birth or age unknown"
class="birthdate" class="birthdate"
>&gt; {{ actor.age }}</span> >&gt; {{ actor.age }}</span>
</li> </li>
@@ -162,7 +258,7 @@
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }} {{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<Icon <Icon
v-if="actor.naturalBoobs === false || actor.naturalButt === false" v-if="actor.naturalBoobs === false || actor.naturalButt === false"
v-tooltip="[actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', ')" v-tooltip="{ content: [actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', '), ariaId: enhancedTooltipId }"
icon="magic-wand2" icon="magic-wand2"
class="enhanced" class="enhanced"
/> />
@@ -184,7 +280,7 @@
actor.boobsVolume && `${actor.boobsVolume}cc`, actor.boobsVolume && `${actor.boobsVolume}cc`,
augmentationMap[actor.boobsPlacement] || actor.boobsPlacement, augmentationMap[actor.boobsPlacement] || actor.boobsPlacement,
augmentationMap[actor.boobsIncision] || actor.boobsIncision, augmentationMap[actor.boobsIncision] || actor.boobsIncision,
augmentationMap[actor.boobsImplant] || actor.boobsImplant augmentationMap[actor.boobsImplant] || actor.boobsImplant,
].filter(Boolean).join(' ')" ].filter(Boolean).join(' ')"
class="augmentations-section" class="augmentations-section"
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template> >Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template>
@@ -224,7 +320,7 @@
<span> <span>
<Icon <Icon
v-if="actor.isCircumcised" v-if="actor.isCircumcised"
:title="'Circumcised'" title="Circumcised"
icon="pagebreak" icon="pagebreak"
class="circumcised" class="circumcised"
/> />
@@ -328,7 +424,7 @@
<a <a
v-for="social in socials" v-for="social in socials"
:key="`social-${social.id}`" :key="`social-${social.id}`"
v-tooltip="social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url" v-tooltip="{ content: social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url, ariaId: `social-${social.id}` }"
:href="getSocialUrl(social)" :href="getSocialUrl(social)"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
@@ -374,29 +470,17 @@
:href="`/actor/edit/${actor.id}/${actor.slug}`" :href="`/actor/edit/${actor.id}/${actor.slug}`"
target="_blank" target="_blank"
class="link" class="link"
>Edit bio</a> >Edit</a>
<a <a
:href="`/actor/revs/${actor.id}/${actor.slug}`" :href="`/actor/revs/${actor.id}/${actor.slug}`"
target="_blank" target="_blank"
class="link" class="link"
>Revisions</a> >Revisions</a>
<span
v-if="user && user.role !== 'user'"
class="link"
@click="showMergeDialog = true"
>Merge</span>
</div> </div>
</li> </li>
</ul> </ul>
<Merge
v-if="showMergeDialog"
:actors="[actor]"
@close="showMergeDialog = false"
/>
<div class="descriptions-container"> <div class="descriptions-container">
<div <div
v-if="descriptions.length > 0" v-if="descriptions.length > 0"
@@ -450,105 +534,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import formatTemplate from 'template-format';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Merge from '#/components/actors/merge.vue';
const expanded = ref(false);
const pageContext = inject('pageContext');
const { user, env } = pageContext;
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const iconMap = {
twitter: 'twitter-x',
};
// if the profile is empty, the expand button overlaps the header
const showExpand = [
'age',
'bust',
'cup',
'eyes',
'ethnicity',
'hairColor',
'hasPiercings',
'hasTattoos',
'height',
'hip',
'naturalBoobs',
'origin',
'residence',
'waist',
'weight',
].some((attribute) => !!props.actor[attribute]);
const augmentationMap = {
bbl: 'BBL',
fat: 'fat transfer',
lift: 'direct lift',
lipo: 'lipo without BBL',
filler: 'filler',
mms: 'MMS',
over: 'over-muscle',
under: 'under-muscle',
mammary: 'under breast',
areolar: 'areolar',
crescent: 'crescent areolar',
lollipop: 'lollipop',
axillary: 'armpit',
umbilical: 'navel',
};
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
.filter((profile) => !!profile.description)
.map((profile) => [profile.descriptionHash, {
text: profile.description,
entity: profile.entity,
}])));
function getSocialUrl(social) {
if (social.url) {
return social.url;
}
if (pageContext.env.socials.urls[social.platform]) {
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
const socials = props.actor.socials.slice(0, 10).map((social) => ({
...social,
handle: social.url
? new URL(social.url).hostname
: social.handle,
}));
const aliases = Object
.entries(props.actor.aliases.reduce((acc, alias) => {
acc[alias.name] = (props.actor[alias.name] || 0) + 1;
return acc;
}, {}))
.toSorted(([, countA], [, countB]) => countB - countA)
.map(([alias]) => alias)
.filter((alias) => alias !== props.actor.name);
const showMergeDialog = ref(false);
</script>
<style> <style>
.header-gender { .header-gender {
display: inline-flex; display: inline-flex;
@@ -898,7 +883,7 @@ const showMergeDialog = ref(false);
display: none; display: none;
justify-content: center; justify-content: center;
position: absolute; position: absolute;
z-index: 10; z-index: 1;
bottom: -.25rem; bottom: -.25rem;
} }

View File

@@ -1,3 +1,12 @@
<script setup>
defineProps({
gender: {
type: String,
default: null,
},
});
</script>
<template> <template>
<span <span
v-if="gender" v-if="gender"
@@ -8,15 +17,6 @@
</span> </span>
</template> </template>
<script setup>
defineProps({
gender: {
type: String,
default: null,
},
});
</script>
<style scoped> <style scoped>
.gender { .gender {
&.female .icon { &.female .icon {

View File

@@ -1,3 +1,81 @@
<script setup>
import { computed, onMounted, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, post } from '#/src/api.js';
const props = defineProps({
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'merged']);
const targetActor = ref(null);
const actorInput = ref(null);
const actorQuery = ref('');
const actorResults = ref([]);
const submitted = ref(false);
const merged = ref(false);
const searchDropdownId = useId();
const conflictTooltipId = useId();
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
async function searchActors() {
const res = await get('/actors', {
q: actorQuery.value, // return partial matches
limit: 10,
global: true,
});
actorResults.value = res.actors;
}
function getActorNames() {
if (props.actors.length > 1) {
return `${props.actors.length} actors`;
}
if (props.actors[0].entity) {
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
}
return props.actors[0].name;
}
async function merge() {
submitted.value = true;
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, null, {
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
appendErrorMessage: true,
});
submitted.value = false;
merged.value = true;
emit('merged');
// emit('close');
}
function selectActor(actor) {
targetActor.value = actor;
actorQuery.value = '';
actorResults.value = [];
}
onMounted(() => {
actorInput.value.focus();
});
</script>
<template> <template>
<Dialog <Dialog
:title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`" :title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`"
@@ -62,6 +140,7 @@
<template v-else> <template v-else>
<VDropdown <VDropdown
:aria-id="searchDropdownId"
:triggers="[]" :triggers="[]"
:shown="actorResults.length > 0" :shown="actorResults.length > 0"
:auto-hide="false" :auto-hide="false"
@@ -99,7 +178,7 @@
<button <button
v-else v-else
v-tooltip="sourceTargetConflict && 'Cannot merge actor profile into itself'" v-tooltip="sourceTargetConflict && { content: 'Cannot merge actor profile into itself', ariaId: conflictTooltipId }"
type="submit" type="submit"
class="button button-primary" class="button button-primary"
:disabled="!targetActor || sourceTargetConflict" :disabled="!targetActor || sourceTargetConflict"
@@ -110,82 +189,6 @@
</Dialog> </Dialog>
</template> </template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, post } from '#/src/api.js';
const props = defineProps({
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'merged']);
const targetActor = ref(null);
const actorInput = ref(null);
const actorQuery = ref('');
const actorResults = ref([]);
const submitted = ref(false);
const merged = ref(false);
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
async function searchActors() {
const res = await get('/actors', {
q: actorQuery.value, // return partial matches
limit: 10,
global: true,
});
actorResults.value = res.actors;
}
function getActorNames() {
if (props.actors.length > 1) {
return `${props.actors.length} actors`;
}
if (props.actors[0].entity) {
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
}
return props.actors[0].name;
}
async function merge() {
submitted.value = true;
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, null, {
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
appendErrorMessage: true,
});
submitted.value = false;
merged.value = true;
emit('merged');
// emit('close');
}
function selectActor(actor) {
targetActor.value = actor;
actorQuery.value = '';
actorResults.value = [];
}
onMounted(() => {
actorInput.value.focus();
});
</script>
<style scoped> <style scoped>
.dialog-body { .dialog-body {
width: 20rem; width: 20rem;

View File

@@ -1,5 +1,37 @@
<script setup>
import { ref, useId } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['actor']);
const dropdownId = useId();
const actors = ref([]);
const query = ref(null);
const queryInput = ref(null);
async function search() {
const data = await get('/actors', { q: query.value });
actors.value = data.actors;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -36,38 +68,6 @@
</VDropdown> </VDropdown>
</template> </template>
<script setup>
import { ref } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const actors = ref([]);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['actor']);
async function search() {
const data = await get('/actors', { q: query.value });
actors.value = data.actors;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped> <style scoped>
.trigger { .trigger {
height: 100%; height: 100%;

View File

@@ -1,3 +1,29 @@
<script setup>
import { inject, ref, useId } from 'vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Gender from './gender.vue';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user, restriction } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
const entityTooltipId = useId();
const aliasTooltipId = useId();
</script>
<template> <template>
<div <div
class="tile" class="tile"
@@ -74,42 +100,28 @@
</div> </div>
<span class="label"> <span class="label">
<span class="name ellipsis">{{ actor.name }}</span> <span
class="name ellipsis"
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
>{{ actor.name }}</span>
<img <img
v-if="actor.entity" v-if="actor.entity"
v-tooltip="actor.entity.name" v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
:src="`/logos/${actor.entity.slug}/favicon_dark.png`" :src="`/logos/${actor.entity.slug}/favicon_dark.png`"
class="favicon" class="favicon"
> >
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="{ content: `Credited as '${actor.alias.name}'`, ariaId: aliasTooltipId }"
icon="at-sign"
class="alias"
/>
</span> </span>
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Heart from '#/components/stashes/heart.vue';
import Gender from './gender.vue';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user, restriction } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
</script>
<style scoped> <style scoped>
.tile { .tile {
display: flex; display: flex;
@@ -148,6 +160,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
} }
.label { .label {
height: 1.75rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -161,7 +174,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
} }
.name { .name {
padding: .35rem .25rem .35rem .5rem; padding: 0 .25rem 0 .5rem;
} }
.favicon { .favicon {
@@ -258,4 +271,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem; height: .75rem;
margin-left: .25rem; margin-left: .25rem;
} }
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</style> </style>

View File

@@ -1,3 +1,19 @@
<script setup>
import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
async function initCaches() {
await post('/caches', null, {
successFeedback: 'Reloaded caches',
errorFeedback: 'Failed to reload caches',
appendErrorMessage: true,
});
}
</script>
<template> <template>
<div class="page"> <div class="page">
<nav class="nav"> <nav class="nav">
@@ -47,22 +63,6 @@
</div> </div>
</template> </template>
<script setup>
import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
async function initCaches() {
await post('/caches', null, {
successFeedback: 'Reloaded caches',
errorFeedback: 'Failed to reload caches',
appendErrorMessage: true,
});
}
</script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;

View File

@@ -1,3 +1,121 @@
<script setup>
import { format } from 'date-fns';
import { computed, inject, ref } from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import { del, get } from '#/src/api.js';
const pageContext = inject('pageContext');
const alerts = ref(pageContext.pageProps.alerts);
const showAlertDialog = ref(false);
const done = ref(true);
const query = ref('');
const filterActors = ref(false);
const filterEntities = ref(false);
const filterTags = ref(false);
const filterExpressions = ref(false);
const filterCombined = ref(null);
const filteredAlerts = computed(() => {
const queryRegex = new RegExp(query.value, 'i');
return alerts.value.filter((alert) => {
if (filterActors.value && alert.actors.length === 0) {
return false;
}
if (filterEntities.value && alert.entities.length === 0) {
return false;
}
if (filterTags.value && alert.tags.length === 0) {
return false;
}
if (filterExpressions.value && alert.matches.length === 0) {
return false;
}
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length > 1) {
return false;
}
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length === 1) {
return false;
}
if (queryRegex.test(alert.id)) {
return true;
}
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
return true;
}
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
return true;
}
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
return true;
}
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
return true;
}
return false;
});
});
async function reloadAlerts() {
alerts.value = await get('/alerts');
}
async function removeAlert(alert) {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-alert
return;
}
done.value = false;
const alertLabel = [
...alert.actors.map((actor) => actor.name),
...alert.tags.map((tag) => tag.name),
...alert.entities.map((entity) => entity.name),
...alert.matches.map((match) => match.expression),
].filter(Boolean).join(', ');
try {
await del(`/alerts/${alert.id}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
await reloadAlerts();
}
finally {
done.value = true;
}
}
const filterCombinedStates = [null, true, false];
function toggleFilterCombined() {
const index = filterCombinedStates.indexOf(filterCombined.value);
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
}
</script>
<template> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -169,28 +287,28 @@
<div class="alert-triggers"> <div class="alert-triggers">
<Icon <Icon
v-if="alert.notify && alert.isFromPreset" v-if="alert.notify && alert.isFromPreset"
v-tooltip="'Notify in traxxx, added as quick alert'" v-tooltip="{ content: 'Notify in traxxx, added as quick alert', ariaId: `alert-notify-preset-${alert.id}` }"
icon="bell-plus" icon="bell-plus"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-else-if="alert.notify" v-else-if="alert.notify"
v-tooltip="'Notify in traxxx'" v-tooltip="{ content: 'Notify in traxxx', ariaId: `alert-notify-${alert.id}` }"
icon="bell2" icon="bell2"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-if="alert.stashes.some((stash) => !stash.isPrimary)" v-if="alert.stashes.some((stash) => !stash.isPrimary)"
v-tooltip="`Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`" v-tooltip="{ content: `Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`, ariaId: `alert-stashes-${alert.id}` }"
icon="folder-heart" icon="folder-heart"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-else-if="alert.stashes.length > 0" v-else-if="alert.stashes.length > 0"
v-tooltip="alert.stashes.length > 0 ? 'Add to Favorites' : undefined" v-tooltip="alert.stashes.length > 0 ? { content: 'Add to Favorites', ariaId: `alert-favorites-${alert.id}` } : undefined"
icon="heart7" icon="heart7"
class="trigger" class="trigger"
/> />
@@ -206,7 +324,7 @@
<div class="alert-actions"> <div class="alert-actions">
<span <span
v-tooltip="format(alert.createdAt, 'yyyy-MM-dd hh:mm')" v-tooltip="{ content: format(alert.createdAt, 'yyyy-MM-dd hh:mm'), ariaId: `alert-created-${alert.id}` }"
class="alert-id" class="alert-id"
title="Alert ID" title="Alert ID"
>#{{ alert.id }}</span> >#{{ alert.id }}</span>
@@ -227,123 +345,6 @@
</section> </section>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns';
import AlertDialog from '#/components/alerts/create.vue';
import { get, del } from '#/src/api.js';
const pageContext = inject('pageContext');
const alerts = ref(pageContext.pageProps.alerts);
const showAlertDialog = ref(false);
const done = ref(true);
const query = ref('');
const filterActors = ref(false);
const filterEntities = ref(false);
const filterTags = ref(false);
const filterExpressions = ref(false);
const filterCombined = ref(null);
const filteredAlerts = computed(() => {
const queryRegex = new RegExp(query.value, 'i');
return alerts.value.filter((alert) => {
if (filterActors.value && alert.actors.length === 0) {
return false;
}
if (filterEntities.value && alert.entities.length === 0) {
return false;
}
if (filterTags.value && alert.tags.length === 0) {
return false;
}
if (filterExpressions.value && alert.matches.length === 0) {
return false;
}
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length > 1) {
return false;
}
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length === 1) {
return false;
}
if (queryRegex.test(alert.id)) {
return true;
}
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
return true;
}
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
return true;
}
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
return true;
}
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
return true;
}
return false;
});
});
async function reloadAlerts() {
alerts.value = await get('/alerts');
}
async function removeAlert(alert) {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-restricted-globals, no-alert
return;
}
done.value = false;
const alertLabel = [
...alert.actors.map((actor) => actor.name),
...alert.tags.map((tag) => tag.name),
...alert.entities.map((entity) => entity.name),
...alert.matches.map((match) => match.expression),
].filter(Boolean).join(', ');
try {
await del(`/alerts/${alert.id}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
await reloadAlerts();
} finally {
done.value = true;
}
}
const filterCombinedStates = [null, true, false];
function toggleFilterCombined() {
const index = filterCombinedStates.indexOf(filterCombined.value);
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
}
</script>
<style scoped> <style scoped>
.alerts { .alerts {
width: 100%; width: 100%;

View File

@@ -1,390 +1,11 @@
<template>
<Dialog
title="New alert"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="createAlert"
>
<div class="dialog-section">
<div class="section-header">
<h3 class="heading">IF</h3>
</div>
<div class="field actors">
<span
v-tooltip="fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.'"
class="field-logic fields-logic noselect"
@click="fieldsAnd = !fieldsAnd"
>
<Icon
v-show="fieldsAnd"
icon="link3"
/>
<Icon
v-show="!fieldsAnd"
icon="unlink3"
/>
</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(actor, index) in actors"
:key="`actor-${actor.id}`"
class="field-item actor"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="actorAnd = !actorAnd"
>{{ actorAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile">
<div class="field-label">{{ actor.name }}</div>
</div>
<Icon
icon="cross2"
class="field-remove"
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
/>
</li>
<li class="field-add">
<VDropdown @show="focusActorInput">
<button
class="button"
type="button"
><Icon icon="plus3" />Add actor</button>
<template #popper>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
@input="searchActors"
>
<ul class="nolist">
<li
v-for="actor in actorResults"
:key="`actor-result-${actor.id}`"
v-close-popper
class="result-item"
@click="selectActor(actor)"
>
<img
v-if="actor.avatar"
class="field-avatar"
:src="getPath(actor.avatar, 'lazy')"
>
<span
v-else
class="field-avatar"
/>
<div class="result-label">
{{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
</div>
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field tags">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(tag, index) in tags"
:key="`tag-${tag.id}`"
class="field-item tag"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="tagAnd = !tagAnd"
>{{ tagAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">{{ tag.name }}</div>
<Icon
icon="cross2"
class="field-remove"
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add tag</button>
<template #popper>
<input
ref="tagInput"
v-model="tagQuery"
class="input"
@input="searchTags"
>
<ul class="nolist">
<li
v-for="tag in tagResults"
:key="`tag-result-${tag.id}`"
v-close-popper
class="result-item result-label"
@click="selectTag(tag)"
>{{ tag.name }}</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field entities">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
class="field-item entity"
>
<div
v-if="index > 0"
v-tooltip.click="{
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
triggers: ['click'],
autoHide: true,
}"
class="field-logic"
>OR</div>
<div class="field-tile field-label">
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add channel</button>
<template #popper>
<input
ref="entityInput"
v-model="entityQuery"
class="input"
@input="searchEntities"
>
<ul class="nolist">
<li
v-for="entity in entityResults"
:key="`entity-result-${entity.id}`"
v-close-popper
class="result-item result-label"
@click="selectEntity(entity)"
>
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field matches">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(match, index) in matches"
:key="`match-${match.property}-${match.expression}`"
class="field-item match"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="matchAnd = !matchAnd"
>{{ matchAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">
<strong>{{ match.property }}:</strong>&nbsp;{{ match.expression }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add expression</button>
<template #popper>
<form @submit.prevent="addMatch">
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
v-model="matchExpression"
placeholder="Expression, // for regex"
class="input"
>
</form>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section then">
<h3 class="heading">THEN</h3>
<label class="field notify">
<span>Notify me in traxxx</span>
<Checkbox
:checked="notify"
@change="(checked) => notify = checked"
/>
</label>
<!--
<label class="field email">
<span>E-mail me</span>
<Checkbox
:checked="email"
@change="(checked) => email = checked"
/>
</label>
-->
<div class="stash">
<ul class="field-items nolist noselect">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="field-item tag"
>
<div class="field-tile field-label stash">
<Icon
v-if="stash.isPrimary"
class="favorites"
icon="heart7"
/>{{ stash.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
/>
</li>
<template v-if="stashes.length < assets.stashes.length">
<li class="field-add">
<button
v-if="stashes.length === 0"
type="button"
class="button favorites"
@click="selectStash(assets.primaryStash)"
><Icon icon="heart7" />Add to favorites</button>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button field-add"
><Icon icon="folder-heart" />Add to stash</button>
<template #popper>
<ul class="nolist">
<li
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
:key="`stash-result-${stash.id}`"
v-close-popper
class="result-item result-stash result-label"
@click="selectStash(stash)"
>
{{ stash.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</template>
</ul>
</div>
</div>
<div class="dialog-section dialog-actions">
<button
class="button button-submit"
>Set alert</button>
</div>
</form>
</Dialog>
</template>
<script setup> <script setup>
import { ref, inject } from 'vue'; import { inject, ref, useId } from 'vue';
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
import Dialog from '#/components/dialog/dialog.vue'; import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue'; import Checkbox from '#/components/form/checkbox.vue';
const { assets } = inject('pageContext'); import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const props = defineProps({ const props = defineProps({
presetActors: { presetActors: {
@@ -403,6 +24,8 @@ const props = defineProps({
const emit = defineEmits(['alert', 'close']); const emit = defineEmits(['alert', 'close']);
const { assets } = inject('pageContext');
const actors = ref(props.presetActors); const actors = ref(props.presetActors);
const actorResults = ref([]); const actorResults = ref([]);
const actorInput = ref(null); const actorInput = ref(null);
@@ -427,6 +50,13 @@ const actorAnd = ref(true);
const tagAnd = ref(true); const tagAnd = ref(true);
const matchAnd = ref(true); const matchAnd = ref(true);
const fieldsLogicTooltipId = useId();
const addActorDropdownId = useId();
const addTagDropdownId = useId();
const addEntityDropdownId = useId();
const addMatchDropdownId = useId();
const addStashDropdownId = useId();
const notify = ref(true); const notify = ref(true);
const email = ref(false); const email = ref(false);
@@ -530,6 +160,387 @@ function selectStash(selectedStash) {
} }
</script> </script>
<template>
<Dialog
title="New alert"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="createAlert"
>
<div class="dialog-section">
<div class="section-header">
<h3 class="heading">IF</h3>
</div>
<div class="field actors">
<span
v-tooltip="{ content: fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.', ariaId: fieldsLogicTooltipId }"
class="field-logic fields-logic noselect"
@click="fieldsAnd = !fieldsAnd"
>
<Icon
v-show="fieldsAnd"
icon="link3"
/>
<Icon
v-show="!fieldsAnd"
icon="unlink3"
/>
</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(actor, index) in actors"
:key="`actor-${actor.id}`"
class="field-item actor"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="actorAnd = !actorAnd"
>{{ actorAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile">
<div class="field-label">{{ actor.name }}</div>
</div>
<Icon
icon="cross2"
class="field-remove"
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
/>
</li>
<li class="field-add">
<VDropdown
:aria-id="addActorDropdownId"
@show="focusActorInput"
>
<button
class="button"
type="button"
><Icon icon="plus3" />Add actor</button>
<template #popper>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
@input="searchActors"
>
<ul class="nolist">
<li
v-for="actor in actorResults"
:key="`actor-result-${actor.id}`"
v-close-popper
class="result-item"
@click="selectActor(actor)"
>
<img
v-if="actor.avatar"
class="field-avatar"
:src="getPath(actor.avatar, 'lazy')"
>
<span
v-else
class="field-avatar"
/>
<div class="result-label">
{{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
</div>
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field tags">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(tag, index) in tags"
:key="`tag-${tag.id}`"
class="field-item tag"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="tagAnd = !tagAnd"
>{{ tagAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">{{ tag.name }}</div>
<Icon
icon="cross2"
class="field-remove"
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addTagDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add tag</button>
<template #popper>
<input
ref="tagInput"
v-model="tagQuery"
class="input"
@input="searchTags"
>
<ul class="nolist">
<li
v-for="tag in tagResults"
:key="`tag-result-${tag.id}`"
v-close-popper
class="result-item result-label"
@click="selectTag(tag)"
>{{ tag.name }}</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field entities">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
class="field-item entity"
>
<div
v-if="index > 0"
v-tooltip.click="{
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
triggers: ['click'],
autoHide: true,
ariaId: `entity-and-tooltip-${entity.id}`,
}"
class="field-logic"
>OR</div>
<div class="field-tile field-label">
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addEntityDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add channel</button>
<template #popper>
<input
ref="entityInput"
v-model="entityQuery"
class="input"
@input="searchEntities"
>
<ul class="nolist">
<li
v-for="entity in entityResults"
:key="`entity-result-${entity.id}`"
v-close-popper
class="result-item result-label"
@click="selectEntity(entity)"
>
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field matches">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(match, index) in matches"
:key="`match-${match.property}-${match.expression}`"
class="field-item match"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="matchAnd = !matchAnd"
>{{ matchAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">
<strong>{{ match.property }}:</strong>&nbsp;{{ match.expression }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addMatchDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add expression</button>
<template #popper>
<form @submit.prevent="addMatch">
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
v-model="matchExpression"
placeholder="Expression, // for regex"
class="input"
>
</form>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section then">
<h3 class="heading">THEN</h3>
<label class="field notify">
<span>Notify me in traxxx</span>
<Checkbox
:checked="notify"
@change="(checked) => notify = checked"
/>
</label>
<!--
<label class="field email">
<span>E-mail me</span>
<Checkbox
:checked="email"
@change="(checked) => email = checked"
/>
</label>
-->
<div class="stash">
<ul class="field-items nolist noselect">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="field-item tag"
>
<div class="field-tile field-label stash">
<Icon
v-if="stash.isPrimary"
class="favorites"
icon="heart7"
/>{{ stash.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
/>
</li>
<template v-if="stashes.length < assets.stashes.length">
<li class="field-add">
<button
v-if="stashes.length === 0"
type="button"
class="button favorites"
@click="selectStash(assets.primaryStash)"
><Icon icon="heart7" />Add to favorites</button>
</li>
<li class="field-add">
<VDropdown :aria-id="addStashDropdownId">
<button
type="button"
class="button field-add"
><Icon icon="folder-heart" />Add to stash</button>
<template #popper>
<ul class="nolist">
<li
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
:key="`stash-result-${stash.id}`"
v-close-popper
class="result-item result-stash result-label"
@click="selectStash(stash)"
>
{{ stash.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</template>
</ul>
</div>
</div>
<div class="dialog-section dialog-actions">
<button
class="button button-submit"
>Set alert</button>
</div>
</form>
</Dialog>
</template>
<style scoped> <style scoped>
.dialog-body { .dialog-body {
width: 30rem; width: 30rem;

View File

@@ -1,3 +1,37 @@
<script setup>
import { inject } from 'vue';
const props = defineProps({
campaign: {
type: Object,
default: null,
},
backdrop: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { restriction } = pageContext;
// console.log(props.campaign);
const bannerSrc = (() => {
if (props.campaign.banner) {
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
}
return null;
})();
</script>
<template> <template>
<div <div
v-if="restriction" v-if="restriction"
@@ -12,7 +46,6 @@
<iframe <iframe
v-else-if="campaign?.banner?.type === 'html'" v-else-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width" :width="campaign.banner.width"
:height="campaign.banner.height" :height="campaign.banner.height"
:src="bannerSrc" :src="bannerSrc"
@@ -45,41 +78,6 @@
</a> </a>
</template> </template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { restriction } = pageContext;
const props = defineProps({
campaign: {
type: Object,
default: null,
},
backdrop: {
type: Boolean,
default: false,
},
});
// console.log(props.campaign);
const bannerSrc = (() => {
if (props.campaign.banner) {
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
}
return null;
})();
</script>
<style scoped> <style scoped>
.campaign { .campaign {
height: 100%; height: 100%;

View File

@@ -1,3 +1,28 @@
<script setup>
import { onMounted } from 'vue';
const props = defineProps({
title: {
type: String,
default: null,
},
confirmClose: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['open', 'close', 'event']);
function close() {
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-alert
emit('close');
}
}
onMounted(() => emit('open'));
</script>
<template> <template>
<Teleport to="#container"> <Teleport to="#container">
<div <div
@@ -24,31 +49,6 @@
</Teleport> </Teleport>
</template> </template>
<script setup>
import { onMounted } from 'vue';
const props = defineProps({
title: {
type: String,
default: null,
},
confirmClose: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['open', 'close', 'event']);
function close() {
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-restricted-globals, no-alert
emit('close');
}
}
onMounted(() => emit('open'));
</script>
<style> <style>
.dialog-body { .dialog-body {
display: flex; display: flex;

View File

@@ -1,3 +1,24 @@
<script setup>
defineProps({
stash: {
type: Object,
default: null,
},
path: {
type: String,
default: '',
},
domains: {
type: Array,
default: () => ['scenes', 'actors', 'movies'],
},
domain: {
type: String,
default: 'scenes',
},
});
</script>
<template> <template>
<nav class="domains"> <nav class="domains">
<Link <Link
@@ -32,27 +53,6 @@
</nav> </nav>
</template> </template>
<script setup>
defineProps({
stash: {
type: Object,
default: null,
},
path: {
type: String,
default: '',
},
domains: {
type: Array,
default: () => ['scenes', 'actors', 'movies'],
},
domain: {
type: String,
default: 'scenes',
},
});
</script>
<style scoped> <style scoped>
.domains { .domains {
display: flex; display: flex;

View File

@@ -1,3 +1,42 @@
<script setup>
import { ref, watch } from 'vue';
import ActorSearch from '#/components/actors/search.vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['actors']);
const newActors = ref([]);
function addActor(actor) {
newActors.value = newActors.value.concat(actor);
emit('actors', props.edits.actors.concat(actor.id));
}
watch(() => props.scene, () => {
newActors.value = [];
});
</script>
<template> <template>
<ul <ul
class="actors nolist" class="actors nolist"
@@ -40,43 +79,6 @@
</ul> </ul>
</template> </template>
<script setup>
import { ref, watch } from 'vue';
import ActorSearch from '#/components/actors/search.vue';
const newActors = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['actors']);
function addActor(actor) {
newActors.value = newActors.value.concat(actor);
emit('actors', props.edits.actors.concat(actor.id));
}
watch(() => props.scene, () => { newActors.value = []; });
</script>
<style scoped> <style scoped>
.actors { .actors {
display: flex; display: flex;

View File

@@ -1,3 +1,23 @@
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['augmentation']);
const augmentation = reactive(props.edits.augmentation);
watch(augmentation, () => emit('augmentation', augmentation));
</script>
<template> <template>
<div <div
class="figure augmentation" class="figure augmentation"
@@ -208,23 +228,3 @@
</div> </div>
</div> </div>
</template> </template>
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['augmentation']);
const augmentation = reactive(props.edits.augmentation);
watch(augmentation, () => emit('augmentation', augmentation));
</script>

View File

@@ -1,3 +1,15 @@
<script setup>
import { format } from 'date-fns';
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<template> <template>
<div class="avatar noshrink"> <div class="avatar noshrink">
<img <img
@@ -19,10 +31,16 @@
>{{ avatar.sharpness.toFixed(2) }}</span> >{{ avatar.sharpness.toFixed(2) }}</span>
</span> </span>
<span
:title="`Added ${format(avatar.createdAt, 'yyyy-MM-dd')}, may not reflect photo age`"
class="avatar-date"
>{{ format(avatar.createdAt, '\'\'yy-MM') }}</span>
<a <a
:href="getPath(avatar)" :href="getPath(avatar)"
target="_blank" target="_blank"
class="avatar-zoom" class="avatar-zoom"
@click.stop
> >
<Icon <Icon
icon="search" icon="search"
@@ -31,17 +49,6 @@
</div> </div>
</template> </template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<style scoped> <style scoped>
.avatar { .avatar {
display: inline-block; display: inline-block;
@@ -89,7 +96,8 @@ defineProps({
} }
.avatar-meta, .avatar-meta,
.avatar-credit { .avatar-credit,
.avatar-date {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
box-sizing: border-box; box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem; bottom: .75rem;
left: 0; left: 0;
} }
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</style> </style>

View File

@@ -1,104 +1,3 @@
<template>
<div class="figure">
<div class="value-section">
<span class="value-label">Units</span>
<select
:value="units"
class="input"
:disabled="!editing.has('figure')"
@change="emit('units', $event.target.value)"
>
<option value="us">USA</option>
<option value="uk">UK</option>
<option value="eu">Europe/Asia</option>
<option value="jp">Japan</option>
<option value="au">Australia</option>
<option value="it">Italy</option>
<option value="fr">France</option>
</select>
</div>
<span class="figure-bust">
<div class="value-section">
<span class="value-label">Bust</span>
<select
v-model="figure.bust"
class="select input"
placeholder="Bust"
:disabled="!editing.has('figure')"
>
<option
:key="`figure-bust-unknown`"
:value="null"
/>
<option
v-for="bust in bustSizes[units]"
:key="`figure-bust-${bust}`"
:value="Array.isArray(bust) ? bust[0] : bust"
>{{ Array.isArray(bust) ? bust.join('/') : bust }}</option>
</select>
</div>
<div class="value-section">
<span class="value-label">Cup</span>
<select
v-model="figure.cup"
class="select input"
placeholder="Cup"
:disabled="!editing.has('figure')"
>
<option
:key="`figure-cup-unknown`"
:value="null"
/>
<option
v-for="cup in cupSizes[units]"
:key="`figure-cup-${cup}`"
:value="Array.isArray(cup) ? cup[0] : cup"
>{{ Array.isArray(cup) ? cup.join('/') : cup }}</option>
</select>
</div>
</span>
<div class="value-section">
<span class="value-label">Waist</span>
<span>
<input
v-model="figure.waist"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
<div class="value-section">
<span class="value-label">Hip</span>
<span>
<input
v-model="figure.hip"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
</div>
</template>
<script setup> <script setup>
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
@@ -145,3 +44,104 @@ const bustSizes = {
bustSizes.uk = bustSizes.us; bustSizes.uk = bustSizes.us;
bustSizes.jp = bustSizes.eu; bustSizes.jp = bustSizes.eu;
</script> </script>
<template>
<div class="figure">
<div class="value-section">
<span class="value-label">Units</span>
<select
:value="units"
class="input"
:disabled="!editing.has('figure')"
@change="emit('units', $event.target.value)"
>
<option value="us">USA</option>
<option value="uk">UK</option>
<option value="eu">Europe/Asia</option>
<option value="jp">Japan</option>
<option value="au">Australia</option>
<option value="it">Italy</option>
<option value="fr">France</option>
</select>
</div>
<span class="figure-bust">
<div class="value-section">
<span class="value-label">Bust</span>
<select
v-model="figure.bust"
class="select input"
placeholder="Bust"
:disabled="!editing.has('figure')"
>
<option
key="figure-bust-unknown"
:value="null"
/>
<option
v-for="bust in bustSizes[units]"
:key="`figure-bust-${bust}`"
:value="Array.isArray(bust) ? bust[0] : bust"
>{{ Array.isArray(bust) ? bust.join('/') : bust }}</option>
</select>
</div>
<div class="value-section">
<span class="value-label">Cup</span>
<select
v-model="figure.cup"
class="select input"
placeholder="Cup"
:disabled="!editing.has('figure')"
>
<option
key="figure-cup-unknown"
:value="null"
/>
<option
v-for="cup in cupSizes[units]"
:key="`figure-cup-${cup}`"
:value="Array.isArray(cup) ? cup[0] : cup"
>{{ Array.isArray(cup) ? cup.join('/') : cup }}</option>
</select>
</div>
</span>
<div class="value-section">
<span class="value-label">Waist</span>
<span>
<input
v-model="figure.waist"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
<div class="value-section">
<span class="value-label">Hip</span>
<span>
<input
v-model="figure.hip"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
</div>
</template>

View File

@@ -1,3 +1,46 @@
<script setup>
import { ref, watch } from 'vue';
import MovieSearch from '#/components/movies/search.vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['movies']);
const newMovies = ref([]);
function addMovie(movie) {
newMovies.value = newMovies.value.concat(movie);
console.log(movie);
emit('movies', props.edits.movies.concat(movie.id));
console.log(props.edits);
}
watch(() => props.scene, () => {
newMovies.value = [];
});
</script>
<template> <template>
<ul <ul
class="movies nolist" class="movies nolist"
@@ -40,47 +83,6 @@
</ul> </ul>
</template> </template>
<script setup>
import { ref, watch } from 'vue';
import MovieSearch from '#/components/movies/search.vue';
const newMovies = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['movies']);
function addMovie(movie) {
newMovies.value = newMovies.value.concat(movie);
console.log(movie);
emit('movies', props.edits.movies.concat(movie.id));
console.log(props.edits);
}
watch(() => props.scene, () => { newMovies.value = []; });
</script>
<style scoped> <style scoped>
.movies { .movies {
display: flex; display: flex;

View File

@@ -1,3 +1,27 @@
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
units: {
type: String,
default: null,
},
});
const emit = defineEmits(['penis', 'units']);
const penis = reactive(props.edits.penis);
watch(penis, () => emit('penis', penis));
</script>
<template> <template>
<div <div
class="figure penis" class="figure penis"
@@ -83,27 +107,3 @@
</div> </div>
</div> </div>
</template> </template>
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
units: {
type: String,
default: null,
},
});
const emit = defineEmits(['penis', 'units']);
const penis = reactive(props.edits.penis);
watch(penis, () => emit('penis', penis));
</script>

View File

@@ -1,3 +1,43 @@
<script setup>
import { inject, reactive, watch } from 'vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['place']);
const pageContext = inject('pageContext');
const countries = pageContext.pageProps.countries;
const place = reactive(props.edits[props.item.key]);
watch(place, () => emit('place', place));
const topCountries = [
'AU',
'BR',
'CZ',
'DE',
'JP',
'RU',
'GB',
'US',
];
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
</script>
<template> <template>
<div <div
class="place" class="place"
@@ -35,43 +75,3 @@
</div> </div>
</div> </div>
</template> </template>
<script setup>
import { reactive, watch, inject } from 'vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const pageContext = inject('pageContext');
const countries = pageContext.pageProps.countries;
const emit = defineEmits(['place']);
const place = reactive(props.edits[props.item.key]);
watch(place, () => emit('place', place));
const topCountries = [
'AU',
'BR',
'CZ',
'DE',
'JP',
'RU',
'GB',
'US',
];
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
</script>

View File

@@ -1,3 +1,42 @@
<script setup>
import formatTemplate from 'template-format';
import { inject } from 'vue';
defineProps({
rev: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
socials: {
type: Array,
default: () => [],
},
});
const pageContext = inject('pageContext');
const { env } = pageContext;
const iconMap = {
twitter: 'twitter-x',
};
function getUrl(social) {
if (social.url) {
return social.url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<template> <template>
<ul class="socials nolist"> <ul class="socials nolist">
<li <li
@@ -38,46 +77,6 @@
</ul> </ul>
</template> </template>
<script setup>
import { inject } from 'vue';
import formatTemplate from 'template-format';
const pageContext = inject('pageContext');
const { env } = pageContext;
defineProps({
rev: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
socials: {
type: Array,
default: () => [],
},
});
const iconMap = {
twitter: 'twitter-x',
};
function getUrl(social) {
if (social.url) {
return social.url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<style scoped> <style scoped>
.socials { .socials {
display: flex; display: flex;

View File

@@ -1,235 +1,6 @@
<template>
<div class="page">
<div
v-if="context === 'admin'"
class="revs-header"
>
<Checkbox
label="Show finalized"
:checked="showReviewed"
@change="(checked) => { showReviewed = checked; reloadRevisions(); }"
/>
</div>
<ul
class="revs nolist"
:class="{ [`revs-${context}`]: true }"
>
<li
v-for="rev in curatedRevisions"
:key="`rev-${rev.id}`"
class="rev"
:class="{
reviewed: reviewedRevisions.has(rev.id),
expanded: context === 'admin' || expanded.has(rev.id)
}"
>
<template v-if="context === 'admin' || expanded.has(rev.id)">
<div class="rev-header">
<a
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-scene nolink noshrink"
>{{ rev.sceneId || rev.actorId }}@{{ rev.hash.slice(0, 6) }}</a>
<a
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-title nolink ellipsis"
>{{ rev.base.title || rev.base.name }}</a>
<div class="rev-details noshrink">
<a
v-if="rev.user"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-username nolink"
>{{ rev.user.username }}</a>
<time
:datetime="rev.createdAt"
class="rev-created"
>{{ format(rev.createdAt, 'yyyy-MM-dd hh:mm') }}</time>
</div>
<div class="rev-actions noshrink">
<span
v-if="rev.review && context === 'admin'"
class="approved"
:class="{ rejected: !rev.review.isApproved }"
>{{ rev.review.isApproved ? 'Approved' : 'Rejected' }} by&nbsp;<a
:href="`/user/${rev.review.username}`"
target="_blank"
class="nolink"
>{{ rev.review.username }}</a>&nbsp;{{ format(rev.review.reviewedAt, 'yyyy-MM-dd hh:mm') }}</span>
<template v-else-if="context === 'admin'">
<Icon
v-tooltip="`Ban user from submitting revisions`"
icon="user-block"
class="review-reject review-ban"
@click="banEditor(rev)"
/>
<Icon
v-tooltip="`Reject revision`"
icon="blocked"
class="review-reject"
@click="reviewRevision(rev, false)"
/>
<input
v-model="feedbacks[rev.id]"
placeholder="Feedback"
class="input"
>
<Icon
v-tooltip="`Approve and apply revision`"
icon="checkmark"
class="review-approve"
@click="reviewRevision(rev, true)"
/>
</template>
</div>
</div>
<ul class="rev-deltas">
<li
v-for="(delta, index) in rev.deltas"
:key="`delta-${rev.id}-${index}`"
class="delta"
>
<span class="delta-key ellipsis">{{ delta.key }}</span>
<div class="delta-deltas">
<span class="delta-from delta-value">
<Socials
v-if="delta.key === 'socials'"
:rev="rev"
:index="index"
:socials="rev.base[delta.key]"
/>
<ul
v-else-if="Array.isArray(rev.base[delta.key])"
class="nolist"
>[
<li
v-for="item in rev.base[delta.key]"
:key="`item-${rev.id}-${index}-${item.id}`"
class="delta-item"
:class="{ modified: item.modified }"
>{{ item.name || item.id || item }}</li>&nbsp;]
</ul>
<Avatar
v-else-if="delta.key === 'avatar'"
:avatar="avatarsById[rev.base[delta.key]]"
class="delta-avatar"
/>
<template v-else-if="rev.base[delta.key] instanceof Date">{{ format(rev.base[delta.key], 'yyyy-MM-dd hh:mm') }}</template>
<template v-else>{{ rev.base[delta.key] }}</template>
</span>
<span class="delta-arrow"></span>
<span class="delta-to delta-value">
<Socials
v-if="delta.key === 'socials'"
:rev="rev"
:index="index"
:socials="delta.value"
/>
<ul
v-else-if="Array.isArray(delta.value)"
class="nolist"
>[
<li
v-for="item in delta.value"
:key="`item-${rev.id}-${index}-${item.id}`"
class="delta-item"
:class="{ modified: item.modified }"
>{{ item.name || item.id || item }}</li>&nbsp;]
</ul>
<Avatar
v-else-if="delta.key === 'avatar'"
:avatar="avatarsById[delta.value]"
class="delta-avatar"
/>
<template v-else-if="delta.value instanceof Date">{{ format(delta.value, 'yyyy-MM-dd hh:mm') }}</template>
<template v-else>{{ delta.value }}</template>
</span>
</div>
</li>
</ul>
<div
v-if="rev.comment"
class="rev-comment"
>
{{ rev.comment }}
</div>
</template>
<div
v-else
class="rev-compact"
@click="expanded.add(rev.id)"
>
<span class="rev-scene nolink noshrink"><template v-if="context !== 'scene' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span>
<span
v-if="context !== 'scene'"
class="rev-title nolink ellipsis"
>{{ rev.base.title }}</span>
<span class="rev-summary">
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
<span
v-if="rev.comment"
class="summary-comment ellipsis"
>{{ rev.comment }}</span>
</span>
<div class="rev-details noshrink">
<a
v-if="rev.user && context !== 'user'"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-username nolink ellipsis"
@click.stop
>{{ rev.user.username }}</a>
<a
v-if="rev.user && context !== 'user'"
v-tooltip="rev.user.username"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-avatar nolink ellipsis"
@click.stop
><Icon icon="user3-long" /></a>
<time
:datetime="rev.createdAt"
class="rev-created"
>{{ format(rev.createdAt, 'yyyy-MM-dd hh:mm') }}</time>
</div>
</div>
</li>
</ul>
</div>
</template>
<script setup> <script setup>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { computed, inject, ref } from 'vue';
import Avatar from '#/components/edit/avatar.vue'; import Avatar from '#/components/edit/avatar.vue';
import Socials from '#/components/edit/revision-socials.vue'; import Socials from '#/components/edit/revision-socials.vue';
@@ -410,7 +181,8 @@ async function reviewRevision(revision, isApproved) {
}); });
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev)); revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
} catch (error) { }
catch (error) {
reviewedRevisions.value.delete(revision.id); reviewedRevisions.value.delete(revision.id);
} }
} }
@@ -425,6 +197,235 @@ async function banEditor(revision) {
} }
</script> </script>
<template>
<div class="page">
<div
v-if="context === 'admin'"
class="revs-header"
>
<Checkbox
label="Show finalized"
:checked="showReviewed"
@change="(checked) => { showReviewed = checked; reloadRevisions(); }"
/>
</div>
<ul
class="revs nolist"
:class="{ [`revs-${context}`]: true }"
>
<li
v-for="rev in curatedRevisions"
:key="`rev-${rev.id}`"
class="rev"
:class="{
reviewed: reviewedRevisions.has(rev.id),
expanded: context === 'admin' || expanded.has(rev.id),
}"
>
<template v-if="context === 'admin' || expanded.has(rev.id)">
<div class="rev-header">
<a
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-scene nolink noshrink"
>{{ rev.sceneId || rev.actorId }}@{{ rev.hash.slice(0, 6) }}</a>
<a
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-title nolink ellipsis"
>{{ rev.base.title || rev.base.name }}</a>
<div class="rev-details noshrink">
<a
v-if="rev.user"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-username nolink"
>{{ rev.user.username }}</a>
<time
:datetime="rev.createdAt"
class="rev-created"
>{{ format(rev.createdAt, 'yyyy-MM-dd hh:mm') }}</time>
</div>
<div class="rev-actions noshrink">
<span
v-if="rev.review && context === 'admin'"
class="approved"
:class="{ rejected: !rev.review.isApproved }"
>{{ rev.review.isApproved ? 'Approved' : 'Rejected' }} by&nbsp;<a
:href="`/user/${rev.review.username}`"
target="_blank"
class="nolink"
>{{ rev.review.username }}</a>&nbsp;{{ format(rev.review.reviewedAt, 'yyyy-MM-dd hh:mm') }}</span>
<template v-else-if="context === 'admin'">
<Icon
v-tooltip="{ content: 'Ban user from submitting revisions', ariaId: `rev-ban-${rev.id}` }"
icon="user-block"
class="review-reject review-ban"
@click="banEditor(rev)"
/>
<Icon
v-tooltip="{ content: 'Reject revision', ariaId: `rev-reject-${rev.id}` }"
icon="blocked"
class="review-reject"
@click="reviewRevision(rev, false)"
/>
<input
v-model="feedbacks[rev.id]"
placeholder="Feedback"
class="input"
>
<Icon
v-tooltip="{ content: 'Approve and apply revision', ariaId: `rev-approve-${rev.id}` }"
icon="checkmark"
class="review-approve"
@click="reviewRevision(rev, true)"
/>
</template>
</div>
</div>
<ul class="rev-deltas">
<li
v-for="(delta, index) in rev.deltas"
:key="`delta-${rev.id}-${index}`"
class="delta"
>
<span class="delta-key ellipsis">{{ delta.key }}</span>
<div class="delta-deltas">
<span class="delta-from delta-value">
<Socials
v-if="delta.key === 'socials'"
:rev="rev"
:index="index"
:socials="rev.base[delta.key]"
/>
<ul
v-else-if="Array.isArray(rev.base[delta.key])"
class="nolist"
>[
<li
v-for="item in rev.base[delta.key]"
:key="`item-${rev.id}-${index}-${item.id}`"
class="delta-item"
:class="{ modified: item.modified }"
>{{ item.name || item.id || item }}</li>&nbsp;]
</ul>
<Avatar
v-else-if="delta.key === 'avatar'"
:avatar="avatarsById[rev.base[delta.key]]"
class="delta-avatar"
/>
<template v-else-if="rev.base[delta.key] instanceof Date">{{ format(rev.base[delta.key], 'yyyy-MM-dd hh:mm') }}</template>
<template v-else>{{ rev.base[delta.key] }}</template>
</span>
<span class="delta-arrow"></span>
<span class="delta-to delta-value">
<Socials
v-if="delta.key === 'socials'"
:rev="rev"
:index="index"
:socials="delta.value"
/>
<ul
v-else-if="Array.isArray(delta.value)"
class="nolist"
>[
<li
v-for="item in delta.value"
:key="`item-${rev.id}-${index}-${item.id}`"
class="delta-item"
:class="{ modified: item.modified }"
>{{ item.name || item.id || item }}</li>&nbsp;]
</ul>
<Avatar
v-else-if="delta.key === 'avatar'"
:avatar="avatarsById[delta.value]"
class="delta-avatar"
/>
<template v-else-if="delta.value instanceof Date">{{ format(delta.value, 'yyyy-MM-dd hh:mm') }}</template>
<template v-else>{{ delta.value }}</template>
</span>
</div>
</li>
</ul>
<div
v-if="rev.comment"
class="rev-comment"
>
{{ rev.comment }}
</div>
</template>
<div
v-else
class="rev-compact"
@click="expanded.add(rev.id)"
>
<span class="rev-scene nolink noshrink"><template v-if="context !== 'scene' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span>
<span
v-if="context !== 'scene'"
class="rev-title nolink ellipsis"
>{{ rev.base.title }}</span>
<span class="rev-summary">
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
<span
v-if="rev.comment"
class="summary-comment ellipsis"
>{{ rev.comment }}</span>
</span>
<div class="rev-details noshrink">
<a
v-if="rev.user && context !== 'user'"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-username nolink ellipsis"
@click.stop
>{{ rev.user.username }}</a>
<a
v-if="rev.user && context !== 'user'"
v-tooltip="{ content: rev.user.username, ariaId: `rev-user-${rev.id}` }"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-avatar nolink ellipsis"
@click.stop
><Icon icon="user3-long" /></a>
<time
:datetime="rev.createdAt"
class="rev-created"
>{{ format(rev.createdAt, 'yyyy-MM-dd hh:mm') }}</time>
</div>
</div>
</li>
</ul>
</div>
</template>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;

View File

@@ -1,3 +1,75 @@
<script setup>
import formatTemplate from 'template-format';
import {
inject,
ref,
useId,
watch,
} from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['socials']);
const pageContext = inject('pageContext');
const { env } = pageContext;
const socials = ref(props.edits.socials);
const platform = ref('');
const handle = ref('');
const url = ref('');
const addSocialDropdownId = useId();
watch(socials, () => emit('socials', socials.value));
const iconMap = {
twitter: 'twitter-x',
};
function addSocial() {
if (!handle.value && !url.value) {
return;
}
if (handle.value && !platform.value) {
return;
}
socials.value = socials.value.concat({
platform: platform.value || null,
handle: handle.value || null,
url: url.value || null,
});
emit('socials', socials.value);
platform.value = '';
handle.value = '';
url.value = '';
}
function getUrl(social) {
if (social.url) {
return url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<template> <template>
<ul <ul
class="list nolist" class="list nolist"
@@ -54,7 +126,7 @@
</li> </li>
<li class="list-new"> <li class="list-new">
<VDropdown> <VDropdown :aria-id="addSocialDropdownId">
<Icon <Icon
icon="plus2" icon="plus2"
class="add noselect" class="add noselect"
@@ -110,77 +182,6 @@
</ul> </ul>
</template> </template>
<script setup>
import {
ref,
watch,
inject,
} from 'vue';
import formatTemplate from 'template-format';
const pageContext = inject('pageContext');
const { env } = pageContext;
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['socials']);
const socials = ref(props.edits.socials);
const platform = ref('');
const handle = ref('');
const url = ref('');
watch(socials, () => emit('socials', socials));
const iconMap = {
twitter: 'twitter-x',
};
function addSocial() {
if (!handle.value && !url.value) {
return;
}
if (handle.value && !platform.value) {
return;
}
socials.value = socials.value.concat({
platform: platform.value || null,
handle: handle.value || null,
url: url.value || null,
});
emit('socials', socials.value);
platform.value = '';
handle.value = '';
url.value = '';
}
function getUrl(social) {
if (social.url) {
return url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<style scoped> <style scoped>
.list { .list {
display: flex; display: flex;

View File

@@ -1,3 +1,72 @@
<script setup>
import { ref, watch } from 'vue';
import TagSearch from '#/components/tags/search.vue';
import events from '#/src/events.js';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['tags']);
const newTags = ref([]);
const tags = [
{
tags: props.item.value.filter((tag) => tag.actorId === null),
actor: null,
actorId: null,
},
...props.scene.actors.map((actor) => ({
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
actor,
actorId: actor?.id || null,
})),
];
function addTag(newTag, actor) {
const actorId = actor?.id || null;
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
events.emit('feedback', {
type: 'error',
message: 'Tag already added',
});
return;
}
const newTagWithActorId = {
...newTag,
actorId,
};
newTags.value = newTags.value.concat(newTagWithActorId);
emit('tags', props.edits.tags.concat(newTagWithActorId));
}
watch(() => props.scene, () => {
newTags.value = [];
});
</script>
<template> <template>
<div class="tags-sections"> <div class="tags-sections">
<div <div
@@ -53,73 +122,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, watch } from 'vue';
import events from '#/src/events.js';
import TagSearch from '#/components/tags/search.vue';
const newTags = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['tags']);
const tags = [
{
tags: props.item.value.filter((tag) => tag.actorId === null),
actor: null,
actorId: null,
},
...props.scene.actors.map((actor) => ({
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
actor,
actorId: actor?.id || null,
})),
];
function addTag(newTag, actor) {
const actorId = actor?.id || null;
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
events.emit('feedback', {
type: 'error',
message: 'Tag already added',
});
return;
}
const newTagWithActorId = {
...newTag,
actorId,
};
newTags.value = newTags.value.concat(newTagWithActorId);
emit('tags', props.edits.tags.concat(newTagWithActorId));
}
watch(() => props.scene, () => { newTags.value = []; });
</script>
<style scoped> <style scoped>
.tags { .tags {
display: flex; display: flex;

View File

@@ -1,3 +1,16 @@
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<template> <template>
<a <a
:href="`/${entity.type}/${entity.slug}`" :href="`/${entity.type}/${entity.slug}`"
@@ -22,19 +35,6 @@
</a> </a>
</template> </template>
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<style scoped> <style scoped>
.entity { .entity {
width: 15rem; width: 15rem;

View File

@@ -1,3 +1,34 @@
<script setup>
import Gender from '#/components/actors/gender.vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
defineProps({
actor: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
filters: {
type: Object,
default: null,
},
type: {
type: String,
default: 'available',
},
toggleActor: {
type: Function,
default: null,
},
});
const emit = defineEmits(['actor']);
</script>
<template> <template>
<li <li
:key="`filter-actor-${actor.id}`" :key="`filter-actor-${actor.id}`"
@@ -44,37 +75,6 @@
</li> </li>
</template> </template>
<script setup>
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
import Gender from '#/components/actors/gender.vue';
defineProps({
actor: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
filters: {
type: Object,
default: null,
},
type: {
type: String,
default: 'available',
},
toggleActor: {
type: Function,
default: null,
},
});
const emit = defineEmits(['actor']);
</script>
<style scoped> <style scoped>
.filter-name { .filter-name {
align-items: stretch; align-items: stretch;

View File

@@ -1,3 +1,79 @@
<script setup>
import { UseVirtualList } from '@vueuse/components';
import { computed, inject, ref, useId } from 'vue';
import Gender from '#/components/actors/gender.vue';
import Actor from '#/components/filters/actor.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const selectedGender = ref(null);
const order = ref('count');
const genderTooltipId = useId();
const sortAlphaTooltipId = useId();
const sortCountTooltipId = useId();
const pageContext = inject('pageContext');
const pageProps = pageContext.pageProps;
const { actor: pageActor } = pageProps;
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
const availableActors = computed(() => props.actors
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
&& actor.id !== pageActor?.id
&& searchRegexp.value.test(actor.name)
&& (!selectedGender.value || actor.gender === selectedGender.value))
.sort((actorA, actorB) => {
if (order.value === 'count') {
return actorB.count - actorA.count;
}
return actorA.name.localeCompare(actorB.name);
}));
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
function toggleActor(actor, combine) {
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
return;
}
if (combine) {
emit('update', 'actors', props.filters.actors.concat(actor));
}
else {
emit('update', 'actors', [actor]);
}
}
function selectGender() {
const genderIndex = genders.value.indexOf(selectedGender.value);
if (genderIndex >= genders.value.length - 1) {
selectedGender.value = genders.value[0];
return;
}
selectedGender.value = genders.value[genderIndex + 1];
}
</script>
<template> <template>
<div class="filter actors-container"> <div class="filter actors-container">
<div <div
@@ -18,6 +94,7 @@
</label> </label>
<div <div
v-tooltip="{ content: selectedGender ? `${selectedGender.slice(0, 1).toUpperCase()}${selectedGender.slice(1)} performers` : `All genders`, ariaId: genderTooltipId }"
class="filter-sort noselect" class="filter-sort noselect"
@click="selectGender" @click="selectGender"
> >
@@ -35,6 +112,7 @@
<div <div
v-show="order === 'name'" v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
@@ -45,6 +123,7 @@
<div <div
v-show="order === 'count'" v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'name'" @click="order = 'name'"
> >
@@ -96,78 +175,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import { UseVirtualList } from '@vueuse/components';
import Actor from '#/components/filters/actor.vue';
import Gender from '#/components/actors/gender.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const selectedGender = ref(null);
const order = ref('count');
const pageContext = inject('pageContext');
const pageProps = pageContext.pageProps;
const { actor: pageActor } = pageProps;
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
const availableActors = computed(() => props.actors
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
&& actor.id !== pageActor?.id
&& searchRegexp.value.test(actor.name)
&& (!selectedGender.value || actor.gender === selectedGender.value))
.sort((actorA, actorB) => {
if (order.value === 'count') {
return actorB.count - actorA.count;
}
return actorA.name.localeCompare(actorB.name);
}));
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
function toggleActor(actor, combine) {
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
return;
}
if (combine) {
emit('update', 'actors', props.filters.actors.concat(actor));
} else {
emit('update', 'actors', [actor]);
}
}
function selectGender() {
const genderIndex = genders.value.indexOf(selectedGender.value);
if (genderIndex >= genders.value.length - 1) {
selectedGender.value = genders.value[0];
return;
}
selectedGender.value = genders.value[genderIndex + 1];
}
</script>
<style scoped> <style scoped>
.filter { .filter {
padding: 0; padding: 0;

View File

@@ -1,3 +1,25 @@
<script setup>
import { format, subYears } from 'date-fns';
import { ref } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
import Checkbox from '#/components/form/checkbox.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
});
// <select> doesn't work well with :value for some reason
const emit = defineEmits(['update']);
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
const dobType = ref(props.filters.dobType);
</script>
<template> <template>
<div class="filter"> <div class="filter">
<div class="filter-section"> <div class="filter-section">
@@ -53,26 +75,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref } from 'vue';
import { format, subYears } from 'date-fns';
import Checkbox from '#/components/form/checkbox.vue';
import RangeFilter from '#/components/filters/range.vue';
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
const props = defineProps({
filters: {
type: Object,
default: null,
},
});
const dobType = ref(props.filters.dobType); // <select> doesn't work well with :value for some reason
const emit = defineEmits(['update']);
</script>
<style scoped> <style scoped>
.input { .input {
width: 100%; width: 100%;

View File

@@ -1,3 +1,33 @@
<script setup>
import { computed } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
cupRange: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
function cupRangeToCups(cupA, cupB) {
const smallestCode = cupA.charCodeAt(0);
const biggestCode = cupB.charCodeAt(0);
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
}
const naturalBoobsValues = [true, undefined, false];
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
</script>
<template> <template>
<div class="filter"> <div class="filter">
<RangeFilter <RangeFilter
@@ -42,33 +72,3 @@
</span> </span>
</div> </div>
</template> </template>
<script setup>
import { computed } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
cupRange: {
type: Array,
default: null,
},
});
function cupRangeToCups(cupA, cupB) {
const smallestCode = cupA.charCodeAt(0);
const biggestCode = cupB.charCodeAt(0);
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
}
const emit = defineEmits(['update']);
const naturalBoobsValues = [true, undefined, false];
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
</script>

View File

@@ -1,92 +1,5 @@
<template>
<div class="filter channels-container">
<div class="filters-sort">
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'name'"
class="filter-sort order noselect"
@click="order = 'count'"
>
<Icon icon="sort-alpha-asc" />
</div>
<div
v-show="order === 'count'"
class="filter-sort order noselect"
@click="order = 'name'"
>
<Icon icon="sort-numeric-desc" />
</div>
</div>
<div
v-if="entities.length === 0 && !filters.entity"
class="filter-empty"
>No channels</div>
<template v-else>
<ul
class="filter-items nolist"
>
<li
v-for="entity in entities"
:key="`filter-channel-${entity.id}`"
class="filter-item"
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
@click="emit('update', 'entity', entity)"
>
<span class="filter-name">
<span
class="filter-text"
:title="entity.name"
>
<img
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
:src="`/logos/${entity.slug}/favicon_dark.png`"
class="favicon"
>
<Icon
v-else
icon="arrow-up4"
/>
{{ entity.name }}
</span>
<span class="filter-details">
<span
v-if="entity.count"
:title="entity.count"
class="filter-count"
>{{ abbreviateNumber(entity.count) }}</span>
<Icon
v-if="filters.entity?.id === entity.id"
icon="cross2"
class="filter-remove"
@click.native.stop="emit('update', 'entity', null)"
/>
</span>
</span>
</li>
</ul>
</template>
</div>
</template>
<script setup> <script setup>
import { ref, computed, inject } from 'vue'; import { computed, inject, ref, useId } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js'; import abbreviateNumber from '#/src/utils/abbreviate-number.js';
@@ -106,6 +19,8 @@ const emit = defineEmits(['update']);
const search = ref(''); const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i')); const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('name'); const order = ref('name');
const sortAlphaTooltipId = useId();
const sortCountTooltipId = useId();
const { pageProps } = inject('pageContext'); const { pageProps } = inject('pageContext');
const { channel: pageChannel } = pageProps; const { channel: pageChannel } = pageProps;
@@ -160,6 +75,95 @@ const entities = computed(() => {
}); });
</script> </script>
<template>
<div class="filter channels-container">
<div class="filters-sort">
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
<Icon icon="sort-alpha-asc" />
</div>
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
<Icon icon="sort-numeric-desc" />
</div>
</div>
<div
v-if="entities.length === 0 && !filters.entity"
class="filter-empty"
>No channels</div>
<template v-else>
<ul
class="filter-items nolist"
>
<li
v-for="entity in entities"
:key="`filter-channel-${entity.id}`"
class="filter-item"
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
@click="emit('update', 'entity', entity)"
>
<span class="filter-name">
<span
class="filter-text"
:title="entity.name"
>
<img
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
:src="`/logos/${entity.slug}/favicon_dark.png`"
class="favicon"
>
<Icon
v-else
icon="arrow-up4"
/>
{{ entity.name }}
</span>
<span class="filter-details">
<span
v-if="entity.count"
:title="entity.count"
class="filter-count"
>{{ abbreviateNumber(entity.count) }}</span>
<Icon
v-if="filters.entity?.id === entity.id"
icon="cross2"
class="filter-remove"
@click.stop="emit('update', 'entity', null)"
/>
</span>
</span>
</li>
</ul>
</template>
</div>
</template>
<style scoped> <style scoped>
.filter-items { .filter-items {
max-height: 15rem; max-height: 15rem;

View File

@@ -1,3 +1,18 @@
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<template> <template>
<ul class="countries nolist"> <ul class="countries nolist">
<li <li
@@ -19,27 +34,12 @@
v-if="selectedCountry === country.alpha2" v-if="selectedCountry === country.alpha2"
icon="cross2" icon="cross2"
class="filter-remove" class="filter-remove"
@click.native.stop="emit('country', null)" @click.stop="emit('country', null)"
/> />
</li> </li>
</ul> </ul>
</template> </template>
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<style scoped> <style scoped>
.countries:not(:last-child) { .countries:not(:last-child) {
border-bottom: solid 1px var(--glass-weak-30); border-bottom: solid 1px var(--glass-weak-30);

View File

@@ -1,3 +1,32 @@
<script setup>
import { computed, inject, ref } from 'vue';
import Countries from './countries.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
countries: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
const pageContext = inject('pageContext');
const { pageProps } = pageContext;
const allCountries = computed(() => props.countries || pageProps.countries);
const countryQuery = ref('');
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
</script>
<template> <template>
<div <div
class="countries-container" class="countries-container"
@@ -43,35 +72,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import Countries from './countries.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
countries: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
const pageContext = inject('pageContext');
const { pageProps } = pageContext;
const allCountries = computed(() => props.countries || pageProps.countries);
const countryQuery = ref('');
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
</script>
<style scoped> <style scoped>
.countries-container { .countries-container {
border-bottom: solid 1px var(--shadow-weak-30); border-bottom: solid 1px var(--shadow-weak-30);

View File

@@ -1,3 +1,28 @@
<script setup>
import { ref } from 'vue';
defineProps({
results: {
type: Number,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
// desktop defaults to open, compact defaults to closed
// we can't measure viewframe in SSR, so use separate toggles
const showFilters = ref(true);
const showFiltersCompact = ref(false);
function toggleFilters(state) {
showFilters.value = state ?? !showFilters.value;
showFiltersCompact.value = state ?? !showFiltersCompact.value;
}
</script>
<template> <template>
<div class="filters-frame"> <div class="filters-frame">
<div <div
@@ -47,31 +72,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref } from 'vue';
defineProps({
results: {
type: Number,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
// desktop defaults to open, compact defaults to closed
// we can't measure viewframe in SSR, so use separate toggles
const showFilters = ref(true);
const showFiltersCompact = ref(false);
function toggleFilters(state) {
showFilters.value = state ?? !showFilters.value;
showFiltersCompact.value = state ?? !showFiltersCompact.value;
}
</script>
<style> <style>
.filter { .filter {
padding: .5rem; padding: .5rem;

View File

@@ -1,3 +1,16 @@
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<template> <template>
<ul class="filter genders nolist"> <ul class="filter genders nolist">
<li> <li>
@@ -47,19 +60,6 @@
</ul> </ul>
</template> </template>
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<style scoped> <style scoped>
.genders { .genders {
display: flex; display: flex;

View File

@@ -1,3 +1,16 @@
<script setup>
import RangeFilter from '#/components/filters/range.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<template> <template>
<div class="filter"> <div class="filter">
<RangeFilter <RangeFilter
@@ -31,16 +44,3 @@
</RangeFilter> </RangeFilter>
</div> </div>
</template> </template>
<script setup>
import RangeFilter from '#/components/filters/range.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>

View File

@@ -1,39 +1,3 @@
<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 #start><slot name="start" /></template>
<template #end><slot name="end" /></template>
</Range>
</span>
</div>
</template>
<script setup> <script setup>
import Checkbox from '../form/checkbox.vue'; import Checkbox from '../form/checkbox.vue';
import Range from '../form/range.vue'; import Range from '../form/range.vue';
@@ -75,3 +39,39 @@ defineProps({
const emit = defineEmits(['change', 'input', 'enable']); const emit = defineEmits(['change', 'input', 'enable']);
</script> </script>
<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 #start><slot name="start" /></template>
<template #end><slot name="end" /></template>
</Range>
</span>
</div>
</template>

View File

@@ -1,3 +1,129 @@
<script setup>
import { computed, inject, ref, useId } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
filters: {
type: Object,
default: null,
},
tags: {
type: Array,
default: () => [],
},
actorTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
const {
tag: pageTag,
actor: pageActor,
} = pageProps;
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority');
const showActorTags = ref(!!pageActor);
const actorTagsTooltipId = useId();
const allTagsTooltipId = useId();
const sortPriorityTooltipId = useId();
const sortCountTooltipId = useId();
const sortAlphaTooltipId = useId();
const priorityTags = [
'anal',
'dp',
'threesome',
'gangbang',
'blowbang',
'orgy',
'airtight',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
'transsexual',
'spitroast',
'mfm',
'fmf',
'bdsm',
'deepthroat',
'blowjob',
'lesbian',
];
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor));
const groupedTags = computed(() => {
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one
const tags = showActorTags.value && isActorTagsAvailable.value
? props.actorTags
: props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name));
if (order.value === 'count') {
const available = filtered.sort((tagA, tagB) => {
if (order.value === 'count') {
return tagB.count - tagA.count;
}
return 0;
});
return {
// page: [pageTag],
selected,
available,
};
}
if (order.value === 'priority') {
return {
// page: [pageTag],
selected,
available: [
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
],
};
}
return {
// page: [pageTag],
selected,
available: filtered,
};
});
function toggleTag(tag, combine) {
emit('update', 'onlyActorTags', showActorTags.value, false);
if (props.filters.tags.includes(tag.slug)) {
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
return;
}
if (combine) {
emit('update', 'tags', props.filters.tags.concat(tag.slug));
}
else {
emit('update', 'tags', [tag.slug]);
}
}
</script>
<template> <template>
<div class="filter tags-container"> <div class="filter tags-container">
<div class="filters-sort"> <div class="filters-sort">
@@ -15,7 +141,7 @@
<template v-if="isActorTagsAvailable"> <template v-if="isActorTagsAvailable">
<div <div
v-show="showActorTags" v-show="showActorTags"
v-tooltip="'Tags relevant to the selected actors'" v-tooltip="{ content: 'Tags relevant to the selected actors', ariaId: actorTagsTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="showActorTags = false" @click="showActorTags = false"
> >
@@ -24,7 +150,7 @@
<div <div
v-show="!showActorTags" v-show="!showActorTags"
v-tooltip="'All tags'" v-tooltip="{ content: 'All tags', ariaId: allTagsTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="showActorTags = true" @click="showActorTags = true"
> >
@@ -34,6 +160,7 @@
<div <div
v-show="order === 'priority'" v-show="order === 'priority'"
v-tooltip="{ content: 'Sorted by tag priority', ariaId: sortPriorityTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
@@ -42,6 +169,7 @@
<div <div
v-show="order === 'count'" v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'name'" @click="order = 'name'"
> >
@@ -52,6 +180,7 @@
<div <div
v-show="order === 'name'" v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'priority'" @click="order = 'priority'"
> >
@@ -119,125 +248,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
filters: {
type: Object,
default: null,
},
tags: {
type: Array,
default: () => [],
},
actorTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
const {
tag: pageTag,
actor: pageActor,
stash: pageStash,
} = pageProps;
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority');
const showActorTags = ref(!!pageActor);
const priorityTags = [
'anal',
'dp',
'threesome',
'gangbang',
'blowbang',
'orgy',
'airtight',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
'transsexual',
'spitroast',
'mfm',
'fmf',
'bdsm',
'deepthroat',
'blowjob',
'lesbian',
];
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor) && !pageStash);
const groupedTags = computed(() => {
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one
const tags = showActorTags.value && isActorTagsAvailable.value
? props.actorTags
: props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name));
if (order.value === 'count') {
const available = filtered.sort((tagA, tagB) => {
if (order.value === 'count') {
return tagB.count - tagA.count;
}
return 0;
});
return {
// page: [pageTag],
selected,
available,
};
}
if (order.value === 'priority') {
return {
// page: [pageTag],
selected,
available: [
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
],
};
}
return {
// page: [pageTag],
selected,
available: filtered,
};
});
function toggleTag(tag, combine) {
if (props.filters.tags.includes(tag.slug)) {
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
return;
}
if (combine) {
emit('update', 'tags', props.filters.tags.concat(tag.slug));
} else {
emit('update', 'tags', [tag.slug]);
}
}
</script>
<style scoped> <style scoped>
.filter-items { .filter-items {
max-height: 15rem; max-height: 15rem;

View File

@@ -1,3 +1,7 @@
<script setup>
const emit = defineEmits(['toggle']);
</script>
<template> <template>
<div <div
class="filters-toggle open" class="filters-toggle open"
@@ -5,10 +9,6 @@
><Icon icon="filter" /></div> ><Icon icon="filter" /></div>
</template> </template>
<script setup>
const emit = defineEmits(['toggle']);
</script>
<style scoped> <style scoped>
.filters-toggle { .filters-toggle {
min-width: 2.75rem; min-width: 2.75rem;

View File

@@ -1,28 +1,3 @@
<template>
<div class="years-container">
<select
v-model="selected"
class="years nobar"
multiple
@change="updateYears"
>
<option
v-for="year in years"
:key="`year-${year.year}`"
class="year"
:value="year.year"
>{{ year.year }}</option>
</select>
<Icon
v-show="selected.length > 0"
icon="cross2"
class="clear"
@click="clearYears"
/>
</div>
</template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
@@ -50,6 +25,31 @@ function clearYears() {
} }
</script> </script>
<template>
<div class="years-container">
<select
v-model="selected"
class="years nobar"
multiple
@change="updateYears"
>
<option
v-for="year in years"
:key="`year-${year.year}`"
class="year"
:value="year.year"
>{{ year.year }}</option>
</select>
<Icon
v-show="selected.length > 0"
icon="cross2"
class="clear"
@click="clearYears"
/>
</div>
</template>
<style scoped> <style scoped>
.years-container { .years-container {
display: flex; display: flex;

View File

@@ -1,3 +1,10 @@
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<template> <template>
<footer class="footer"> <footer class="footer">
<span class="footer-segment">© traxxx</span> <span class="footer-segment">© traxxx</span>
@@ -27,13 +34,6 @@
</footer> </footer>
</template> </template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<style scoped> <style scoped>
.footer { .footer {
display: flex; display: flex;

View File

@@ -1,3 +1,12 @@
<script setup>
import { computed, inject } from 'vue';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<template> <template>
<nav class="nav-list noselect"> <nav class="nav-list noselect">
<a <a
@@ -37,15 +46,6 @@
</nav> </nav>
</template> </template>
<script setup>
import { computed, inject } from 'vue';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<style scoped> <style scoped>
.nav-list { .nav-list {
display: flex; display: flex;

View File

@@ -1,3 +1,24 @@
<script setup>
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
const uid = String(Math.random()).slice(2);
</script>
<template> <template>
<label class="check-container noselect"> <label class="check-container noselect">
<span <span
@@ -22,27 +43,6 @@
</label> </label>
</template> </template>
<script setup>
const uid = String(Math.random()).slice(2);
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
</script>
<style scoped> <style scoped>
.check-container { .check-container {
display: flex; display: flex;

View File

@@ -1,61 +1,8 @@
<template>
<div class="range-container">
<div
class="label label-start"
:class="{ disabled }"
@click="setRange(range.a === minValue ? 'a' : 'b', 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="range.a"
:min="min"
:max="max"
:data-value="range.a"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
<input
v-model.number="range.b"
:min="min"
:max="max"
:data-value="range.b"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
</div>
<div
class="label label-end"
:class="{ disabled }"
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
>
<slot name="end" />
</div>
</div>
</template>
<script setup> <script setup>
import { import {
ref,
computed, computed,
nextTick, nextTick,
ref,
} from 'vue'; } from 'vue';
const props = defineProps({ const props = defineProps({
@@ -138,6 +85,59 @@ async function setRange(prop, value) {
} }
</script> </script>
<template>
<div class="range-container">
<div
class="label label-start"
:class="{ disabled }"
@click="setRange(range.a === minValue ? 'a' : 'b', 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="range.a"
:min="min"
:max="max"
:data-value="range.a"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
<input
v-model.number="range.b"
:min="min"
:max="max"
:data-value="range.b"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
</div>
<div
class="label label-end"
:class="{ disabled }"
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
>
<slot name="end" />
</div>
</div>
</template>
<style> <style>
.dark .range-container .range { .dark .range-container .range {
--slider-range: var(--lighten-weak-10); --slider-range: var(--lighten-weak-10);

View File

@@ -1,26 +1,3 @@
<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> <script>
export default { export default {
props: { props: {
@@ -44,12 +21,35 @@ export default {
emits: ['change'], emits: ['change'],
data() { data() {
return { return {
id: Math.floor(new Date().getTime() * Math.random()), id: Math.floor(Date.now() * Math.random()),
}; };
}, },
}; };
</script> </script>
<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>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'breakpoints'; @import 'breakpoints';

View File

@@ -1,3 +1,64 @@
<script setup>
import Cookies from 'js-cookie';
import {
computed,
inject,
ref,
useId,
} from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import Notifications from '#/components/header/notifications.vue';
import Settings from '#/components/settings/settings.vue';
// import getPath from '#/src/get-path.js';
import { del } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
import logo from '../../assets/img/logo.svg?raw';
const pageContext = inject('pageContext');
const theme = ref(pageContext.env.theme);
const user = pageContext.user;
const primaryStash = pageContext.assets?.primaryStash;
const unseen = ref(pageContext.assets?.unseenNotifications);
const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false);
const showSettings = ref(false);
const showAlertDialog = ref(false);
const notifsDropdownId = useId();
const menuDropdownId = useId();
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
function search() {
navigate('/search', { q: query.value }, { redirect: true });
}
function setTheme(newTheme) {
theme.value = newTheme;
Cookies.set('theme', newTheme);
events.emit('theme', newTheme);
}
async function logout() {
await del('/session');
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
}
function blurSearch(event) {
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
searchFocused.value = false;
}
}
</script>
<template> <template>
<header class="header"> <header class="header">
<nav class="nav"> <nav class="nav">
@@ -92,6 +153,7 @@
> >
<VDropdown <VDropdown
v-if="user" v-if="user"
:aria-id="notifsDropdownId"
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true" :prevent-overflow="true"
class="notifs-trigger" class="notifs-trigger"
@@ -121,6 +183,7 @@
</VDropdown> </VDropdown>
<VDropdown <VDropdown
:aria-id="menuDropdownId"
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true" :prevent-overflow="true"
class="menu-trigger" class="menu-trigger"
@@ -187,6 +250,32 @@
</a> </a>
</li> </li>
<li
v-if="user"
class="menu-item"
>
<a
:href="`/user/${user.username}/stashes`"
class="menu-button nolink stashes"
>
<Icon icon="folder-heart" />
Stashes
</a>
</li>
<li
v-if="user"
class="menu-item"
>
<a
:href="`/user/${user.username}/alerts`"
class="menu-button nolink alerts"
>
<Icon icon="alarm" />
Alerts
</a>
</li>
<li <li
v-close-popper v-close-popper
class="menu-item menu-button" class="menu-item menu-button"
@@ -257,64 +346,6 @@
</header> </header>
</template> </template>
<script setup>
import {
ref,
computed,
inject,
} from 'vue';
import Cookies from 'js-cookie';
import navigate from '#/src/navigate.js';
import { del } from '#/src/api.js';
import events from '#/src/events.js';
// import getPath from '#/src/get-path.js';
import Notifications from '#/components/header/notifications.vue';
import Settings from '#/components/settings/settings.vue';
import AlertDialog from '#/components/alerts/create.vue';
import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext');
const theme = ref(pageContext.env.theme);
const user = pageContext.user;
const primaryStash = pageContext.assets?.primaryStash;
const unseen = ref(pageContext.assets?.unseenNotifications);
const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false);
const showSettings = ref(false);
const showAlertDialog = ref(false);
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
function search() {
navigate('/search', { q: query.value }, { redirect: true });
}
function setTheme(newTheme) {
theme.value = newTheme;
Cookies.set('theme', newTheme);
events.emit('theme', newTheme);
}
async function logout() {
await del('/session');
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
}
function blurSearch(event) {
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
searchFocused.value = false;
}
}
</script>
<style scoped> <style scoped>
.header { .header {
display: flex; display: flex;

View File

@@ -1,3 +1,67 @@
<script setup>
import {
defineEmits,
inject,
onMounted,
ref,
} from 'vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, patch } from '#/src/api.js';
import { formatDate } from '#/utils/format.js';
const emit = defineEmits(['unseen', 'addAlert']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const notifications = ref([]);
const done = ref(true);
async function fetchNotifications() {
done.value = false;
const res = await get(`/users/${user?.id}/notifications`);
notifications.value = res.notifications;
done.value = true;
emit('unseen', res.unseen);
}
async function markSeen(notif) {
if (notif.isSeen || !done.value) {
return;
}
done.value = false;
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function markAllSeen() {
done.value = false;
await patch(`/users/${user?.id}/notifications`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
onMounted(async () => {
await fetchNotifications();
});
</script>
<template> <template>
<div class="menu"> <div class="menu">
<div class="notifs-header"> <div class="notifs-header">
@@ -88,71 +152,6 @@
</div> </div>
</template> </template>
<script setup>
import {
ref,
defineEmits,
onMounted,
inject,
} from 'vue';
import { get, patch } from '#/src/api.js';
import { formatDate } from '#/utils/format.js';
import Ellipsis from '#/components/loading/ellipsis.vue';
const pageContext = inject('pageContext');
const user = pageContext.user;
const notifications = ref([]);
const done = ref(true);
const emit = defineEmits(['unseen', 'addAlert']);
async function fetchNotifications() {
done.value = false;
const res = await get(`/users/${user?.id}/notifications`);
notifications.value = res.notifications;
done.value = true;
emit('unseen', res.unseen);
}
async function markSeen(notif) {
if (notif.isSeen || !done.value) {
return;
}
done.value = false;
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function markAllSeen() {
done.value = false;
await patch(`/users/${user?.id}/notifications`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
onMounted(async () => {
await fetchNotifications();
});
</script>
<style scoped> <style scoped>
.notifs { .notifs {
width: 30rem; width: 30rem;

View File

@@ -1,17 +1,6 @@
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
const svg = ref(null);
const props = defineProps({ const props = defineProps({
icon: { icon: {
type: String, type: String,
@@ -27,11 +16,22 @@ const props = defineProps({
}, },
}); });
const svg = ref(null);
import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => { import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => {
svg.value = raw.default; svg.value = raw.default;
}); });
</script> </script>
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<style> <style>
.icon { .icon {
fill: var(--text); fill: var(--text);

View File

@@ -1,12 +1,3 @@
<template>
<a
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
rel="noopener"
>
<slot />
</a>
</template>
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
@@ -19,3 +10,12 @@ defineProps({
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
</script> </script>
<template>
<a
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
rel="noopener"
>
<slot />
</a>
</template>

View File

@@ -1,3 +1,40 @@
<script setup>
import { computed, ref, useId } from 'vue';
import Player from '#/components/video/player.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const playing = ref(false);
const paused = ref(false);
const poster = computed(() => {
if (props.release.poster) {
return getPath(props.release.poster, 'thumbnail');
}
if (props.release.covers?.length > 0) {
return getPath(props.release.covers[0], 'thumbnail');
}
if (props.release.photos?.length > 0) {
return getPath(props.release.photos[0], 'thumbnail');
}
return null;
});
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
const restrictedTooltipId = useId();
</script>
<template> <template>
<div <div
class="banner-container" class="banner-container"
@@ -49,7 +86,7 @@
<Icon <Icon
v-if="(release.trailer || release.teaser).isRestricted" v-if="(release.trailer || release.teaser).isRestricted"
v-tooltip="'Restricted video'" v-tooltip="{ content: 'Restricted video', ariaId: restrictedTooltipId }"
icon="blocked" icon="blocked"
class="restricted" class="restricted"
/> />
@@ -102,42 +139,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed } from 'vue';
import getPath from '#/src/get-path.js';
import Player from '#/components/video/player.vue';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const playing = ref(false);
const paused = ref(false);
const poster = computed(() => {
if (props.release.poster) {
return getPath(props.release.poster, 'thumbnail');
}
if (props.release.covers?.length > 0) {
return getPath(props.release.covers[0], 'thumbnail');
}
if (props.release.photos?.length > 0) {
return getPath(props.release.photos[0], 'thumbnail');
}
return null;
});
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
</script>
<style scoped> <style scoped>
.banner-container { .banner-container {
background-position: center; background-position: center;

View File

@@ -1,115 +1,20 @@
<template>
<div class="movies-page">
<Filters
v-if="showFilters"
:class="{ loading }"
>
<div class="filter">
<input
v-model="filters.search"
type="search"
placeholder="Search movies"
class="search input"
@search="search"
>
</div>
<YearsFilter
:filters="filters"
:years="aggYears"
@update="updateFilter"
/>
<TagsFilter
:filters="filters"
:tags="aggTags"
@update="updateFilter"
/>
<ChannelsFilter
:filters="filters"
:channels="aggChannels"
@update="updateFilter"
/>
<ActorsFilter
:filters="filters"
:actors="aggActors"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.requireCover"
label="Require box cover"
@change="(checked) => updateFilter('requireCover', checked, true)"
/>
</div>
</Filters>
<div class="movies-container">
<div class="movies-header">
<div class="meta">{{ total }} results</div>
<select
v-model="scope"
class="input"
@change="search({ autoScope: false })"
>
<!-- not selected in SSR without prop -->
<option
v-if="pageStash"
:selected="scope === 'stashed'"
value="stashed"
>Added</option>
<option
v-if="filters.search"
:selected="scope === 'results'"
value="results"
>Relevance</option>
<option value="likes">Popular</option>
<option value="latest">Latest</option>
<option value="upcoming">Upcoming</option>
<option value="new">New</option>
</select>
</div>
<ul class="movies nolist">
<li
v-for="movie in movies"
:key="`movie-${movie.id}`"
>
<MovieTile :movie="movie" />
</li>
</ul>
<Pagination
:total="total"
:page="currentPage"
/>
</div>
</div>
</template>
<script setup> <script setup>
import { ref, inject } from 'vue';
import { parse } from 'path-to-regexp'; import { parse } from 'path-to-regexp';
import { inject, ref } from 'vue';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
import events from '#/src/events.js';
import MovieTile from '#/components/movies/tile.vue';
import Filters from '#/components/filters/filters.vue';
import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue'; import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue'; import ChannelsFilter from '#/components/filters/channels.vue';
import Filters from '#/components/filters/filters.vue';
import TagsFilter from '#/components/filters/tags.vue';
import YearsFilter from '#/components/filters/years.vue';
import Checkbox from '#/components/form/checkbox.vue'; import Checkbox from '#/components/form/checkbox.vue';
import MovieTile from '#/components/movies/tile.vue';
import Pagination from '#/components/pagination/pagination.vue'; import Pagination from '#/components/pagination/pagination.vue';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { pageProps, routeParams, urlParsed } = pageContext; const { pageProps, routeParams, urlParsed } = pageContext;
@@ -236,6 +141,115 @@ function updateFilter(prop, value, reload = true) {
} }
</script> </script>
<template>
<div class="movies-page">
<Filters
v-if="showFilters"
:class="{ loading }"
>
<div class="filter search-container">
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="filters.search"
type="input"
placeholder="Search movies"
class="search input"
@keydown.enter="search"
>
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@click="search"
/>
</div>
<YearsFilter
:filters="filters"
:years="aggYears"
@update="updateFilter"
/>
<TagsFilter
:filters="filters"
:tags="aggTags"
@update="updateFilter"
/>
<ChannelsFilter
:filters="filters"
:channels="aggChannels"
@update="updateFilter"
/>
<ActorsFilter
:filters="filters"
:actors="aggActors"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.requireCover"
label="Require box cover"
@change="(checked) => updateFilter('requireCover', checked, true)"
/>
</div>
</Filters>
<div class="movies-container">
<div class="movies-header">
<div class="meta">{{ total }} results</div>
<select
v-model="scope"
class="input"
@change="search({ autoScope: false })"
>
<!-- not selected in SSR without prop -->
<option
v-if="pageStash"
:selected="scope === 'stashed'"
value="stashed"
>Added</option>
<option
v-if="filters.search"
:selected="scope === 'results'"
value="results"
>Relevance</option>
<option value="likes">Popular</option>
<option value="latest">Latest</option>
<option value="upcoming">Upcoming</option>
<option value="new">New</option>
</select>
</div>
<ul class="movies nolist">
<li
v-for="movie in movies"
:key="`movie-${movie.id}`"
>
<MovieTile :movie="movie" />
</li>
</ul>
<Pagination
:total="total"
:page="currentPage"
/>
</div>
</div>
</template>
<style scoped> <style scoped>
.movies-page { .movies-page {
display: flex; display: flex;

View File

@@ -1,5 +1,38 @@
<script setup>
import { format } from 'date-fns';
import { ref, useId } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['movie']);
const dropdownId = useId();
const movies = ref([]);
const query = ref(null);
const queryInput = ref(null);
async function search() {
const data = await get('/movies', { q: query.value });
movies.value = data.movies;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -36,39 +69,6 @@
</VDropdown> </VDropdown>
</template> </template>
<script setup>
import { ref } from 'vue';
import { format } from 'date-fns';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const movies = ref([]);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['movie']);
async function search() {
const data = await get('/movies', { q: query.value });
movies.value = data.movies;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped> <style scoped>
.trigger { .trigger {
height: 100%; height: 100%;

View File

@@ -1,3 +1,32 @@
<script setup>
import { format } from 'date-fns';
import { inject, ref } from 'vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
movie: {
type: Object,
default: null,
},
showDetails: {
type: Boolean,
default: true,
},
});
const pageContext = inject('pageContext');
const user = pageContext.user;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const currentYear = new Date().getFullYear();
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
</script>
<template> <template>
<div <div
class="movie-tile" class="movie-tile"
@@ -118,35 +147,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { format } from 'date-fns';
import getPath from '#/src/get-path.js';
import Heart from '#/components/stashes/heart.vue';
const props = defineProps({
movie: {
type: Object,
default: null,
},
showDetails: {
type: Boolean,
default: true,
},
});
const pageContext = inject('pageContext');
const user = pageContext.user;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const currentYear = new Date().getFullYear();
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
</script>
<style scoped> <style scoped>
.movie-tile { .movie-tile {
display: flex; display: flex;

View File

@@ -1,3 +1,119 @@
<script setup>
import { parse } from 'path-to-regexp';
import { computed, inject } from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
const props = defineProps({
page: {
type: Number,
default: null,
},
total: {
type: Number,
default: null,
},
redirect: {
type: Boolean,
default: true,
},
query: {
type: String,
default: null,
},
includeQuery: {
type: Boolean,
default: true,
},
useMaxMatches: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['navigation']);
const pageContext = inject('pageContext');
const {
routeParams,
urlParsed,
pageProps,
env,
campaigns,
} = pageContext;
const currentPage = computed(() => props.page || Number(routeParams?.page));
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
const total = computed(() => props.total || Number(pageProps.total));
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value - index - 1;
if (page < 1) {
return null;
}
return page;
}).filter(Boolean));
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value + index + 1;
if (page > pageTotal.value) {
return null;
}
return page;
}).filter(Boolean));
function go(page, event) {
if (!props.redirect) {
event.preventDefault();
history.pushState({}, '', event.target.href);
}
emit('navigation', {
href: event.target.href,
page,
});
}
function getPath(page) {
const query = typeof window === 'undefined'
? urlParsed.searchOriginal
: window.location.search;
if (!routeParams.path && props.includeQuery && query) {
return `${pageContext.urlParsed.pathname}${page}${query}`;
}
if (!routeParams.path) {
return `${pageContext.urlParsed.pathname}${page}`;
}
const path = parse(routeParams.path)
.map((segment) => {
if (segment.name === 'page') {
return `/${page}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
})
.join('');
if (props.includeQuery && query) {
return `${path}${query}`;
}
return path;
}
</script>
<template> <template>
<div class="pagination-container"> <div class="pagination-container">
<Campaign <Campaign
@@ -91,122 +207,6 @@
</div> </div>
</template> </template>
<script setup>
import { computed, inject } from 'vue';
import { parse } from 'path-to-regexp';
import Campaign from '#/components/campaigns/campaign.vue';
const props = defineProps({
page: {
type: Number,
default: null,
},
total: {
type: Number,
default: null,
},
redirect: {
type: Boolean,
default: true,
},
query: {
type: String,
default: null,
},
includeQuery: {
type: Boolean,
default: true,
},
useMaxMatches: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['navigation']);
const pageContext = inject('pageContext');
const {
routeParams,
urlParsed,
pageProps,
env,
campaigns,
} = pageContext;
const currentPage = computed(() => props.page || Number(routeParams?.page));
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
const total = computed(() => props.total || Number(pageProps.total));
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value - index - 1;
if (page < 1) {
return null;
}
return page;
}).filter(Boolean));
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value + index + 1;
if (page > pageTotal.value) {
return null;
}
return page;
}).filter(Boolean));
function go(page, event) {
if (!props.redirect) {
event.preventDefault();
history.pushState({}, '', event.target.href); // eslint-disable-line no-restricted-globals
}
emit('navigation', {
href: event.target.href,
page,
});
}
function getPath(page) {
const query = typeof window === 'undefined'
? urlParsed.searchOriginal
: window.location.search;
if (!routeParams.path && props.includeQuery && query) {
return `${pageContext.urlParsed.pathname}${page}${query}`;
}
if (!routeParams.path) {
return `${pageContext.urlParsed.pathname}${page}`;
}
const path = parse(routeParams.path)
.map((segment) => {
if (segment.name === 'page') {
return `/${page}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
})
.join('');
if (props.includeQuery && query) {
return `${path}${query}`;
}
return path;
}
</script>
<style scoped> <style scoped>
.pagination-container { .pagination-container {
display: flex; display: flex;

View File

@@ -1,3 +1,28 @@
<script setup>
import { computed } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate, formatDuration } from '#/utils/format.js';
const props = defineProps({
chapters: {
type: Array,
default: () => [],
},
});
const lastChapter = props.chapters.at(-1);
const duration = lastChapter.time + lastChapter.duration;
const timeline = computed(() => {
if (props.chapters.every((chapter) => chapter.time)) {
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
}
return null;
});
</script>
<template> <template>
<div <div
v-if="timeline" v-if="timeline"
@@ -43,19 +68,19 @@
<span class="chapter-details"> <span class="chapter-details">
<span <span
v-if="typeof chapter.time === 'number'" v-if="typeof chapter.time === 'number'"
v-tooltip="'Time in video'" v-tooltip="{ content: 'Time in video', ariaId: `chapter-time-${chapter.id}` }"
class="chapter-time" class="chapter-time"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span> ><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span <span
v-if="chapter.duration" v-if="chapter.duration"
v-tooltip="'Duration'" v-tooltip="{ content: 'Duration', ariaId: `chapter-duration-${chapter.id}` }"
class="chapter-duration" class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span> ><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time <time
v-if="chapter.date" v-if="chapter.date"
v-tooltip="formatDate(chapter.date, 'yyyy-MM-dd hh:mm')" v-tooltip="{ content: formatDate(chapter.date, 'yyyy-MM-dd hh:mm'), ariaId: `chapter-date-${chapter.id}` }"
:datetime="chapter.date" :datetime="chapter.date"
class="chapter-date" class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time> >{{ formatDate(chapter.date, 'MMM d') }}</time>
@@ -90,31 +115,6 @@
</ul> </ul>
</template> </template>
<script setup>
import { computed } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDuration, formatDate } from '#/utils/format.js';
const props = defineProps({
chapters: {
type: Array,
default: () => [],
},
});
const lastChapter = props.chapters.at(-1);
const duration = lastChapter.time + lastChapter.duration;
const timeline = computed(() => {
if (props.chapters.every((chapter) => chapter.time)) {
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
}
return null;
});
</script>
<style scoped> <style scoped>
.chapters { .chapters {
display: grid; display: grid;

View File

@@ -1,3 +1,18 @@
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<template> <template>
<div class="meta"> <div class="meta">
<div class="channel"> <div class="channel">
@@ -44,21 +59,6 @@
</div> </div>
</template> </template>
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<style scoped> <style scoped>
.meta { .meta {
display: flex; display: flex;

View File

@@ -1,3 +1,195 @@
<script setup>
import Cookies from 'js-cookie';
import { parse } from 'path-to-regexp';
import {
computed,
inject,
ref,
} from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import Filters from '#/components/filters/filters.vue';
import TagsFilter from '#/components/filters/tags.vue';
import YearsFilter from '#/components/filters/years.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import Pagination from '#/components/pagination/pagination.vue';
import SceneTile from '#/components/scenes/tile.vue';
import { get } from '#/src/api.js';
// import events from '#/src/events.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import navigate from '#/src/navigate.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
const props = defineProps({
showFilters: {
type: Boolean,
default: true,
},
showMeta: {
type: Boolean,
default: true,
},
showScopeTabs: {
type: Boolean,
default: false,
},
defaultScope: {
type: String,
default: 'latest',
},
});
const {
pageProps,
routeParams,
urlParsed,
campaigns,
env,
} = inject('pageContext');
const {
actor: pageActor,
tag: pageTag,
entity: pageEntity,
stash: pageStash,
} = pageProps;
const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page));
const scope = ref(routeParams.scope || props.defaultScope);
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
const loading = ref(false);
const scenesContainer = ref(null);
const view = ref(env.scenesView || 'grid');
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
const filters = ref({
search: urlParsed.search.q,
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
entity: queryEntity,
actors: queryActors,
});
const sceneCampaign = campaigns?.scenes;
const campaignIndex = campaigns?.index;
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
function getPath(targetScope, preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'scope') {
return `${segment.prefix}${targetScope}`;
}
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (filters.value.search) {
scope.value = 'results';
}
if (!filters.value.search && scope.value === 'results') {
scope.value = 'latest';
}
}
const query = {
q: filters.value.search || undefined,
};
const entity = filters.value.entity || pageEntity;
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
loading.value = true;
navigate(getPath(scope.value, false), {
...query,
years: filters.value.years.join(',') || undefined,
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
tags: filters.value.tags.join(',') || undefined,
at: (filters.value.tags.length > 0 && filters.value.onlyActorTags) || undefined,
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
}, { redirect: false });
const res = await get('/scenes', {
...query,
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
at: !!filters.value.onlyActorTags,
stashId: pageStash?.id,
e: entitySlug,
scope: scope.value,
page: currentPage.value, // client uses param rather than query pagination
});
scenes.value = res.scenes;
aggYears.value = res.aggYears;
aggActors.value = res.aggActors;
aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels;
total.value = res.total;
loading.value = false;
// events.emit('scrollUp');
scenesContainer.value?.scrollIntoView(true);
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
function setView(newView) {
view.value = newView;
Cookies.set('scenesView', newView);
}
</script>
<template> <template>
<div <div
class="scenes-page" class="scenes-page"
@@ -12,12 +204,19 @@
<!-- onsearch not compatible with FF and Safari --> <!-- onsearch not compatible with FF and Safari -->
<input <input
v-model="filters.search" v-model="filters.search"
type="search" type="input"
placeholder="Search scenes" placeholder="Search scenes"
class="search input" class="search input"
@search="search" @keydown.enter="search"
> >
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon <Icon
icon="search" icon="search"
class="search-button" class="search-button"
@@ -200,195 +399,6 @@
</div> </div>
</template> </template>
<script setup>
import {
ref,
computed,
inject,
} from 'vue';
import Cookies from 'js-cookie';
import { parse } from 'path-to-regexp';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
// import events from '#/src/events.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
import Filters from '#/components/filters/filters.vue';
import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import SceneTile from '#/components/scenes/tile.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
const props = defineProps({
showFilters: {
type: Boolean,
default: true,
},
showMeta: {
type: Boolean,
default: true,
},
showScopeTabs: {
type: Boolean,
default: false,
},
defaultScope: {
type: String,
default: 'latest',
},
});
const {
pageProps,
routeParams,
urlParsed,
campaigns,
env,
} = inject('pageContext');
const {
actor: pageActor,
tag: pageTag,
entity: pageEntity,
stash: pageStash,
} = pageProps;
const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page));
const scope = ref(routeParams.scope || props.defaultScope);
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
const loading = ref(false);
const scenesContainer = ref(null);
const view = ref(env.scenesView || 'grid');
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
const filters = ref({
search: urlParsed.search.q,
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
entity: queryEntity,
actors: queryActors,
});
const sceneCampaign = campaigns?.scenes;
const campaignIndex = campaigns?.index;
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
function getPath(targetScope, preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'scope') {
return `${segment.prefix}${targetScope}`;
}
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (filters.value.search) {
scope.value = 'results';
}
if (!filters.value.search && scope.value === 'results') {
scope.value = 'latest';
}
}
const query = {
q: filters.value.search || undefined,
};
const entity = filters.value.entity || pageEntity;
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
loading.value = true;
navigate(getPath(scope.value, false), {
...query,
years: filters.value.years.join(',') || undefined,
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
tags: filters.value.tags.join(',') || undefined,
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
}, { redirect: false });
const res = await get('/scenes', {
...query,
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
stashId: pageStash?.id,
e: entitySlug,
scope: scope.value,
page: currentPage.value, // client uses param rather than query pagination
});
scenes.value = res.scenes;
aggYears.value = res.aggYears;
aggActors.value = res.aggActors;
aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels;
total.value = res.total;
loading.value = false;
// events.emit('scrollUp');
scenesContainer.value?.scrollIntoView(true);
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
function setView(newView) {
view.value = newView;
Cookies.set('scenesView', newView);
}
</script>
<style scoped> <style scoped>
.scenes-page { .scenes-page {
display: flex; display: flex;

View File

@@ -1,3 +1,158 @@
<script setup>
import Cookies from 'js-cookie';
import { inject, onMounted, ref } from 'vue';
import { parse } from 'yaml';
import defaultTemplate from '#/assets/summary.yaml?raw';
import { del, get, post } from '#/src/api.js';
// import slugify from '#/utils/slugify.js';
import events from '#/src/events.js';
import processSummaryTemplate from '#/utils/process-summary-template.js';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const cookies = Cookies.withConverter({
write: (value) => value,
});
const templates = ref(pageContext.assets.templates);
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
const template = ref(initialTemplate?.template || defaultTemplate);
const hasError = ref(false);
const input = ref(null);
const changed = ref(false);
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
function getSummary() {
return processSummaryTemplate(template.value, props.release, pageContext.env);
}
const summary = ref(getSummary());
function selectTemplate(templateId) {
selectedTemplate.value = templateId;
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
template.value = nextTemplate.template;
templateName.value = nextTemplate.name;
summary.value = getSummary();
cookies.set('selectedTemplate', String(templateId));
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
}
function update() {
hasError.value = false;
changed.value = true;
try {
summary.value = getSummary();
}
catch (error) {
hasError.value = true;
}
}
async function save(explicit) {
if (!explicit && !changed.value) {
return;
}
try {
parse(template.value);
const createdTemplate = await post('/templates', {
name: templateName.value,
template: template.value,
}, {
successFeedback: `Saved summary template '${templateName.value}'`,
errorFeedback: `Failed to save summary template '${templateName.value}'`,
});
changed.value = false;
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(createdTemplate.id);
}
catch (error) {
events.emit('feedback', {
type: 'error',
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
});
}
}
function add() {
selectedTemplate.value = null;
template.value = '';
templateName.value = `custom_${Date.now()}`;
summary.value = '';
input.value.focus();
}
async function remove() {
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-alert
await del(`/templates/${selectedTemplate.value}`, {
undoFeedback: `Deleted summary template '${templateName.value}'`,
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
});
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(templates.value.at(-1)?.id);
}
}
function copy() {
navigator.clipboard.writeText(summary.value);
events.emit('feedback', {
type: 'success',
message: 'Summary copied to clipboard',
});
}
function reset() {
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-alert
template.value = defaultTemplate;
update();
events.emit('feedback', {
type: 'undo',
message: 'Reset summary template',
});
}
}
function indent() {
// YAML does not support tabs
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
}
onMounted(() => {
window.addEventListener('beforeunload', (event) => {
if (changed.value) {
event.preventDefault();
}
});
});
</script>
<template> <template>
<div class="editor"> <div class="editor">
<div class="editor-header"> <div class="editor-header">
@@ -89,159 +244,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject, onMounted } from 'vue';
import { parse } from 'yaml';
import Cookies from 'js-cookie';
// import slugify from '#/utils/slugify.js';
import events from '#/src/events.js';
import { get, post, del } from '#/src/api.js';
import processSummaryTemplate from '#/utils/process-summary-template.js';
import defaultTemplate from '#/assets/summary.yaml?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext');
const cookies = Cookies.withConverter({
write: (value) => value,
});
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const templates = ref(pageContext.assets.templates);
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
const template = ref(initialTemplate?.template || defaultTemplate);
const hasError = ref(false);
const input = ref(null);
const changed = ref(false);
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
function getSummary() {
return processSummaryTemplate(template.value, props.release, pageContext.env);
}
const summary = ref(getSummary());
function selectTemplate(templateId) {
selectedTemplate.value = templateId;
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
template.value = nextTemplate.template;
templateName.value = nextTemplate.name;
summary.value = getSummary();
cookies.set('selectedTemplate', String(templateId));
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
}
function update() {
hasError.value = false;
changed.value = true;
try {
summary.value = getSummary();
} catch (error) {
hasError.value = true;
}
}
async function save(explicit) {
if (!explicit && !changed.value) {
return;
}
try {
parse(template.value);
const createdTemplate = await post('/templates', {
name: templateName.value,
template: template.value,
}, {
successFeedback: `Saved summary template '${templateName.value}'`,
errorFeedback: `Failed to save summary template '${templateName.value}'`,
});
changed.value = false;
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(createdTemplate.id);
} catch (error) {
events.emit('feedback', {
type: 'error',
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
});
}
}
function add() {
selectedTemplate.value = null;
template.value = '';
templateName.value = `custom_${Date.now()}`;
summary.value = '';
input.value.focus();
}
async function remove() {
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-restricted-globals, no-alert
await del(`/templates/${selectedTemplate.value}`, {
undoFeedback: `Deleted summary template '${templateName.value}'`,
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
});
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(templates.value.at(-1)?.id);
}
}
function copy() {
navigator.clipboard.writeText(summary.value);
events.emit('feedback', {
type: 'success',
message: 'Summary copied to clipboard',
});
}
function reset() {
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-restricted-globals, no-alert
template.value = defaultTemplate;
update();
events.emit('feedback', {
type: 'undo',
message: 'Reset summary template',
});
}
}
function indent() {
// YAML does not support tabs
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
}
onMounted(() => {
window.addEventListener('beforeunload', (event) => {
if (changed.value) {
event.preventDefault();
}
});
});
</script>
<style scoped> <style scoped>
.editor { .editor {
display: flex; display: flex;

View File

@@ -1,3 +1,30 @@
<script setup>
import { inject, ref } from 'vue';
import Meta from '#/components/scenes/meta.vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
scene: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
</script>
<template> <template>
<div <div
class="tile" class="tile"
@@ -115,33 +142,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import Meta from '#/components/scenes/meta.vue';
import Heart from '#/components/stashes/heart.vue';
const props = defineProps({
scene: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
</script>
<style scoped> <style scoped>
.tile { .tile {
display: flex; display: flex;

View File

@@ -1,3 +1,14 @@
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
serie: {
type: Object,
default: null,
},
});
</script>
<template> <template>
<div class="serie-tile"> <div class="serie-tile">
<a <a
@@ -22,17 +33,6 @@
</div> </div>
</template> </template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
serie: {
type: Object,
default: null,
},
});
</script>
<style scoped> <style scoped>
.serie-tile { .serie-tile {
display: flex; display: flex;

View File

@@ -1,3 +1,42 @@
<script setup>
import Cookies from 'js-cookie';
import { ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
const cookies = Cookies.withConverter({
write: (value) => value,
});
const tags = {
'anal': 'anal',
'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
'pissing': 'pissing',
'gay': 'gay',
'transsexual': 'transsexual',
'bisexual': 'bisexual',
'compilation': 'compilation',
'bts': 'behind the scenes',
'vr': 'virtual reality',
};
const storedTags = cookies.get('tags');
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
function toggleTag(tag, isChecked) {
if (isChecked) {
checkedTags.value.add(tag);
}
else {
checkedTags.value.delete(tag);
}
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
}
</script>
<template> <template>
<Dialog title="Settings"> <Dialog title="Settings">
<div class="dialog-body"> <div class="dialog-body">
@@ -27,44 +66,6 @@
</Dialog> </Dialog>
</template> </template>
<script setup>
import { ref } from 'vue';
import Cookies from 'js-cookie';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
const cookies = Cookies.withConverter({
write: (value) => value,
});
const tags = {
anal: 'anal',
'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
pissing: 'pissing',
gay: 'gay',
transsexual: 'transsexual',
bisexual: 'bisexual',
compilation: 'compilation',
bts: 'behind the scenes',
vr: 'virtual reality',
};
const storedTags = cookies.get('tags');
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
function toggleTag(tag, isChecked) {
if (isChecked) {
checkedTags.value.add(tag);
} else {
checkedTags.value.delete(tag);
}
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
}
</script>
<style scoped> <style scoped>
.dialog-body { .dialog-body {
width: 30rem; width: 30rem;

View File

@@ -1,3 +1,14 @@
<script setup>
import { computed, inject } from 'vue';
import logo from '#/assets/img/logo.svg?raw';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<template> <template>
<div <div
class="sidebar-container" class="sidebar-container"
@@ -85,17 +96,6 @@
</div> </div>
</template> </template>
<script setup>
import { computed, inject } from 'vue';
import logo from '#/assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<style scoped> <style scoped>
.sidebar-container { .sidebar-container {
width: 100%; width: 100%;

View File

@@ -1,3 +1,30 @@
<script setup>
import { ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import { post } from '#/src/api.js';
const emit = defineEmits(['created', 'close']);
const stashName = ref(null);
const stashNameInput = ref(null);
async function createStash() {
const newStash = await post('/stashes', {
name: stashName.value,
public: false,
}, {
successFeedback: `Stash '${stashName.value}' created`,
errorFeedback: `Failed to create stash '${stashName.value}'`,
appendErrorMessage: true,
});
emit('created', newStash);
stashName.value = null;
}
</script>
<template> <template>
<Dialog <Dialog
title="New stash" title="New stash"
@@ -23,33 +50,6 @@
</Dialog> </Dialog>
</template> </template>
<script setup>
import { ref } from 'vue';
import { post } from '#/src/api.js';
import Dialog from '#/components/dialog/dialog.vue';
const emit = defineEmits(['created', 'close']);
const stashName = ref(null);
const stashNameInput = ref(null);
async function createStash() {
const newStash = await post('/stashes', {
name: stashName.value,
public: false,
}, {
successFeedback: `Stash '${stashName.value}' created`,
errorFeedback: `Failed to create stash '${stashName.value}'`,
appendErrorMessage: true,
});
emit('created', newStash);
stashName.value = null;
}
</script>
<style scoped> <style scoped>
.dialog-body { .dialog-body {
padding: 1rem; padding: 1rem;

View File

@@ -1,3 +1,204 @@
<script setup>
import { computed, inject, ref, useId } from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import Icon from '#/components/icon/icon.vue';
import StashDialog from '#/components/stashes/create.vue';
import StashMenu from '#/components/stashes/menu.vue';
import { del, get, post } from '#/src/api.js';
import ellipsis from '#/utils/ellipsis.js';
const props = defineProps({
domain: {
type: String,
default: 'scenes',
},
item: {
type: Object,
default: null,
},
showSecondary: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['stashed', 'unstashed']);
const pageContext = inject('pageContext');
const pageStash = pageContext.pageProps.stash;
const user = pageContext.user;
const stashes = ref(pageContext.assets?.stashes);
const primaryStash = pageContext.assets?.primaryStash;
const itemStashes = ref(props.item.stashes);
const itemAlerts = ref(props.item.alerts);
const itemAlerted = ref(props.item.alerts?.only.length > 0);
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
const done = ref(true);
const stashMenuDropdownId = useId();
const stashTogglesDropdownId = useId();
const showStashes = ref(false);
const showStashDialog = ref(false);
const showAlertDialog = ref(false);
const feedbackCutoff = 20;
const itemKeys = {
scenes: 'sceneId',
actors: 'actorId',
movies: 'movieId',
};
async function stashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.concat(stash);
try {
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
});
emit('stashed', stash);
}
catch (error) {
itemStashes.value = stashState;
}
finally {
done.value = true;
}
}
async function unstashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
try {
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
});
emit('unstashed', stash);
}
catch (error) {
itemStashes.value = stashState;
}
finally {
done.value = true;
}
}
async function alertItem() {
if (!done.value) {
return;
}
const alertLabel = props.item.title || props.item.name;
done.value = false;
itemAlerted.value = true;
try {
const newAlert = await post('/alerts', {
...(props.domain === 'actors' && { actors: [props.item.id] }),
...(props.domain === 'tags' && { tags: [props.item.id] }),
...(props.domain === 'entities' && { entities: [props.item.id] }),
notify: true,
email: false,
preset: true,
}, {
successFeedback: `Alert set for '${alertLabel}'`,
errorFeedback: `Failed to set alert for '${alertLabel}'`,
appendErrorMessage: true,
});
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
}
catch (error) {
itemAlerted.value = false;
}
finally {
done.value = true;
}
}
async function removeAlert() {
if (done.value === false) {
return;
}
const alertLabel = props.item.title || props.item.name;
const alertIds = itemAlerts.value.only;
done.value = false;
itemAlerted.value = false;
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
try {
await del(`/alerts/${alertIds.join(',')}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
}
catch (error) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
}
finally {
done.value = true;
}
}
function setAlerted(alert) {
// only set alerted status if the current item is the only one in the new stash
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
}
else {
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
}
}
function toggleShowStashes(state) {
if (props.showSecondary) {
return;
}
if (state) {
showStashes.value = state;
return;
}
showStashes.value = !showStashes.value;
}
async function reloadStashes(newStash) {
stashes.value = await get(`/users/${user.id}/stashes`);
await stashItem(newStash);
}
</script>
<template> <template>
<div <div
v-if="user" v-if="user"
@@ -37,6 +238,7 @@
<VDropdown <VDropdown
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))" v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes" :shown="showStashes"
@hide="showStashes = false" @hide="showStashes = false"
> >
@@ -60,6 +262,7 @@
<VDropdown <VDropdown
v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)" v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)"
:aria-id="stashTogglesDropdownId"
:triggers="[]" :triggers="[]"
:shown="showStashes" :shown="showStashes"
:disabled="showSecondary" :disabled="showSecondary"
@@ -70,7 +273,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)" v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)"
:icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'" :icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'"
class="heart favorited noselect" class="heart favorited noselect"
@click.native.stop="unstashItem(pageStash)" @click.stop="unstashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
@@ -78,7 +281,7 @@
v-else v-else
:icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'" :icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'"
class="heart noselect" class="heart noselect"
@click.native.stop="stashItem(pageStash)" @click.stop="stashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
</template> </template>
@@ -88,7 +291,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)" v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)"
icon="heart7" icon="heart7"
class="heart favorited noselect" class="heart favorited noselect"
@click.native.stop="unstashItem(primaryStash)" @click.stop="unstashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
@@ -96,7 +299,7 @@
v-else v-else
icon="heart8" icon="heart8"
class="heart noselect" class="heart noselect"
@click.native.stop="stashItem(primaryStash)" @click.stop="stashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
</template> </template>
@@ -127,196 +330,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import { get, post, del } from '#/src/api.js';
import ellipsis from '#/utils/ellipsis.js';
import Icon from '#/components/icon/icon.vue';
import StashMenu from '#/components/stashes/menu.vue';
import StashDialog from '#/components/stashes/create.vue';
import AlertDialog from '#/components/alerts/create.vue';
const props = defineProps({
domain: {
type: String,
default: 'scenes',
},
item: {
type: Object,
default: null,
},
showSecondary: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['stashed', 'unstashed']);
const pageContext = inject('pageContext');
const pageStash = pageContext.pageProps.stash;
const user = pageContext.user;
const stashes = ref(pageContext.assets?.stashes);
const primaryStash = pageContext.assets?.primaryStash;
const itemStashes = ref(props.item.stashes);
const itemAlerts = ref(props.item.alerts);
const itemAlerted = ref(props.item.alerts?.only.length > 0);
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
const done = ref(true);
const showStashes = ref(false);
const showStashDialog = ref(false);
const showAlertDialog = ref(false);
const feedbackCutoff = 20;
const itemKeys = {
scenes: 'sceneId',
actors: 'actorId',
movies: 'movieId',
};
async function stashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.concat(stash);
try {
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
});
emit('stashed', stash);
} catch (error) {
itemStashes.value = stashState;
} finally {
done.value = true;
}
}
async function unstashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
try {
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
});
emit('unstashed', stash);
} catch (error) {
itemStashes.value = stashState;
} finally {
done.value = true;
}
}
async function alertItem() {
if (!done.value) {
return;
}
const alertLabel = props.item.title || props.item.name;
done.value = false;
itemAlerted.value = true;
try {
const newAlert = await post('/alerts', {
...(props.domain === 'actors' && { actors: [props.item.id] }),
...(props.domain === 'tags' && { tags: [props.item.id] }),
...(props.domain === 'entities' && { entities: [props.item.id] }),
notify: true,
email: false,
preset: true,
}, {
successFeedback: `Alert set for '${alertLabel}'`,
errorFeedback: `Failed to set alert for '${alertLabel}'`,
appendErrorMessage: true,
});
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
} catch (error) {
itemAlerted.value = false;
} finally {
done.value = true;
}
}
async function removeAlert() {
if (done.value === false) {
return;
}
const alertLabel = props.item.title || props.item.name;
const alertIds = itemAlerts.value.only;
done.value = false;
itemAlerted.value = false;
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
try {
await del(`/alerts/${alertIds.join(',')}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
} catch (error) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
} finally {
done.value = true;
}
}
function setAlerted(alert) {
// only set alerted status if the current item is the only one in the new stash
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
} else {
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
}
}
function toggleShowStashes(state) {
if (props.showSecondary) {
return;
}
if (state) {
showStashes.value = state;
return;
}
showStashes.value = !showStashes.value;
}
async function reloadStashes(newStash) {
stashes.value = await get(`/users/${user.id}/stashes`);
await stashItem(newStash);
}
</script>
<style scoped> <style scoped>
.bookmarks { .bookmarks {
display: flex; display: flex;

View File

@@ -1,3 +1,20 @@
<script setup>
import Checkbox from '#/components/form/checkbox.vue';
defineProps({
stashes: {
type: Array,
default: () => [],
},
itemStashes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['stash', 'unstash', 'create']);
</script>
<template> <template>
<ul class="stash-menu nolist noselect"> <ul class="stash-menu nolist noselect">
<li <li
@@ -26,23 +43,6 @@
</ul> </ul>
</template> </template>
<script setup>
import Checkbox from '#/components/form/checkbox.vue';
defineProps({
stashes: {
type: Array,
default: () => [],
},
itemStashes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['stash', 'unstash', 'create']);
</script>
<style scoped> <style scoped>
.menu-item { .menu-item {
display: flex; display: flex;

View File

@@ -1,3 +1,15 @@
<script setup>
import { inject } from 'vue';
import Domains from '#/components/domains/domains.vue';
const pageContext = inject('pageContext');
const { routeParams } = pageContext;
const domain = routeParams.domain;
const stash = pageContext.pageProps.stash;
</script>
<template> <template>
<div class="stash"> <div class="stash">
<div class="header"> <div class="header">
@@ -44,18 +56,6 @@
</div> </div>
</template> </template>
<script setup>
import { inject } from 'vue';
import Domains from '#/components/domains/domains.vue';
const pageContext = inject('pageContext');
const { routeParams } = pageContext;
const domain = routeParams.domain;
const stash = pageContext.pageProps.stash;
</script>
<style scoped> <style scoped>
.stash { .stash {
display: flex; display: flex;

View File

@@ -1,3 +1,25 @@
<script setup>
import { inject, ref } from 'vue';
import StashDialog from '#/components/stashes/create.vue';
import StashTile from '#/components/stashes/tile.vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashes = ref(pageContext.pageProps.stashes);
const profile = ref(pageContext.pageProps.profile);
const showStashDialog = ref(false);
async function reloadStashes() {
// profile.value = await get(`/users/${profile.value.id}`);
stashes.value = await get(`/users/${profile.value.id}/stashes`);
}
</script>
<template> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -34,28 +56,6 @@
</section> </section>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import StashTile from '#/components/stashes/tile.vue';
import StashDialog from '#/components/stashes/create.vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashes = ref(pageContext.pageProps.stashes);
const profile = ref(pageContext.pageProps.profile);
const showStashDialog = ref(false);
async function reloadStashes() {
// profile.value = await get(`/users/${profile.value.id}`);
stashes.value = await get(`/users/${profile.value.id}/stashes`);
}
</script>
<style scoped> <style scoped>
.stashes { .stashes {
display: grid; display: grid;

View File

@@ -1,3 +1,98 @@
<script setup>
import { inject, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import { del, patch } from '#/src/api.js';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
stash: {
type: Object,
default: null,
},
profile: {
type: Object,
default: null,
},
});
const emit = defineEmits(['reload']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashName = ref(props.stash.name);
const stashNameInput = ref(null);
const showRenameDialog = ref(false);
const done = ref(true);
const privateTooltipId = useId();
const menuDropdownId = useId();
const domainCounts = {
scenes: props.stash.stashedScenes,
actors: props.stash.stashedActors,
movies: props.stash.stashedMovies,
};
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
async function setPublic(isPublic) {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
errorFeedback: 'Failed to update stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function remove() {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-alert
return;
}
done.value = false;
await del(`/stashes/${props.stash.id}`, {
undoFeedback: `Stash '${props.stash.name}' removed`,
errorFeedback: 'Failed to remove stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function rename() {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
successFeedback: `Stash renamed to ${stashName.value}`,
errorFeedback: 'Failed to rename stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
showRenameDialog.value = false;
}
</script>
<template> <template>
<div class="stash"> <div class="stash">
<div class="stash-header"> <div class="stash-header">
@@ -16,12 +111,12 @@
<Icon <Icon
v-if="!stash.isPublic" v-if="!stash.isPublic"
v-tooltip="'This stash is private'" v-tooltip="{ content: 'This stash is private', ariaId: privateTooltipId }"
icon="eye-blocked" icon="eye-blocked"
class="private noselect" class="private noselect"
/> />
<VDropdown> <VDropdown :aria-id="menuDropdownId">
<Icon <Icon
v-if="profile.id === user?.id" v-if="profile.id === user?.id"
icon="more2" icon="more2"
@@ -122,99 +217,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { del, patch } from '#/src/api.js';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
import Dialog from '#/components/dialog/dialog.vue';
const props = defineProps({
stash: {
type: Object,
default: null,
},
profile: {
type: Object,
default: null,
},
});
const emit = defineEmits(['reload']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashName = ref(props.stash.name);
const stashNameInput = ref(null);
const showRenameDialog = ref(false);
const done = ref(true);
const domainCounts = {
scenes: props.stash.stashedScenes,
actors: props.stash.stashedActors,
movies: props.stash.stashedMovies,
};
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
async function setPublic(isPublic) {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
errorFeedback: 'Failed to update stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function remove() {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-restricted-globals, no-alert
return;
}
done.value = false;
await del(`/stashes/${props.stash.id}`, {
undoFeedback: `Stash '${props.stash.name}' removed`,
errorFeedback: 'Failed to remove stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function rename() {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
successFeedback: `Stash renamed to ${stashName.value}`,
errorFeedback: 'Failed to rename stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
showRenameDialog.value = false;
}
</script>
<style scoped> <style scoped>
.stash { .stash {
width: 100%; width: 100%;

View File

@@ -1,7 +1,24 @@
<script setup>
import { useId } from 'vue';
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
const entityTooltipId = useId();
</script>
<template> <template>
<a <a
v-if="photo?.entity" v-if="photo?.entity"
v-tooltip="photo.entity.name" v-tooltip="{ content: photo.entity.name, ariaId: entityTooltipId }"
:to="`/${photo.entity.type}/${photo.entity.slug}`" :to="`/${photo.entity.type}/${photo.entity.slug}`"
> >
<img <img
@@ -30,19 +47,6 @@
</a> </a>
</template> </template>
<script setup>
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
</script>
<style scoped> <style scoped>
.album-logo { .album-logo {
max-height: .75rem; max-height: .75rem;

View File

@@ -1,3 +1,41 @@
<script setup>
import { computed, inject } from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
import Logo from '#/components/tags/logo.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
tag: {
type: Object,
default: null,
},
});
const emit = defineEmits(['load', 'campaign']);
const { campaigns } = inject('pageContext');
const photos = computed(() => {
/* sfw not currently implemented
if (props.tag.poster && this.$store.state.ui.sfw) {
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return props.tag.photos.map((photo) => photo.sfw);
}
*/
if (props.tag.poster) {
return [props.tag.poster].concat(props.tag.photos);
}
return props.tag.photos;
});
</script>
<template> <template>
<div class="photos nobar"> <div class="photos nobar">
<Campaign <Campaign
@@ -40,44 +78,6 @@
</div> </div>
</template> </template>
<script setup>
import { computed, inject } from 'vue';
import Logo from '#/components/tags/logo.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
tag: {
type: Object,
default: null,
},
});
const emit = defineEmits(['load', 'campaign']);
const { campaigns } = inject('pageContext');
const photos = computed(() => {
/* sfw not currently implemented
if (props.tag.poster && this.$store.state.ui.sfw) {
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return props.tag.photos.map((photo) => photo.sfw);
}
*/
if (props.tag.poster) {
return [props.tag.poster].concat(props.tag.photos);
}
return props.tag.photos;
});
</script>
<style scoped> <style scoped>
.photos { .photos {
display: flex; display: flex;

View File

@@ -1,5 +1,50 @@
<script setup>
import { inject, ref, useId } from 'vue';
import { get } from '#/src/api.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['tag']);
const dropdownId = useId();
const pageContext = inject('pageContext');
const tagNames = {
dp: 'double penetration',
};
const defaultTags = pageContext.pageProps.tagIds
? Object.entries(pageContext.pageProps.tagIds).map(([slug, id]) => ({
id,
slug,
name: tagNames[slug] || slug,
}))
: [];
const tags = ref(defaultTags);
const query = ref(null);
const queryInput = ref(null);
async function search() {
tags.value = await get('/tags', { query: query.value });
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -30,49 +75,6 @@
</VDropdown> </VDropdown>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const tagNames = {
dp: 'double penetration',
};
const defaultTags = pageContext.pageProps.tagIds
? Object.entries(pageContext.pageProps.tagIds).map(([slug, id]) => ({
id,
slug,
name: tagNames[slug] || slug,
}))
: [];
const tags = ref(defaultTags);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['tag']);
async function search() {
tags.value = await get('/tags', { query: query.value });
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped> <style scoped>
.trigger { .trigger {
height: 100%; height: 100%;

View File

@@ -1,49 +1,22 @@
<template>
<div class="tooltip-container">
<div
ref="handle"
class="tooltip-handle"
@click.stop="toggle"
>
<slot />
</div>
<div
v-show="opened"
class="tooltip-layer"
>
<div
ref="tooltip"
class="tooltip-wrapper"
:style="{ transform: `translate(${position.x}px, ${position.y}px)` }"
>
<div
class="tooltip"
@click="close"
>
<slot
name="tooltip"
@click.stop
/>
</div>
</div>
</div>
</div>
</template>
<script setup> <script setup>
import {
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue';
// import { useFloating } from '@floating-ui/vue'; // import { useFloating } from '@floating-ui/vue';
import { computePosition, shift } from '@floating-ui/dom'; import { computePosition, shift } from '@floating-ui/dom';
import {
nextTick,
onBeforeUnmount,
onMounted,
ref,
} from 'vue';
import events from '#/src/events.js'; import events from '#/src/events.js';
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
const handle = ref(null); const handle = ref(null);
const tooltip = ref(null); const tooltip = ref(null);
const opened = ref(false); const opened = ref(false);
@@ -53,13 +26,6 @@ const position = ref({
y: 0, y: 0,
}); });
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
async function open() { async function open() {
events.emit('blur'); events.emit('blur');
opened.value = true; opened.value = true;
@@ -97,7 +63,8 @@ function close() {
function toggle() { function toggle() {
if (opened.value) { if (opened.value) {
close(); close();
} else { }
else {
open(); open();
} }
} }
@@ -111,6 +78,39 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<template>
<div class="tooltip-container">
<div
ref="handle"
class="tooltip-handle"
@click.stop="toggle"
>
<slot />
</div>
<div
v-show="opened"
class="tooltip-layer"
>
<div
ref="tooltip"
class="tooltip-wrapper"
:style="{ transform: `translate(${position.x}px, ${position.y}px)` }"
>
<div
class="tooltip"
@click="close"
>
<slot
name="tooltip"
@click.stop
/>
</div>
</div>
</div>
</div>
</template>
<style> <style>
.tooltip-layer { .tooltip-layer {
position: absolute; position: absolute;

View File

@@ -1,3 +1,57 @@
<script setup>
import { format, formatDistanceStrict } from 'date-fns';
import { inject, ref, useId } from 'vue';
import { del, get, post } from '#/src/api.js';
import events from '#/src/events.js';
const pageContext = inject('pageContext');
const now = pageContext.meta.now;
const user = pageContext.user;
const keys = ref(pageContext.pageProps.keys);
const newKey = ref(null);
const flushTooltipId = useId();
async function createKey() {
const key = await post('/me/keys', null, {
appendErrorMessage: true,
});
newKey.value = key;
keys.value = await get('/me/keys');
}
async function removeKey(key) {
if (confirm(`Are you sure you want to remove API key '${key.identifier}'? It can not be restored.`)) { // eslint-disable-line no-alert
newKey.value = null;
await del(`/me/keys/${key.identifier}`);
keys.value = await get('/me/keys');
}
}
async function flushKeys() {
if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-alert
newKey.value = null;
await del('/me/keys');
keys.value = [];
}
}
function copyKey(event) {
event.target.select();
navigator.clipboard.writeText(newKey.value.key);
events.emit('feedback', {
type: 'success',
message: 'Key copied to clipboard',
});
}
</script>
<template> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -5,7 +59,7 @@
<div class="keys-actions"> <div class="keys-actions">
<Icon <Icon
v-tooltip="'Flush all keys'" v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
icon="stack-cancel" icon="stack-cancel"
class="keys-flush" class="keys-flush"
@click="flushKeys" @click="flushKeys"
@@ -63,7 +117,7 @@
<Icon icon="plus-circle" /> <Icon icon="plus-circle" />
<time <time
v-tooltip="`Created ${format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')}`" v-tooltip="{ content: `Created ${format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')}`, ariaId: `key-created-${key.id}` }"
:datetime="key.createdAt.toISOString()" :datetime="key.createdAt.toISOString()"
>{{ formatDistanceStrict(key.createdAt, now) }} ago</time> >{{ formatDistanceStrict(key.createdAt, now) }} ago</time>
</span> </span>
@@ -73,7 +127,7 @@
<template v-if="key.lastUsedAt"> <template v-if="key.lastUsedAt">
<time <time
v-tooltip="`Last used ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')} from IP ${key.lastUsedIp}`" v-tooltip="{ content: `Last used ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')} from IP ${key.lastUsedIp}`, ariaId: `key-used-${key.id}` }"
:datetime="key.lastUsedAt.toISOString()" :datetime="key.lastUsedAt.toISOString()"
>{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time> >{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time>
</template> </template>
@@ -99,59 +153,6 @@
</section> </section>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { format, formatDistanceStrict } from 'date-fns';
import { get, post, del } from '#/src/api.js';
import events from '#/src/events.js';
const pageContext = inject('pageContext');
const now = pageContext.meta.now;
const user = pageContext.user;
const keys = ref(pageContext.pageProps.keys);
const newKey = ref(null);
async function createKey() {
const key = await post('/me/keys', null, {
appendErrorMessage: true,
});
newKey.value = key;
keys.value = await get('/me/keys');
}
async function removeKey(key) {
if (confirm(`Are you sure you want to remove API key '${key.identifier}'? It can not be restored.`)) { // eslint-disable-line no-restricted-globals, no-alert
newKey.value = null;
await del(`/me/keys/${key.identifier}`);
keys.value = await get('/me/keys');
}
}
async function flushKeys() {
if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-restricted-globals, no-alert
newKey.value = null;
await del('/me/keys');
keys.value = [];
}
}
function copyKey(event) {
event.target.select();
navigator.clipboard.writeText(newKey.value.key);
events.emit('feedback', {
type: 'success',
message: 'Key copied to clipboard',
});
}
</script>
<style scoped> <style scoped>
.keys-header { .keys-header {
display: flex; display: flex;

View File

@@ -1,16 +1,6 @@
<template>
<video
ref="player"
:poster="poster"
class="player video-js vjs-big-play-centered"
@playing="emit('play')"
@pause="emit('pause')"
/>
</template>
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue';
import videoJs from 'video.js'; import videoJs from 'video.js';
import { onMounted, ref, watch } from 'vue';
// import 'videojs-vr/dist/videojs-vr.min'; // import 'videojs-vr/dist/videojs-vr.min';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
@@ -66,6 +56,16 @@ watch(() => props.video, () => updatePlayer());
watch(() => props.poster, () => updatePlayer()); watch(() => props.poster, () => updatePlayer());
</script> </script>
<template>
<video
ref="player"
:poster="poster"
class="player video-js vjs-big-play-centered"
@playing="emit('play')"
@pause="emit('pause')"
/>
</template>
<style> <style>
.player.video-js { .player.video-js {
.vjs-poster { .vjs-poster {

View File

@@ -147,7 +147,7 @@ module.exports = {
login: true, login: true,
signup: true, signup: true,
usernameLength: [2, 24], usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/, usernamePattern: /^[\w-]+$/,
captcha: { captcha: {
enabled: false, enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001', siteKey: '10000000-ffff-ffff-ffff-000000000001',
@@ -178,7 +178,7 @@ module.exports = {
}, },
stashes: { stashes: {
nameLength: [2, 24], nameLength: [2, 24],
namePattern: /^[a-zA-Z0-9!?$&\s_-]+$/, namePattern: /^[\w!?$&\s-]+$/,
viewRefreshCrontab: '* * * * *', // every minute viewRefreshCrontab: '* * * * *', // every minute
viewRefreshCooldowns: { viewRefreshCooldowns: {
actors: 60, // minutes actors: 60, // minutes

50
eslint.config.js Normal file
View File

@@ -0,0 +1,50 @@
import antfu from '@antfu/eslint-config';
export default antfu(
{
ignores: [
'common/**',
'static/**',
],
vue: true,
// jsonc/yaml/toml stay off: assets/*.yaml are data files, not source, and weren't linted before.
jsonc: false,
yaml: false,
toml: false,
stylistic: {
indent: 'tab',
quotes: 'single',
semi: true,
},
},
{
languageOptions: {
globals: {
CONFIG: 'readonly',
},
},
rules: {
'default-param-last': 'off',
'no-underscore-dangle': 'off',
'no-console': 'off',
'style/arrow-parens': ['error', 'always'],
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
}],
'prefer-destructuring': 'off',
'import/prefer-default-export': 'off',
'unused-imports/no-unused-vars': ['error', {
args: 'after-used',
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
caughtErrors: 'none',
}],
'vue/multi-word-component-names': 'off',
'vue/no-reserved-component-names': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
},
},
);

28745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,96 +4,96 @@
"prod": "npm run build && npm run server:prod", "prod": "npm run build && npm run server:prod",
"build": "vite build", "build": "vite build",
"server:dev": "node ./src/app", "server:dev": "node ./src/app",
"server:prod": "cross-env NODE_ENV=production node ./src/app" "server:prod": "cross-env NODE_ENV=production node ./src/app",
"lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.25",
"@dicebear/collection": "^7.0.5", "@dicebear/collection": "7.0.5",
"@dicebear/core": "^7.0.5", "@dicebear/core": "7.0.5",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^10.5.0",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.7.6",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^2.0.0",
"@hcaptcha/vue3-hcaptcha": "^1.3.0", "@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@maxmind/geoip2-node": "^6.3.4", "@maxmind/geoip2-node": "^7.0.0",
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.2",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^6.0.7",
"@vue/compiler-sfc": "^3.3.10", "@vue/compiler-sfc": "^3.5.39",
"@vue/server-renderer": "^3.3.10", "@vue/server-renderer": "^3.5.39",
"@vueuse/components": "^10.7.1", "@vueuse/components": "^14.3.0",
"@vueuse/core": "^10.7.1", "@vueuse/core": "^14.3.0",
"compression": "^1.7.4", "compression": "^1.8.1",
"config": "^3.3.9", "config": "^4.4.2",
"connect-redis": "^7.1.1", "connect-redis": "^9.0.0",
"convert": "^4.14.1", "convert": "^7.0.0",
"cookie": "^0.6.0", "cookie": "^2.0.1",
"cron": "^3.1.6", "cron": "^4.4.0",
"cross-env": "^7.0.3", "cross-env": "^10.1.0",
"date-fns": "^3.0.0", "date-fns": "^4.4.0",
"ejs": "^4.0.1", "ejs": "^6.0.1",
"error-stack-parser": "^2.1.4", "error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"express": "^4.18.2", "express": "^5.2.1",
"express-promise-router": "^4.1.1", "express-session": "^1.19.0",
"express-query-boolean": "^2.0.0",
"express-session": "^1.18.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"graphql": "^16.9.0", "graphql": "^16.14.2",
"graphql-parse-resolve-info": "^4.13.0", "graphql-parse-resolve-info": "^4.14.1",
"graphql-scalars": "^1.24.2", "graphql-scalars": "^1.25.0",
"hcaptcha": "^0.2.0", "hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0", "ip-cidr": "^4.0.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.8",
"knex": "^3.1.0", "knex": "^3.3.0",
"manticoresearch": "^4.0.0", "manticoresearch": "^10.0.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.3.0",
"mathjs": "^12.2.1", "mathjs": "^15.2.0",
"merkle-json": "^2.6.0", "merkle-json": "^2.15.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nanoid": "^5.0.4", "nanoid": "^5.1.16",
"object.omit": "^3.0.0", "object.omit": "^3.0.0",
"obscenity": "^0.4.6", "obscenity": "^0.4.6",
"path-to-regexp": "^6.2.1", "path-to-regexp": "6.3.0",
"pg": "^8.11.3", "pg": "^8.22.0",
"redis": "^4.6.12", "redis": "^6.1.0",
"sharp": "^0.32.6", "sharp": "^0.35.3",
"sirv": "^2.0.3", "sirv": "^3.0.2",
"template-format": "^1.2.5", "template-format": "^1.2.5",
"unprint": "^0.14.1", "unprint": "^0.19.20",
"video.js": "^8.10.0", "video.js": "^8.23.9",
"vike": "^0.4.220", "vike": "^0.4.260",
"vite": "^6.1.0", "vite": "^8.1.4",
"vue": "^3.3.10", "vue": "^3.5.39",
"vue-virtual-scroller": "^2.0.0-beta.8", "vue-virtual-scroller": "^3.0.4",
"winston": "^3.11.0", "winston": "^3.19.0",
"winston-daily-rotate-file": "^4.7.1", "winston-daily-rotate-file": "^5.0.0",
"yaml": "^2.4.2", "yaml": "^2.9.0",
"yargs": "^17.7.2" "yargs": "^18.0.0"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.23.4", "@antfu/eslint-config": "^9.1.0",
"@babel/core": "^7.23.6", "@babel/cli": "^8.0.4",
"@babel/eslint-parser": "^7.23.3", "@babel/core": "^8.0.1",
"@babel/plugin-syntax-import-attributes": "^7.23.3", "@babel/plugin-transform-optional-chaining": "^8.0.1",
"@babel/plugin-transform-optional-chaining": "^7.23.4", "@babel/preset-env": "^8.0.2",
"@babel/preset-env": "^7.23.6", "@csstools/postcss-global-data": "^4.0.0",
"@csstools/postcss-global-data": "^2.1.1", "@modyfi/vite-plugin-yaml": "^1.1.1",
"@modyfi/vite-plugin-yaml": "^1.1.0", "eslint": "^10.7.0",
"eslint": "^8.56.0", "postcss-custom-media": "^12.0.1",
"eslint-config-airbnb-base": "^15.0.0", "postcss-nesting": "^14.0.0"
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-vue": "^9.19.2",
"postcss-custom-media": "^10.0.2",
"postcss-nesting": "^12.0.2"
}, },
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite"
}, },
"version": "0.50.13", "version": "0.51.18",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
},
"allowScripts": {
"vue-demi@0.14.6": true,
"sharp@0.32.6": true,
"esbuild@0.28.1": true
} }
} }

7
pages/+onHookCall.js Normal file
View File

@@ -0,0 +1,7 @@
export async function onHookCall(hook, pageContext) {
const start = Date.now();
await hook.call();
console.log(`TIME ${Date.now() - start}ms ${hook.name}-${pageContext.urlOriginal}`);
}

View File

@@ -1,3 +1,17 @@
<script setup>
import { inject } from 'vue';
defineProps({
is404: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { abortReason } = pageContext;
</script>
<template> <template>
<div class="page"> <div class="page">
<div v-if="is404"> <div v-if="is404">
@@ -14,20 +28,6 @@
</div> </div>
</template> </template>
<script setup>
import { inject } from 'vue';
defineProps({
is404: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { abortReason } = pageContext;
</script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;

View File

@@ -1,13 +1,13 @@
<script setup>
import Actors from '#/components/actors/actors.vue';
</script>
<template> <template>
<div class="page"> <div class="page">
<Actors /> <Actors />
</div> </div>
</template> </template>
<script setup>
import Actors from '#/components/actors/actors.vue';
</script>
<style scoped> <style scoped>
.page { .page {
display: flex; display: flex;

View File

@@ -1,3 +1,28 @@
<script setup>
import { inject, useId } from 'vue';
import Bio from '#/components/actors/bio.vue';
import Gender from '#/components/actors/gender.vue';
import Domains from '#/components/domains/domains.vue';
import Movies from '#/components/movies/movies.vue';
import Scenes from '#/components/scenes/scenes.vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
const pageContext = inject('pageContext');
const { pageProps, routeParams } = pageContext;
const { actor } = pageProps;
const domain = routeParams.domain;
const badCredits = ['Pierre Woodman']; // consistently horrible photos
const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits.includes(photo.credit));
const aliasTooltipId = useId();
console.log(actor);
</script>
<template> <template>
<div class="actor"> <div class="actor">
<div class="actor-header"> <div class="actor-header">
@@ -5,6 +30,17 @@
<span v-if="actor.entity">{{ actor.name }} ({{ actor.entity.name }})</span> <span v-if="actor.entity">{{ actor.name }} ({{ actor.entity.name }})</span>
<span v-else>{{ actor.name }}</span> <span v-else>{{ actor.name }}</span>
<a
v-if="actor.alias"
v-tooltip="{ content: `Alias for ${actor.alias.name} (#${actor.alias.id})`, ariaId: aliasTooltipId }"
:href="`/actor/${actor.alias.id}/${actor.alias.slug}`"
>
<Icon
icon="at-sign"
class="header-alias"
/>
</a>
<Gender <Gender
:gender="actor.gender" :gender="actor.gender"
class="header-gender" class="header-gender"
@@ -61,28 +97,6 @@
</div> </div>
</template> </template>
<script setup>
import { inject } from 'vue';
import getPath from '#/src/get-path.js';
import Bio from '#/components/actors/bio.vue';
import Gender from '#/components/actors/gender.vue';
import Scenes from '#/components/scenes/scenes.vue';
import Movies from '#/components/movies/movies.vue';
import Heart from '#/components/stashes/heart.vue';
import Domains from '#/components/domains/domains.vue';
const pageContext = inject('pageContext');
const { pageProps, routeParams } = pageContext;
const { actor } = pageProps;
const domain = routeParams.domain;
const badCredits = ['Pierre Woodman']; // consistently horrible photos
const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits.includes(photo.credit));
</script>
<style scoped> <style scoped>
.actor { .actor {
display: flex; display: flex;
@@ -111,6 +125,14 @@ const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits
flex-shrink: 0; flex-shrink: 0;
} }
.header-alias.icon {
height: 1.75rem;
display: flex;
align-items: center;
padding: 0 1rem;
fill: var(--primary);
}
.header-social { .header-social {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View File

@@ -1,12 +1,12 @@
import { redirect, render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { redirect, render } from 'vike/abort';
import { fetchActorsById } from '#/src/actors.js'; import { fetchActorsById } from '#/src/actors.js';
import { fetchScenes } from '#/src/scenes.js'; import { getCampaignIndex, getRandomCampaigns } from '#/src/campaigns.js';
import { fetchMovies } from '#/src/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { fetchCountries } from '#/src/countries.js'; import { fetchCountries } from '#/src/countries.js';
import { getRandomCampaigns, getCampaignIndex } from '#/src/campaigns.js'; import { fetchMovies } from '#/src/movies.js';
import { fetchScenes } from '#/src/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
async function fetchReleases(pageContext) { async function fetchReleases(pageContext) {
if (pageContext.routeParams.domain === 'movies') { if (pageContext.routeParams.domain === 'movies') {
@@ -59,6 +59,10 @@ export async function onBeforeRender(pageContext) {
isEditing && fetchCountries(), isEditing && fetchCountries(),
]); ]);
if (actor.alias && actorReleases.length === 0 && !Object.hasOwn(pageContext.urlParsed.search, 'inspect')) {
throw redirect(`/actor/${actor.alias.id}/${actor.alias.slug}`);
}
const campaignIndex = getCampaignIndex(actorReleases.limit); const campaignIndex = getCampaignIndex(actorReleases.limit);
const [paginationCampaign, sceneCampaign] = campaigns; const [paginationCampaign, sceneCampaign] = campaigns;

View File

@@ -1,3 +1,12 @@
<script setup>
import { inject } from 'vue';
import Revisions from '#/components/edit/revisions.vue';
const pageContext = inject('pageContext');
const actor = pageContext.pageProps.actor;
</script>
<template> <template>
<div class="content"> <div class="content">
<div class="revs-header"> <div class="revs-header">
@@ -21,15 +30,6 @@
</div> </div>
</template> </template>
<script setup>
import { inject } from 'vue';
import Revisions from '#/components/edit/revisions.vue';
const pageContext = inject('pageContext');
const actor = pageContext.pageProps.actor;
</script>
<style scoped> <style scoped>
.content { .content {
padding: 1rem; padding: 1rem;

View File

@@ -1,4 +1,4 @@
import { fetchActorsById, fetchActorRevisions } from '#/src/actors.js'; import { fetchActorRevisions, fetchActorsById } from '#/src/actors.js';
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user); const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user);

View File

@@ -1,3 +1,350 @@
<script setup>
import { format } from 'date-fns';
import omit from 'object.omit';
import { computed, inject, ref } from 'vue';
import Merge from '#/components/actors/merge.vue';
import EditAugmentation from '#/components/edit/augmentation.vue';
import Avatar from '#/components/edit/avatar.vue';
import EditFigure from '#/components/edit/figure.vue';
import EditPenis from '#/components/edit/penis.vue';
import EditPlace from '#/components/edit/place.vue';
import EditSocials from '#/components/edit/socials.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import {
// get,
post,
} from '#/src/api.js';
import verifyAbility from '#/utils/verify-ability.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const actor = ref(pageContext.pageProps.actor || null);
const fields = computed(() => [
...(actor.value?.photos.length > 0
? [{
key: 'avatar',
type: 'avatar',
value: actor.value.avatar?.id,
options: actor.value.photos,
}]
: []),
...(user.role === 'admin'
? [{
key: 'name',
type: 'string',
value: actor.value?.name,
}]
: []),
{
key: 'gender',
type: 'select',
value: actor.value?.gender || null,
options: [null, 'female', 'male', 'transsexual', 'other'],
inline: true,
},
{
key: 'dateOfBirth',
label: 'date of birth',
type: 'date',
value: actor.value?.dateOfBirth
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
: null,
inline: true,
},
{
key: 'socials',
type: 'socials',
value: actor.value?.socials || [],
},
{
key: 'origin',
type: 'place',
value: {
country: actor.value?.origin?.country?.alpha2 || null,
place: [actor.value?.origin?.city, actor.value?.origin?.state].filter(Boolean).join(', '),
},
},
{
key: 'residence',
type: 'place',
value: {
country: actor.value?.residence?.country?.alpha2 || null,
place: [actor.value?.residence?.city, actor.value?.residence?.state].filter(Boolean).join(', '),
},
},
{
key: 'ethnicity',
type: 'string',
value: actor.value?.ethnicity || null,
suggestions: [
'Asian',
'Black',
'Indian',
'Latina',
'White',
],
},
{
key: 'size',
type: 'size',
value: {
metricHeight: actor.value?.height?.metric || null,
metricWeight: actor.value?.weight?.metric || null,
imperialHeight: actor.value?.height?.imperial || [],
imperialWeight: actor.value?.weight?.imperial || null,
},
},
{
key: 'figure',
type: 'figure',
value: {
bust: actor.value?.bust || null,
cup: actor.value?.cup || null,
waist: actor.value?.waist || null,
hip: actor.value?.hip || null,
},
},
{
key: 'augmentation',
type: 'augmentation',
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
value: {
naturalBoobs: actor.value?.naturalBoobs ?? null,
boobsVolume: actor.value?.boobsVolume || null,
boobsImplant: actor.value?.boobsImplant || null,
boobsPlacement: actor.value?.boobsPlacement || null,
boobsIncision: actor.value?.boobsIncision || null,
boobsSurgeon: actor.value?.boobsSurgeon || null,
naturalButt: actor.value?.naturalButt ?? null,
buttVolume: actor.value?.buttVolume || null,
buttImplant: actor.value?.buttImplant || null,
naturalLips: actor.value?.naturalLips ?? null,
lipsVolume: actor.value?.lipsVolume || null,
naturalLabia: actor.value?.naturalLabia ?? null,
},
},
{
key: 'hairColor',
label: 'hair color',
type: 'select',
value: actor.value?.hairColor || null,
options: [
null,
'black',
'blonde',
'brown',
'red',
'gray',
'blue',
'green',
'pink',
'purple',
],
inline: true,
},
{
key: 'eyes',
label: 'eye color',
type: 'select',
value: actor.value?.eyes || null,
options: [
null,
'blue',
'brown',
'gray',
'green',
'hazel',
],
inline: true,
},
{
key: 'tattoos',
type: 'has',
value: {
has: actor.value?.hasTattoos ?? null,
description: actor.value?.tattoos || null,
},
},
{
key: 'piercings',
type: 'has',
note: 'Excludes earrings',
value: {
has: actor.value?.hasPiercings ?? null,
description: actor.value?.piercings || null,
},
},
{
key: 'agency',
type: 'string',
value: actor.value?.agency || null,
suggestions: [
'101 Modeling',
'Adult Talent Managers (ATMLA)',
'AMA Modeling',
'The Bakery Talent',
'Coxxx Models',
'East Coast Talent (ECT)',
'Hussie Models',
'Invision Models',
'OC Modeling',
'Spiegler Girls',
],
},
{
key: 'penis',
type: 'penis',
value: {
metricLength: actor.value?.penisLength?.metric || null,
metricGirth: actor.value?.penisGirth?.metric || null,
imperialLength: actor.value?.penisLength?.imperial || null,
imperialGirth: actor.value?.penisGirth?.imperial || null,
isCircumcised: actor.value?.isCircumcised || null,
},
},
{
key: 'dateOfDeath',
label: 'date of death',
type: 'date',
value: actor.value?.dateOfDeath
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
: null,
},
{
key: 'allowGlobalMatch',
label: 'global match',
type: 'has',
note: 'Allow this actor to be assigned to scenes automatically, overriding single-name protections.',
hasDescription: false,
value: {
has: actor.value?.allowGlobalMatch ?? null,
},
},
]
.filter((field) => (actor.value ? true : ['name', 'gender'].includes(field.key)))
.map((field) => ({
...field,
forced: actor.value ? field.forced : true,
})));
const editing = ref(new Set(actor.value ? [] : ['name', 'gender']));
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value])));
const creating = !actor.value;
const comment = ref(null);
const apply = ref(user.role !== 'user');
const submitting = ref(false);
const submitted = ref(false);
const showMergeDialog = ref(false);
const sizeUnits = ref('metric');
const figureUnits = ref('us');
const penisUnits = ref('imperial');
function toggleField(item) {
if (editing.value.has(item.key)) {
editing.value.delete(item.key);
// delete edits.value[item.key];
return;
}
editing.value.add(item.key);
}
function setAvatar(avatarId) {
edits.value.avatar = avatarId;
}
const keyMap = {
origin: {
country: 'originCountry',
place: 'originPlace',
},
residence: {
country: 'residenceCountry',
place: 'residencePlace',
},
tattoos: {
has: 'hasTattoos',
description: 'tattoos',
},
piercings: {
has: 'hasPiercings',
description: 'piercings',
},
allowGlobalMatch: {
has: 'allowGlobalMatch',
},
};
const groupMap = {
penisLength: 'penis',
penisGirth: 'penis',
height: 'size',
weight: 'size',
};
async function submit() {
try {
submitting.value = true;
await post('/revisions/actors', {
actorId: actor.value.id,
edits: omit({
...Object.fromEntries(Array.from(editing.value).flatMap((key) => {
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
}
return [[key, edits.value[key]]];
})),
...Object.fromEntries(Object.entries({
height: sizeUnits.value === 'imperial' ? edits.value.size.imperialHeight : edits.value.size.metricHeight,
weight: sizeUnits.value === 'imperial' ? edits.value.size.imperialWeight : edits.value.size.metricWeight,
penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength,
penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth,
}).filter(([key]) => editing.value.has(groupMap[key] || key))),
}, [
'metricHeight',
'metricWeight',
'imperialHeight',
'imperialWeight',
'metricLength',
'metricGirth',
'imperialLength',
'imperialGirth',
]),
sizeUnits: sizeUnits.value,
figureUnits: figureUnits.value,
penisUnits: penisUnits.value,
comment: comment.value,
apply: apply.value,
}, {
successFeedback: 'Your revision has been submitted for approval.',
appendErrorMessage: true,
});
submitting.value = false;
editing.value = new Set();
edits.value = {};
comment.value = null;
submitted.value = true;
// actor.value = await get(`/actors/${actor.value.id}`);
}
catch (error) {
console.error(error);
}
}
</script>
<template> <template>
<div class="editor"> <div class="editor">
<p <p
@@ -55,11 +402,26 @@
> >
<h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2> <h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2>
<span class="header-actions">
<span
v-if="verifyAbility(user, 'actor', 'merge')"
target="_blank"
class="link noshrink"
@click="showMergeDialog = true"
>Merge</span>
<Merge
v-if="showMergeDialog"
:actors="[actor]"
@close="showMergeDialog = false"
/>
<a <a
:href="`/actor/${actor.id}/${actor.slug}`" :href="`/actor/${actor.id}/${actor.slug}`"
target="_blank" target="_blank"
class="link noshrink" class="link noshrink"
>Go to actor</a> >Go to actor</a>
</span>
</div> </div>
<div <div
@@ -81,7 +443,7 @@
<Icon <Icon
v-if="item.note" v-if="item.note"
v-tooltip="item.note" v-tooltip="{ content: item.note, ariaId: `field-note-${item.key}` }"
icon="info2" icon="info2"
class="item-note" class="item-note"
/> />
@@ -354,346 +716,6 @@
</div> </div>
</template> </template>
<script setup>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns';
import omit from 'object.omit';
import EditSocials from '#/components/edit/socials.vue';
import EditPlace from '#/components/edit/place.vue';
import EditFigure from '#/components/edit/figure.vue';
import EditAugmentation from '#/components/edit/augmentation.vue';
import EditPenis from '#/components/edit/penis.vue';
import Avatar from '#/components/edit/avatar.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import {
// get,
post,
} from '#/src/api.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const actor = ref(pageContext.pageProps.actor || null);
const fields = computed(() => [
...(actor.value?.photos.length > 0 ? [{
key: 'avatar',
type: 'avatar',
value: actor.value.avatar?.id,
options: actor.value.photos,
}] : []),
...(user.role === 'admin'
? [{
key: 'name',
type: 'string',
value: actor.value?.name,
}]
: []),
{
key: 'gender',
type: 'select',
value: actor.value?.gender || null,
options: [null, 'female', 'male', 'transsexual', 'other'],
inline: true,
},
{
key: 'dateOfBirth',
label: 'date of birth',
type: 'date',
value: actor.value?.dateOfBirth
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
: null,
inline: true,
},
{
key: 'socials',
type: 'socials',
value: actor.value?.socials || [],
},
{
key: 'origin',
type: 'place',
value: {
country: actor.value?.origin?.country?.alpha2 || null,
place: [actor.value?.origin?.city, actor.value?.origin?.state].filter(Boolean).join(', '),
},
},
{
key: 'residence',
type: 'place',
value: {
country: actor.value?.residence?.country?.alpha2 || null,
place: [actor.value?.residence?.city, actor.value?.residence?.state].filter(Boolean).join(', '),
},
},
{
key: 'ethnicity',
type: 'string',
value: actor.value?.ethnicity || null,
suggestions: [
'Asian',
'Black',
'Indian',
'Latina',
'White',
],
},
{
key: 'size',
type: 'size',
value: {
metricHeight: actor.value?.height?.metric || null,
metricWeight: actor.value?.weight?.metric || null,
imperialHeight: actor.value?.height?.imperial || [],
imperialWeight: actor.value?.weight?.imperial || null,
},
},
{
key: 'figure',
type: 'figure',
value: {
bust: actor.value?.bust || null,
cup: actor.value?.cup || null,
waist: actor.value?.waist || null,
hip: actor.value?.hip || null,
},
},
{
key: 'augmentation',
type: 'augmentation',
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
value: {
naturalBoobs: actor.value?.naturalBoobs || null,
boobsVolume: actor.value?.boobsVolume || null,
boobsImplant: actor.value?.boobsImplant || null,
boobsPlacement: actor.value?.boobsPlacement || null,
boobsIncision: actor.value?.boobsIncision || null,
boobsSurgeon: actor.value?.boobsSurgeon || null,
naturalButt: actor.value?.naturalButt || null,
buttVolume: actor.value?.buttVolume || null,
buttImplant: actor.value?.buttImplant || null,
naturalLips: actor.value?.naturalLips || null,
lipsVolume: actor.value?.lipsVolume || null,
naturalLabia: actor.value?.naturalLabia || null,
},
},
{
key: 'hairColor',
label: 'hair color',
type: 'select',
value: actor.value?.hairColor || null,
options: [
null,
'black',
'blonde',
'brown',
'red',
'gray',
'blue',
'green',
'pink',
'purple',
],
inline: true,
},
{
key: 'eyes',
label: 'eye color',
type: 'select',
value: actor.value?.eyes || null,
options: [
null,
'blue',
'brown',
'gray',
'green',
'hazel',
],
inline: true,
},
{
key: 'tattoos',
type: 'has',
value: {
has: actor.value?.hasTattoos ?? null,
description: actor.value?.tattoos || null,
},
},
{
key: 'piercings',
type: 'has',
note: 'Excludes earrings',
value: {
has: actor.value?.hasPiercings ?? null,
description: actor.value?.piercings || null,
},
},
{
key: 'agency',
type: 'string',
value: actor.value?.agency || null,
suggestions: [
'101 Modeling',
'Adult Talent Managers (ATMLA)',
'AMA Modeling',
'The Bakery Talent',
'Coxxx Models',
'East Coast Talent (ECT)',
'Hussie Models',
'Invision Models',
'OC Modeling',
'Spiegler Girls',
],
},
{
key: 'penis',
type: 'penis',
value: {
metricLength: actor.value?.penisLength?.metric || null,
metricGirth: actor.value?.penisGirth?.metric || null,
imperialLength: actor.value?.penisLength?.imperial || null,
imperialGirth: actor.value?.penisGirth?.imperial || null,
isCircumcised: actor.value?.isCircumcised || null,
},
},
{
key: 'dateOfDeath',
label: 'date of death',
type: 'date',
value: actor.value?.dateOfDeath
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
: null,
},
{
key: 'allowGlobalMatch',
label: 'global match',
type: 'has',
note: 'Allow this actor to be assigned to scenes automatically, overriding single-name protections.',
hasDescription: false,
value: {
has: actor.value?.allowGlobalMatch ?? null,
},
},
]
.filter((field) => (actor.value ? true : ['name', 'gender'].includes(field.key)))
.map((field) => ({
...field,
forced: actor.value ? field.forced : true,
})));
const editing = ref(new Set(actor.value ? [] : ['name', 'gender']));
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value])));
const creating = !actor.value;
const comment = ref(null);
const apply = ref(user.role !== 'user');
const submitting = ref(false);
const submitted = ref(false);
const sizeUnits = ref('metric');
const figureUnits = ref('us');
const penisUnits = ref('imperial');
function toggleField(item) {
if (editing.value.has(item.key)) {
editing.value.delete(item.key);
// delete edits.value[item.key];
return;
}
editing.value.add(item.key);
}
function setAvatar(avatarId) {
edits.value.avatar = avatarId;
}
const keyMap = {
origin: {
country: 'originCountry',
place: 'originPlace',
},
residence: {
country: 'residenceCountry',
place: 'residencePlace',
},
tattoos: {
has: 'hasTattoos',
description: 'tattoos',
},
piercings: {
has: 'hasPiercings',
description: 'piercings',
},
allowGlobalMatch: {
has: 'allowGlobalMatch',
},
};
const groupMap = {
penisLength: 'penis',
penisGirth: 'penis',
height: 'size',
weight: 'size',
};
async function submit() {
try {
submitting.value = true;
await post('/revisions/actors', {
actorId: actor.value.id,
edits: omit({
...Object.fromEntries(Array.from(editing.value).flatMap((key) => {
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
}
return [[key, edits.value[key]]];
})),
...Object.fromEntries(Object.entries({
height: sizeUnits.value === 'imperial' ? edits.value.size.imperialHeight : edits.value.size.metricHeight,
weight: sizeUnits.value === 'imperial' ? edits.value.size.imperialWeight : edits.value.size.metricWeight,
penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength,
penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth,
}).filter(([key]) => editing.value.has(groupMap[key] || key))),
}, [
'metricHeight',
'metricWeight',
'imperialHeight',
'imperialWeight',
'metricLength',
'metricGirth',
'imperialLength',
'imperialGirth',
]),
sizeUnits: sizeUnits.value,
figureUnits: figureUnits.value,
penisUnits: penisUnits.value,
comment: comment.value,
apply: apply.value,
}, {
successFeedback: 'Your revision has been submitted for approval.',
appendErrorMessage: true,
});
submitting.value = false;
editing.value = new Set();
edits.value = {};
comment.value = null;
submitted.value = true;
// actor.value = await get(`/actors/${actor.value.id}`);
} catch (error) {
console.error(error);
}
}
</script>
<style> <style>
.editor { .editor {
flex-grow: 1; flex-grow: 1;
@@ -703,9 +725,21 @@ async function submit() {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
padding: 1rem; padding: 1rem;
} }
.header-actions {
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
.link {
cursor: pointer;
}
}
.heading { .heading {
margin: 0; margin: 0;
} }
@@ -946,5 +980,9 @@ async function submit() {
flex-grow: 1; flex-grow: 1;
} }
} }
.editor-header {
flex-direction: column;
}
} }
</style> </style>

View File

@@ -1,4 +1,4 @@
import { redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { redirect } from 'vike/abort';
import { fetchActorsById } from '#/src/actors.js'; import { fetchActorsById } from '#/src/actors.js';
import { fetchCountries } from '#/src/countries.js'; import { fetchCountries } from '#/src/countries.js';

View File

@@ -10,9 +10,11 @@ export default (pageContext) => {
if (matched) { if (matched) {
return { return {
routeParams: matched.params.actorId ? { routeParams: matched.params.actorId
? {
actorId: matched.params.actorId, actorId: matched.params.actorId,
} : {}, }
: {},
}; };
} }

View File

@@ -1,3 +1,7 @@
<script setup>
import Admin from '#/components/admin/admin.vue';
</script>
<template> <template>
<Admin> <Admin>
<h2 class="heading">Admin Panel</h2> <h2 class="heading">Admin Panel</h2>
@@ -34,10 +38,6 @@
</Admin> </Admin>
</template> </template>
<script setup>
import Admin from '#/components/admin/admin.vue';
</script>
<style scoped> <style scoped>
.menu { .menu {
margin: 0; margin: 0;

View File

@@ -1,4 +1,4 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { render } from 'vike/abort';
export function onBeforeRender(pageContext) { export function onBeforeRender(pageContext) {
if (pageContext.user.role === 'user') { if (pageContext.user.role === 'user') {

View File

@@ -1,14 +1,105 @@
<script setup>
import { inject, onMounted, ref } from 'vue';
import AddActor from '#/components/actors/add.vue';
import MergeActors from '#/components/actors/merge.vue';
import Admin from '#/components/admin/admin.vue';
import Checkbox from '#/components/form/checkbox.vue';
import getPath from '#/src/get-path.js';
import navigate from '#/src/navigate.js';
// import { get } from '#/src/api.js';
const { pageProps, urlParsed } = inject('pageContext');
const actors = ref(pageProps.actors);
const selectedActors = ref(new Set([]));
const activeActor = ref(null);
const actorQuery = ref(urlParsed.search.q || null);
const showAliased = ref(Object.hasOwn(urlParsed.search, 'alias'));
const lastSelectedIndex = ref(null);
const holdingShift = ref(false);
const showMergeDialog = ref(false);
const showAddDialog = ref(false);
function selectActors(selectedActor, isChecked, index) {
const [start, end] = holdingShift.value
? [index, lastSelectedIndex.value].toSorted((indexA, indexB) => indexA - indexB)
: [index, index];
const actorIds = actors.value
.slice(start, end + 1)
.map((actor) => actor.id);
actorIds.forEach((actorId) => {
if (isChecked) {
selectedActors.value.add(actorId);
}
else {
selectedActors.value.delete(actorId);
}
});
lastSelectedIndex.value = index;
}
async function searchActors() {
navigate('/admin/actors', {
q: actorQuery.value || undefined,
alias: showAliased.value || undefined,
}, { redirect: true });
}
onMounted(() => {
window.addEventListener('keydown', (event) => {
if (event.key === 'Shift') {
holdingShift.value = true;
}
});
window.addEventListener('keyup', (event) => {
if (event.key === 'Shift') {
holdingShift.value = false;
}
});
window.addEventListener('blur', () => {
holdingShift.value = false;
});
});
</script>
<template> <template>
<Admin class="page"> <Admin class="page">
<div class="header"> <div class="header">
<form
class="search"
@submit.prevent="searchActors"
>
<input <input
v-model="actorQuery" v-model="actorQuery"
type="search" type="input"
placeholder="Search actors" placeholder="Search actors"
class="input search" class="input search-input"
@search="searchActors"
> >
<button
v-if="actorQuery?.length"
type="button"
class="search-cancel nobutton"
@click="actorQuery = ''; searchActors()"
>
<Icon icon="cross2" />
</button>
</form>
<Checkbox
label="Show aliased"
:checked="showAliased"
@change="(checked) => { showAliased = checked; searchActors(); }"
/>
<div class="header-actions"> <div class="header-actions">
<button <button
class="button" class="button"
@@ -45,14 +136,26 @@
:key="`actor-${actor.id}`" :key="`actor-${actor.id}`"
class="actor" class="actor"
> >
<td class="actor-id ellipsis">{{ actor.id }}</td> <td class="actor-id ellipsis">
<a
:href="`/actor/${actor.id}/${actor.slug}?inspect`"
target="_blank"
class="nolink"
>{{ actor.id }}</a>
</td>
<td <td
v-tooltip="actor.entity?.name || 'Global'" v-tooltip="{ content: actor.alias ? `#${actor.alias.id} ${actor.alias.name}` : (actor.entity?.name || 'Global'), ariaId: `actor-entity-${actor.id}` }"
class="actor-entity ellipsis" class="actor-entity ellipsis"
> >
<Icon
v-if="actor.alias"
icon="at-sign"
class="actor-alias"
/>
<img <img
v-if="actor.entity" v-else-if="actor.entity"
:src="`/logos/${actor.entity.slug}/favicon_dark.png`" :src="`/logos/${actor.entity.slug}/favicon_dark.png`"
class="actor-favicon" class="actor-favicon"
> >
@@ -94,14 +197,14 @@
target="_blank" target="_blank"
> >
<Icon <Icon
v-tooltip="'Edit bio'" v-tooltip="{ content: 'Edit bio', ariaId: `actor-edit-${actor.id}` }"
icon="pencil5" icon="pencil5"
class="actor-action action-merge" class="actor-action action-merge"
/> />
</a> </a>
<Icon <Icon
v-tooltip="'Merge'" v-tooltip="{ content: 'Merge', ariaId: `actor-merge-${actor.id}` }"
icon="make-group" icon="make-group"
class="actor-action action-merge" class="actor-action action-merge"
@click="activeActor = actor; showMergeDialog = true;" @click="activeActor = actor; showMergeDialog = true;"
@@ -133,79 +236,18 @@
</Admin> </Admin>
</template> </template>
<script setup>
import { ref, inject, onMounted } from 'vue';
import Admin from '#/components/admin/admin.vue';
import Checkbox from '#/components/form/checkbox.vue';
import MergeActors from '#/components/actors/merge.vue';
import AddActor from '#/components/actors/add.vue';
import getPath from '#/src/get-path.js';
import navigate from '#/src/navigate.js';
// import { get } from '#/src/api.js';
const { pageProps, urlParsed } = inject('pageContext');
const actors = ref(pageProps.actors);
const selectedActors = ref(new Set([]));
const activeActor = ref(null);
const actorQuery = ref(urlParsed.search.q || null);
const lastSelectedIndex = ref(null);
const holdingShift = ref(false);
const showMergeDialog = ref(false);
const showAddDialog = ref(false);
function selectActors(selectedActor, isChecked, index) {
const [start, end] = holdingShift.value
? [index, lastSelectedIndex.value].toSorted((indexA, indexB) => indexA - indexB)
: [index, index];
const actorIds = actors.value
.slice(start, end + 1)
.map((actor) => actor.id);
actorIds.forEach((actorId) => {
if (isChecked) {
selectedActors.value.add(actorId);
} else {
selectedActors.value.delete(actorId);
}
});
lastSelectedIndex.value = index;
}
async function searchActors() {
navigate('/admin/actors', { q: actorQuery.value || undefined }, { redirect: true });
}
onMounted(() => {
window.addEventListener('keydown', (event) => {
if (event.key === 'Shift') {
holdingShift.value = true;
}
});
window.addEventListener('keyup', (event) => {
if (event.key === 'Shift') {
holdingShift.value = false;
}
});
window.addEventListener('blur', () => {
holdingShift.value = false;
});
});
</script>
<style scoped> <style scoped>
.search { .search {
display: flex;
align-items: center;
max-width: 20rem; max-width: 20rem;
flex-grow: 1; flex-grow: 1;
} }
.search-input {
flex-grow: 1;
}
.header { .header {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -263,6 +305,11 @@ onMounted(() => {
border-radius: 1rem; border-radius: 1rem;
} }
.actor-alias {
fill: var(--primary);
padding: .5rem;
}
th.actor-actions { th.actor-actions {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,7 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { render } from 'vike/abort';
import { fetchActors } from '#/src/actors.js'; import { fetchActors } from '#/src/actors.js';
import { curateActorsQuery } from '#/src/web/actors.js';
import verifyAbility from '#/utils/verify-ability.js'; import verifyAbility from '#/utils/verify-ability.js';
export default async function onBeforeRender(pageContext) { export default async function onBeforeRender(pageContext) {
@@ -8,9 +9,7 @@ export default async function onBeforeRender(pageContext) {
throw render(404); throw render(404);
} }
const { actors } = await fetchActors({ const { actors } = await fetchActors(curateActorsQuery(pageContext.urlParsed.search), {
query: pageContext.urlParsed.search.q,
}, {
limit: 100, limit: 100,
order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'], order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'],
}, pageContext.user); }, pageContext.user);

View File

@@ -1,3 +1,56 @@
<script setup>
import { format, subMonths, subWeeks } from 'date-fns';
import {
computed,
inject,
ref,
watch,
} from 'vue';
import Admin from '#/components/admin/admin.vue';
import navigate from '#/src/navigate.js';
const {
pageProps,
urlParsed,
meta,
} = inject('pageContext');
const { entities } = pageProps;
const alertThreshold = ref(Number(urlParsed.search.alert) || 12);
const deadThreshold = ref(Number(urlParsed.search.dead) || 36);
const order = urlParsed.search.order || 'desc';
const alertDate = computed(() => subWeeks(meta.now, alertThreshold.value));
const deadDate = computed(() => subMonths(meta.now, deadThreshold.value));
const alertEntities = computed(() => entities.filter((entity) => entity.latestReleaseDate > deadDate.value && entity.latestReleaseDate < alertDate.value));
function sort(sorting) {
navigate('/admin/entities', {
sort: sorting,
order: order === 'desc' ? 'asc' : 'desc',
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: true,
});
}
watch([alertThreshold, deadThreshold], () => {
navigate('/admin/entities', {
...urlParsed.search,
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: false,
});
});
</script>
<template> <template>
<Admin class="page"> <Admin class="page">
<div class="header"> <div class="header">
@@ -84,59 +137,6 @@
</Admin> </Admin>
</template> </template>
<script setup>
import {
ref,
computed,
watch,
inject,
} from 'vue';
import { format, subMonths, subWeeks } from 'date-fns';
import navigate from '#/src/navigate.js';
import Admin from '#/components/admin/admin.vue';
const {
pageProps,
urlParsed,
meta,
} = inject('pageContext');
const { entities } = pageProps;
const alertThreshold = ref(Number(urlParsed.search.alert) || 12);
const deadThreshold = ref(Number(urlParsed.search.dead) || 36);
const order = urlParsed.search.order || 'desc';
const alertDate = computed(() => subWeeks(meta.now, alertThreshold.value));
const deadDate = computed(() => subMonths(meta.now, deadThreshold.value));
const alertEntities = computed(() => entities.filter((entity) => entity.latestReleaseDate > deadDate.value && entity.latestReleaseDate < alertDate.value));
function sort(sorting) {
navigate('/admin/entities', {
sort: sorting,
order: order === 'desc' ? 'asc' : 'desc',
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: true,
});
}
watch([alertThreshold, deadThreshold], () => {
navigate('/admin/entities', {
...urlParsed.search,
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: false,
});
});
</script>
<style scoped> <style scoped>
.page { .page {
flex-grow: 1; flex-grow: 1;

View File

@@ -1,4 +1,4 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { render } from 'vike/abort';
import { fetchEntityHealths } from '#/src/entities.js'; import { fetchEntityHealths } from '#/src/entities.js';
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {

View File

@@ -1,3 +1,8 @@
<script setup>
import Admin from '#/components/admin/admin.vue';
import Revisions from '#/components/edit/revisions.vue';
</script>
<template> <template>
<Admin class="page"> <Admin class="page">
<Revisions <Revisions
@@ -6,11 +11,6 @@
</Admin> </Admin>
</template> </template>
<script setup>
import Admin from '#/components/admin/admin.vue';
import Revisions from '#/components/edit/revisions.vue';
</script>
<style scoped> <style scoped>
.page { .page {
flex-grow: 1; flex-grow: 1;

View File

@@ -1,4 +1,4 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { render } from 'vike/abort';
import { fetchActorRevisions } from '#/src/actors.js'; import { fetchActorRevisions } from '#/src/actors.js';
import verifyAbility from '#/utils/verify-ability.js'; import verifyAbility from '#/utils/verify-ability.js';

View File

@@ -1,4 +1,4 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */ import { render } from 'vike/abort';
import { fetchSceneRevisions } from '#/src/scenes.js'; import { fetchSceneRevisions } from '#/src/scenes.js';
import verifyAbility from '#/utils/verify-ability.js'; import verifyAbility from '#/utils/verify-ability.js';

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