Compare commits

..

97 Commits

Author SHA1 Message Date
6444cf21eb Added preferences and serialize to repo. Stale sessions are refreshed on restart or after a period of time now. 2026-07-18 06:24:44 +02:00
216e7f59ef Added e-mail alert settings, saveable but not integrated yet. 2026-07-18 06:24:07 +02:00
479b044798 0.54.0 2026-07-18 03:21:02 +02:00
7c6bde690f Replaced profile pill bar with portal page. 2026-07-18 03:20:50 +02:00
981205efaa Enabled e-mail action in alert dialog and alerts overview action indicators. 2026-07-18 01:02:03 +02:00
b6408f3244 Implemented e-mail notification trigger. 2026-07-18 00:01:18 +02:00
fab895b85e 0.53.0 2026-07-16 02:21:58 +02:00
9d869c4a37 Integrated notify from legacy triggered by sync. 2026-07-16 02:21:55 +02:00
583b6984d8 0.52.6 2026-07-13 22:05:08 +02:00
ad912e8f03 Integrated scene alerts in alert filters. 2026-07-13 22:05:06 +02:00
fe1170b22e 0.52.5 2026-07-13 21:52:24 +02:00
b69e0a104c Fixed empty studio returned for scenes without studio. 2026-07-13 21:52:17 +02:00
e23bc927e2 0.52.4 2026-07-13 21:40:17 +02:00
29a17b2e94 Split footer to prevent overflows. 2026-07-13 21:40:12 +02:00
18693faa5f 0.52.3 2026-07-13 21:25:47 +02:00
378f81efe4 Moed pages/scene to pages/scenes/@sceneId for consistency. 2026-07-13 21:25:45 +02:00
05e3cf811a 0.52.2 2026-07-13 20:45:04 +02:00
5815ac2bf4 Improved scene tile actors height to prevent second line leak. Improved alert action tooltip. Changed popular tag from MFM to threesome due lack of specifically tagged scenes. 2026-07-13 20:45:01 +02:00
cafa6476ca 0.52.1 2026-07-13 19:53:15 +02:00
3007150aeb Fixed parameter binding in alert purge, added log. 2026-07-13 19:53:13 +02:00
2199bb3f39 Fixed release date alert query still in debug mode. 2026-07-13 17:31:34 +02:00
7f52da5f21 0.52.0 2026-07-13 17:29:28 +02:00
4ae88132af 0.51.23 2026-07-13 17:27:52 +02:00
327885dd17 Purging expired notifications and alerts. Fixed manticore stash sync. 2026-07-13 17:27:50 +02:00
cf758c2494 0.51.22 2026-07-13 03:58:32 +02:00
6e82d2af37 Added actor merge comment. 2026-07-13 03:58:29 +02:00
f42a48dbcc 0.51.21 2026-07-13 03:40:12 +02:00
e95145ff1a Adding revisions for actor merges. 2026-07-13 03:40:08 +02:00
4031f35c73 Added actor render timings. 2026-07-13 01:06:38 +02:00
522dc33225 0.51.20 2026-07-13 00:54:54 +02:00
02b7dcb292 Fixed actor scene length check in redirect, removed stray console log. 2026-07-13 00:54:52 +02:00
5dbcf81d84 0.51.19 2026-07-13 00:49:17 +02:00
ff2d4eefb5 Renamed deprecated requireGlobal actor filter property. Fixed actor search wildcard expansion returning no manticore results for name components under 3 characters. 2026-07-13 00:49:15 +02:00
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
202 changed files with 26402 additions and 22581 deletions

View File

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

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);
}
}
.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

@@ -65,3 +65,12 @@
.capitalize {
text-transform: capitalize;
}
.hidden {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M14.341 3.579c-0.347-0.473-0.831-1.027-1.362-1.558s-1.085-1.015-1.558-1.362c-0.806-0.591-1.197-0.659-1.421-0.659h-7.75c-0.689 0-1.25 0.561-1.25 1.25v13.5c0 0.689 0.561 1.25 1.25 1.25h11.5c0.689 0 1.25-0.561 1.25-1.25v-9.75c0-0.224-0.068-0.615-0.659-1.421v0 0zM12.271 2.729c0.48 0.48 0.856 0.912 1.134 1.271h-2.406v-2.405c0.359 0.278 0.792 0.654 1.271 1.134v0 0zM14 14.75c0 0.136-0.114 0.25-0.25 0.25h-11.5c-0.135 0-0.25-0.114-0.25-0.25v-13.5c0-0.135 0.115-0.25 0.25-0.25 0 0 7.749-0 7.75 0v3.5c0 0.276 0.224 0.5 0.5 0.5h3.5v9.75z"></path>
<path d="M4 8h5v5h-5v-5z"></path>
<path d="M9 10l3-2v5l-3-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 756 B

4
assets/img/icons/profile.svg Executable file
View File

@@ -0,0 +1,4 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13.5 0h-12c-0.825 0-1.5 0.675-1.5 1.5v13c0 0.825 0.675 1.5 1.5 1.5h12c0.825 0 1.5-0.675 1.5-1.5v-13c0-0.825-0.675-1.5-1.5-1.5zM13 14h-11v-12h11v12zM4 9h7v1h-7zM4 11h7v1h-7zM5 4.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5zM7.5 6h-2c-0.825 0-1.5 0.45-1.5 1v1h5v-1c0-0.55-0.675-1-1.5-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 488 B

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>
<div class="page">
<Filters :results="total">
@@ -5,12 +134,19 @@
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="q"
type="search"
type="input"
placeholder="Search actors"
class="input search"
@search="search"
@keydown.enter="search"
>
<Icon
v-if="q"
icon="cross2"
class="search-button"
@click="q = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@@ -104,135 +240,6 @@
</div>
</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>
.gender-button {
&.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>
<Dialog
title="Add actor"
@@ -80,64 +139,6 @@
</Dialog>
</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>
.dialog-body {
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>
<div
class="profile"
@@ -53,7 +149,7 @@
<dfn class="bio-label"><Icon icon="cake" />Age</dfn>
<span
:title="'Exact date of birth or age unknown'"
title="Exact date of birth or age unknown"
class="birthdate"
>&gt; {{ actor.age }}</span>
</li>
@@ -162,7 +258,7 @@
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<Icon
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"
class="enhanced"
/>
@@ -184,7 +280,7 @@
actor.boobsVolume && `${actor.boobsVolume}cc`,
augmentationMap[actor.boobsPlacement] || actor.boobsPlacement,
augmentationMap[actor.boobsIncision] || actor.boobsIncision,
augmentationMap[actor.boobsImplant] || actor.boobsImplant
augmentationMap[actor.boobsImplant] || actor.boobsImplant,
].filter(Boolean).join(' ')"
class="augmentations-section"
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template>
@@ -224,7 +320,7 @@
<span>
<Icon
v-if="actor.isCircumcised"
:title="'Circumcised'"
title="Circumcised"
icon="pagebreak"
class="circumcised"
/>
@@ -328,7 +424,7 @@
<a
v-for="social in socials"
: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)"
target="_blank"
rel="noopener"
@@ -374,29 +470,17 @@
:href="`/actor/edit/${actor.id}/${actor.slug}`"
target="_blank"
class="link"
>Edit bio</a>
>Edit</a>
<a
:href="`/actor/revs/${actor.id}/${actor.slug}`"
target="_blank"
class="link"
>Revisions</a>
<span
v-if="user && user.role !== 'user'"
class="link"
@click="showMergeDialog = true"
>Merge</span>
</div>
</li>
</ul>
<Merge
v-if="showMergeDialog"
:actors="[actor]"
@close="showMergeDialog = false"
/>
<div class="descriptions-container">
<div
v-if="descriptions.length > 0"
@@ -450,105 +534,6 @@
</div>
</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>
.header-gender {
display: inline-flex;
@@ -898,7 +883,7 @@ const showMergeDialog = ref(false);
display: none;
justify-content: center;
position: absolute;
z-index: 10;
z-index: 1;
bottom: -.25rem;
}

View File

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

View File

@@ -1,3 +1,84 @@
<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 comment = 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(',')}`, {
comment: comment.value || 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>
<Dialog
:title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`"
@@ -62,6 +143,7 @@
<template v-else>
<VDropdown
:aria-id="searchDropdownId"
:triggers="[]"
:shown="actorResults.length > 0"
:auto-hide="false"
@@ -94,12 +176,19 @@
</VDropdown>
</template>
<textarea
v-model="comment"
placeholder="Optional comment"
rows="2"
class="input comment"
/>
<div class="dialog-actions">
<Ellipsis v-if="submitted" />
<button
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"
class="button button-primary"
:disabled="!targetActor || sourceTargetConflict"
@@ -110,82 +199,6 @@
</Dialog>
</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>
.dialog-body {
width: 20rem;
@@ -213,6 +226,10 @@ onMounted(() => {
.input {
width: 100%;
&.comment {
flex-basis: auto;
}
}
.path {

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>
<VDropdown
:aria-id="dropdownId"
:disabled="disabled"
class="trigger"
@show="focus"
@@ -36,38 +68,6 @@
</VDropdown>
</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>
.trigger {
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>
<div
class="tile"
@@ -74,42 +100,32 @@
</div>
<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
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`"
class="favicon"
>
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="{
content: `Credited as '${actor.alias.name}'`,
triggers: ['hover', 'click'],
ariaId: aliasTooltipId,
}"
icon="at-sign"
class="alias"
/>
</span>
</div>
</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>
.tile {
display: flex;
@@ -148,6 +164,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
}
.label {
height: 1.75rem;
display: flex;
justify-content: space-between;
align-items: center;
@@ -161,7 +178,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
}
.name {
padding: .35rem .25rem .35rem .5rem;
padding: 0 .25rem 0 .5rem;
}
.favicon {
@@ -258,4 +275,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem;
margin-left: .25rem;
}
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</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>
<div class="page">
<nav class="nav">
@@ -33,6 +49,17 @@
:class="{ active: pageContext.routeParams.section === 'entities' }"
>Entity Health</a>
</li>
<li
v-if="pageContext.user.role === 'admin'"
class="nav-item"
>
<a
href="/admin/emails"
class="nav-link nolink"
:class="{ active: pageContext.urlPathname.startsWith('/admin/emails') }"
>E-mails</a>
</li>
</ul>
<button
@@ -47,22 +74,6 @@
</div>
</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>
.page {
display: flex;

View File

@@ -1,8 +1,133 @@
<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 filterScenes = 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 (filterScenes.value && alert.scenes.length === 0) {
return false;
}
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches, ...alert.scenes].length > 1) {
return false;
}
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches, ...alert.scenes].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;
}
if (alert.scenes.some((scene) => queryRegex.test(scene.title))) {
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>
<section class="profile-section">
<div class="section-header">
<h3 class="heading">Alerts</h3>
<button
class="button"
@click="showAlertDialog = true"
@@ -22,37 +147,45 @@
<div class="filters-section">
<Icon
v-tooltip="'Only show actor alerts'"
icon="star"
title="Only show actor alerts"
class="noselect"
:class="{ active: filterActors }"
@click="filterActors = !filterActors"
/>
<Icon
v-tooltip="'Only show tag alerts'"
icon="price-tags"
title="Only show tag alerts"
class="noselect"
:class="{ active: filterTags }"
@click="filterTags = !filterTags"
/>
<Icon
v-tooltip="'Only show channel alerts'"
icon="device_hub"
title="Only show channel alerts"
class="noselect"
:class="{ active: filterEntities }"
@click="filterEntities = !filterEntities"
/>
<Icon
v-tooltip="'Only show expression alerts'"
icon="regexp"
title="Only show expression alerts"
class="noselect"
:class="{ active: filterExpressions }"
@click="filterExpressions = !filterExpressions"
/>
<Icon
v-tooltip="'Only show scene release date alerts'"
icon="clapboard-play"
class="noselect"
:class="{ active: filterScenes }"
@click="filterScenes = !filterScenes"
/>
<Icon
v-if="filterCombined === false"
icon="target3"
@@ -163,50 +296,64 @@
</span>
</span>
</span>
<span
v-for="scene in alert.scenes"
:key="`alert-${alert.id}-${scene.id}`"
class="alert-scene"
>
<time
:datetime="scene.date.toISOString()"
class="alert-date"
>{{ format(scene.date, 'yyyy-MM-dd') }}</time>
<a
:href="`/scene/${scene.id}/${scene.slug}`"
class="alert-value alert-title ellipsis link"
>{{ scene.title }}</a>
</span>
</div>
<div class="alert-meta">
<div class="alert-triggers">
<Icon
v-if="alert.notify && alert.isFromPreset"
v-tooltip="'Notify in traxxx, added as quick alert'"
v-tooltip="{ content: alert.notify ? 'Notify in traxxx, added as quick alert' : undefined, ariaId: `alert-notify-preset-${alert.id}` }"
icon="bell-plus"
class="trigger"
/>
<Icon
v-else-if="alert.notify"
v-tooltip="'Notify in traxxx'"
v-else
v-tooltip="{ content: alert.notify ? 'Notify in traxxx' : undefined, ariaId: `alert-notify-${alert.id}` }"
icon="bell2"
class="trigger"
:class="{ trigger: alert.notify }"
/>
<Icon
v-tooltip="'E-mail me'"
icon="envelop5"
:class="{ trigger: alert.email }"
/>
<Icon
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"
class="trigger"
/>
<Icon
v-else-if="alert.stashes.length > 0"
v-tooltip="alert.stashes.length > 0 ? 'Add to Favorites' : undefined"
v-else
v-tooltip="alert.stashes.length > 0 ? { content: 'Add to Favorites', ariaId: `alert-favorites-${alert.id}` } : undefined"
icon="heart7"
class="trigger"
:class="{ trigger: alert.stashes.length > 0 }"
/>
<!--
<Icon
icon="envelop5"
title="E-mail me"
:class="{ trigger: alert.email }"
/>
-->
</div>
<div class="alert-actions">
<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"
title="Alert ID"
>#{{ alert.id }}</span>
@@ -227,124 +374,11 @@
</section>
</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>
.profile-section .section-header {
justify-content: center;
}
.alerts {
width: 100%;
margin-bottom: 1rem;
@@ -475,6 +509,16 @@ function toggleFilterCombined() {
display: flex;
}
.alert-scene {
display: flex;
gap: .5rem;
padding: 0 .5rem;
}
.alert-date {
flex-shrink: 0;
}
.alert-actions {
display: flex;
align-items: center;

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>
import { ref, inject } from 'vue';
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
import { inject, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.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({
presetActors: {
@@ -403,6 +24,12 @@ const props = defineProps({
const emit = defineEmits(['alert', 'close']);
const {
assets,
user,
env,
} = inject('pageContext');
const actors = ref(props.presetActors);
const actorResults = ref([]);
const actorInput = ref(null);
@@ -427,6 +54,13 @@ const actorAnd = ref(true);
const tagAnd = 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 email = ref(false);
@@ -530,6 +164,388 @@ function selectStash(selectedStash) {
}
</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
v-if="env.emailEnabled && user.email"
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>
.dialog-body {
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>
<div
v-if="restriction"
@@ -12,7 +46,6 @@
<iframe
v-else-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width"
:height="campaign.banner.height"
:src="bannerSrc"
@@ -45,41 +78,6 @@
</a>
</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>
.campaign {
height: 100%;

View File

@@ -1,3 +1,30 @@
<script setup>
import { onMounted } from 'vue';
defineOptions({ inheritAttrs: false });
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>
<Teleport to="#container">
<div
@@ -6,6 +33,7 @@
>
<div
class="dialog"
v-bind="$attrs"
@click.stop
>
<div class="dialog-header">
@@ -24,31 +52,6 @@
</Teleport>
</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>
.dialog-body {
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>
<nav class="domains">
<Link
@@ -32,27 +53,6 @@
</nav>
</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>
.domains {
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>
<ul
class="actors nolist"
@@ -40,43 +79,6 @@
</ul>
</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>
.actors {
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>
<div
class="figure augmentation"
@@ -208,23 +228,3 @@
</div>
</div>
</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>
<div class="avatar noshrink">
<img
@@ -19,10 +31,16 @@
>{{ avatar.sharpness.toFixed(2) }}</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
:href="getPath(avatar)"
target="_blank"
class="avatar-zoom"
@click.stop
>
<Icon
icon="search"
@@ -31,17 +49,6 @@
</div>
</template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.avatar {
display: inline-block;
@@ -89,7 +96,8 @@ defineProps({
}
.avatar-meta,
.avatar-credit {
.avatar-credit,
.avatar-date {
position: absolute;
z-index: 10;
box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem;
left: 0;
}
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</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>
import { reactive, watch } from 'vue';
@@ -145,3 +44,104 @@ const bustSizes = {
bustSizes.uk = bustSizes.us;
bustSizes.jp = bustSizes.eu;
</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>
<ul
class="movies nolist"
@@ -40,47 +83,6 @@
</ul>
</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>
.movies {
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>
<div
class="figure penis"
@@ -83,27 +107,3 @@
</div>
</div>
</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>
<div
class="place"
@@ -35,43 +75,3 @@
</div>
</div>
</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>
<ul class="socials nolist">
<li
@@ -38,46 +77,6 @@
</ul>
</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>
.socials {
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>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns';
import { computed, inject, ref } from 'vue';
import Avatar from '#/components/edit/avatar.vue';
import Socials from '#/components/edit/revision-socials.vue';
@@ -357,6 +128,14 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
};
}
if (delta.key === 'mergeTarget' || delta.key === 'mergeSource') {
// new socials don't have IDs yet, so we need to compare the values
return {
...delta,
value: `#${delta.actorId} ${delta.actorName} (${delta.profileIds.length} profiles${delta.profileIds.length > 0 ? `: ${delta.profileIds.join()}` : ''})`,
};
}
if (dateKeys.includes(delta.key) && delta.value) {
return {
...delta,
@@ -410,7 +189,8 @@ async function reviewRevision(revision, isApproved) {
});
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
} catch (error) {
}
catch (error) {
reviewedRevisions.value.delete(revision.id);
}
}
@@ -425,6 +205,235 @@ async function banEditor(revision) {
}
</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>
.page {
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>
<ul
class="list nolist"
@@ -54,7 +126,7 @@
</li>
<li class="list-new">
<VDropdown>
<VDropdown :aria-id="addSocialDropdownId">
<Icon
icon="plus2"
class="add noselect"
@@ -110,77 +182,6 @@
</ul>
</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>
.list {
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>
<div class="tags-sections">
<div
@@ -53,73 +122,6 @@
</div>
</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>
.tags {
display: flex;

307
components/emails/alert.vue Normal file
View File

@@ -0,0 +1,307 @@
<script setup>
import {
Body,
Column,
Container,
Heading,
Html,
Img,
Link,
Preview,
Row,
Text,
} from '@vue-email/components';
import { format } from 'date-fns';
defineProps({
trigger: {
type: Object,
default: () => mockTrigger,
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockTrigger = {
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
alerts: [
{
sceneId: 684061,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684061,
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
date: '2026-02-17T00:00:00.000Z',
description: 'Vanessa Blakes curves are impossible to ignore—especially her thick, round, bootylicious ass. The scene opens with the camera following Vanessa through the house as she struts confidently in a slow, teasing booty walk, giving viewers a full view of her luscious curves. She pauses at the stairs, turning back to flaunt her massive ass before letting her hands wander over her body. Feeling the heat build, Vanessa begins to pleasure herself, showcasing her insatiable appetite for big black cock. Moving to the couch, she grabs a dildo and slowly works it into her ass, preparing herself for whats coming next. Sitting back and watching the show is Lexington Steele, enjoying every second as she stretches and teases in anticipation. Lexington finally steps in, and Vanessa eagerly drops to her knees, wrapping her lips around his massive BBC and giving him a deep, hungry blowjob. The action quickly escalates to the couch, where they fuck in multiple intense positions, Vanessas thick body bouncing with every thrust. When shes fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
actors: [
{
id: 919,
name: 'Vanessa Blake',
},
{
id: 1374,
name: 'Lexington Steele',
},
],
actorIds: [
919,
1374,
],
tags: [
{
name: 'MILF',
slug: 'milf',
},
{
name: 'facial',
slug: 'facial',
},
],
tagIds: [
4,
12,
19,
30,
29,
37,
41,
68,
82,
100,
112,
119,
null,
130,
],
entityId: 522,
parentEntityId: 47,
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
},
},
{
sceneId: 684060,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684060,
title: 'Elizabeth Skylars Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
slug: 'elizabeth-skylar-s-big-assets-drive-the-manuel',
date: '2026-02-21T00:00:00.000Z',
description: 'Elizabeth Skylar is ready to indulge her cravings, and theres only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
actors: [
{
id: 44001,
name: 'Elizabeth Skylar',
},
{
id: 512,
name: 'Manuel Ferrara',
},
],
actorIds: [
512,
44001,
],
tags: [
{
name: 'anal',
slug: 'anal',
},
{
name: 'ass to mouth',
tag: 'atm',
},
],
tagIds: [
4,
30,
29,
31,
37,
41,
68,
82,
119,
125,
],
entityId: 522,
parentEntityId: 47,
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
},
},
],
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ trigger.username }}</Heading>
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
<div
v-for="(alert, index) in trigger.alerts"
:key="alert.alert.id"
style="margin-bottom: 15px; font-size: 14px;"
:style="{
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0,
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none',
}"
>
<Row style="margin-bottom: 8px;">
<Column valign="top">
{{ format(alert.scene.date, 'yyyy-MM-dd') }} |
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span>
<span v-else>
New
<template
v-if="alert.alert.tags.length > 0"
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template>
scene
<template
v-if="alert.alert.actors.length > 0"
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }}&nbsp;</template>
<template
v-if="alert.alert.entities.length > 0"
>for {{ alert.alert.entities[0].name }}&nbsp;</template>
<template
v-if="alert.alert.matches.length > 0"
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template>
</span>
</Column>
<Column
width="50"
valign="top"
style="text-align: right;"
>
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span>
</Column>
</Row>
<div>
<Row>
<Column
width="175"
valign="top"
>
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`">
<Img
v-if="alert.scene.posterUrl"
:src="alert.scene.posterUrl"
width="160"
:alt="alert.scene.title"
style="margin-top: 2px; border-radius: 8px;"
/>
<div
v-else
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
>No thumb</div>
</Link>
</Column>
<Column valign="top">
<div style="margin-bottom: 5px;">
<Link
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"
style="font-weight: bold; line-height: 1.5; color: inherit;"
>{{ alert.scene.title }}</Link>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
</div>
</Column>
</Row>
</div>
</div>
<Heading
as="h3"
style="margin-top: 30px;"
>Enjoy!</Heading>
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
</Container>
</Body>
</Html>
</template>

View File

@@ -1,3 +1,16 @@
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<template>
<a
:href="`/${entity.type}/${entity.slug}`"
@@ -22,19 +35,6 @@
</a>
</template>
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<style scoped>
.entity {
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>
<li
:key="`filter-actor-${actor.id}`"
@@ -44,37 +75,6 @@
</li>
</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>
.filter-name {
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>
<div class="filter actors-container">
<div
@@ -18,6 +94,7 @@
</label>
<div
v-tooltip="{ content: selectedGender ? `${selectedGender.slice(0, 1).toUpperCase()}${selectedGender.slice(1)} performers` : `All genders`, ariaId: genderTooltipId }"
class="filter-sort noselect"
@click="selectGender"
>
@@ -35,6 +112,7 @@
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
@@ -45,6 +123,7 @@
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
@@ -96,78 +175,6 @@
</div>
</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>
.filter {
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>
<div class="filter">
<div class="filter-section">
@@ -53,26 +75,6 @@
</div>
</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>
.input {
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>
<div class="filter">
<RangeFilter
@@ -42,33 +72,3 @@
</span>
</div>
</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>
import { ref, computed, inject } from 'vue';
import { computed, inject, ref, useId } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
@@ -106,6 +19,8 @@ const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('name');
const sortAlphaTooltipId = useId();
const sortCountTooltipId = useId();
const { pageProps } = inject('pageContext');
const { channel: pageChannel } = pageProps;
@@ -160,6 +75,95 @@ const entities = computed(() => {
});
</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>
.filter-items {
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>
<ul class="countries nolist">
<li
@@ -19,27 +34,12 @@
v-if="selectedCountry === country.alpha2"
icon="cross2"
class="filter-remove"
@click.native.stop="emit('country', null)"
@click.stop="emit('country', null)"
/>
</li>
</ul>
</template>
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<style scoped>
.countries:not(:last-child) {
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>
<div
class="countries-container"
@@ -43,35 +72,6 @@
</div>
</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>
.countries-container {
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>
<div class="filters-frame">
<div
@@ -47,31 +72,6 @@
</div>
</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>
.filter {
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>
<ul class="filter genders nolist">
<li>
@@ -47,19 +60,6 @@
</ul>
</template>
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<style scoped>
.genders {
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>
<div class="filter">
<RangeFilter
@@ -31,16 +44,3 @@
</RangeFilter>
</div>
</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>
import Checkbox from '../form/checkbox.vue';
import Range from '../form/range.vue';
@@ -75,3 +39,39 @@ defineProps({
const emit = defineEmits(['change', 'input', 'enable']);
</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>
<div class="filter tags-container">
<div class="filters-sort">
@@ -15,7 +141,7 @@
<template v-if="isActorTagsAvailable">
<div
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"
@click="showActorTags = false"
>
@@ -24,7 +150,7 @@
<div
v-show="!showActorTags"
v-tooltip="'All tags'"
v-tooltip="{ content: 'All tags', ariaId: allTagsTooltipId }"
class="filter-sort order noselect"
@click="showActorTags = true"
>
@@ -34,6 +160,7 @@
<div
v-show="order === 'priority'"
v-tooltip="{ content: 'Sorted by tag priority', ariaId: sortPriorityTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
@@ -42,6 +169,7 @@
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
@@ -52,6 +180,7 @@
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'priority'"
>
@@ -119,125 +248,6 @@
</div>
</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>
.filter-items {
max-height: 15rem;

View File

@@ -1,3 +1,7 @@
<script setup>
const emit = defineEmits(['toggle']);
</script>
<template>
<div
class="filters-toggle open"
@@ -5,10 +9,6 @@
><Icon icon="filter" /></div>
</template>
<script setup>
const emit = defineEmits(['toggle']);
</script>
<style scoped>
.filters-toggle {
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>
import { ref } from 'vue';
@@ -50,6 +25,31 @@ function clearYears() {
}
</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>
.years-container {
display: flex;

View File

@@ -1,7 +1,13 @@
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<template>
<footer class="footer">
<span class="footer-segment">© traxxx</span>
<div class="footer-row">
<a
v-if="env.links.matrix"
:href="env.links.matrix"
@@ -23,28 +29,39 @@
:href="env.links.content"
target="_blank"
class="footer-segment footer-link"
>Content Removal / DMCA</a>
>Content Removal (DMCA)</a>
</div>
<div class="footer-row">
<span class="footer-segment">© traxxx v{{ env.version }}</span>
</div>
</footer>
</template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<style scoped>
.footer {
display: flex;
justify-content: center;
align-items: stretch;
flex-direction: column;
background: var(--background-base-20);
box-shadow: inset 0 0 3px var(--shadow-weak-30);
font-size: .8rem;
font-size: .75rem;
font-weight: bold;
}
.footer-row {
display: flex;
justify-content: center;
align-items: stretch;
&:first-child .footer-segment {
padding-top: .6rem;
}
&:not(:first-child) {
border-top: solid 1px var(--glass-weak-30);
}
}
.footer-segment {
display: inline-flex;
align-items: center;

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>
<nav class="nav-list noselect">
<a
@@ -37,15 +46,6 @@
</nav>
</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>
.nav-list {
display: flex;

View File

@@ -1,3 +1,26 @@
<script setup>
import { useId } from 'vue';
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
const uid = useId();
</script>
<template>
<label class="check-container noselect">
<span
@@ -22,27 +45,6 @@
</label>
</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>
.check-container {
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>
import {
ref,
computed,
nextTick,
ref,
} from 'vue';
const props = defineProps({
@@ -138,6 +85,59 @@ async function setRange(prop, value) {
}
</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>
.dark .range-container .range {
--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>
export default {
props: {
@@ -44,12 +21,35 @@ export default {
emits: ['change'],
data() {
return {
id: Math.floor(new Date().getTime() * Math.random()),
id: Math.floor(Date.now() * Math.random()),
};
},
};
</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>
@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>
<header class="header">
<nav class="nav">
@@ -92,6 +153,7 @@
>
<VDropdown
v-if="user"
:aria-id="notifsDropdownId"
:triggers="['click']"
:prevent-overflow="true"
class="notifs-trigger"
@@ -121,6 +183,7 @@
</VDropdown>
<VDropdown
:aria-id="menuDropdownId"
:triggers="['click']"
:prevent-overflow="true"
class="menu-trigger"
@@ -187,14 +250,59 @@
</a>
</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 menu-div"
>
<a
:href="`/user/${user.username}/alerts`"
class="menu-button nolink alerts"
>
<Icon icon="alarm" />
Alerts
</a>
</li>
<li
v-close-popper
class="menu-item menu-button"
@click="showSettings = true"
@touchstart="showSettings = true"
>
<Icon icon="equalizer" />
Settings
<Icon icon="filter" />
Filters
</li>
<li
v-if="theme === 'dark'"
v-close-popper
class="menu-item menu-button menu-div"
@click="setTheme('light')"
>
<Icon icon="sun3" />
Light theme
</li>
<li
v-else
v-close-popper
class="menu-item menu-button menu-div"
@click="setTheme('dark')"
>
<Icon icon="moon" />
Dark theme
</li>
<li
@@ -211,28 +319,6 @@
</a>
</li>
<li
v-if="theme === 'dark'"
v-close-popper
class="menu-item menu-button"
@click="setTheme('light')"
@touchstart="setTheme('light')"
>
<Icon icon="sun3" />
Light theme
</li>
<li
v-else
v-close-popper
class="menu-item menu-button"
@click="setTheme('dark')"
@touchstart="setTheme('dark')"
>
<Icon icon="moon" />
Dark theme
</li>
<li
v-if="user"
class="menu-button menu-item logout"
@@ -257,64 +343,6 @@
</header>
</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>
.header {
display: flex;
@@ -540,6 +568,7 @@ function blurSearch(event) {
align-items: center;
padding: .75rem .5rem .75rem .75rem;
font-size: 1.1rem;
cursor: pointer;
.icon {
fill: var(--glass);
@@ -549,7 +578,6 @@ function blurSearch(event) {
&:hover {
color: var(--primary);
cursor: pointer;
&:not(.logout) .icon {
fill: var(--primary);
@@ -561,6 +589,10 @@ function blurSearch(event) {
}
}
.menu-div {
border-bottom: solid 1px var(--glass-weak-30);
}
/*
.favorites .icon {
fill: var(--primary);

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>
<div class="menu">
<div class="notifs-header">
@@ -44,7 +108,15 @@
class="notif-body notif-link nolink"
>
<span class="notif-trigger">
<span class="notif-details">
<span
v-if="notif.matchedSceneIds.length > 0"
class="notif-details"
>Scene is released</span>
<span
v-else
class="notif-details"
>
New
<template
@@ -88,71 +160,6 @@
</div>
</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>
.notifs {
width: 30rem;

View File

@@ -1,17 +1,6 @@
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<script setup>
import { ref } from 'vue';
const svg = ref(null);
const props = defineProps({
icon: {
type: String,
@@ -27,11 +16,22 @@ const props = defineProps({
},
});
const svg = ref(null);
import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => {
svg.value = raw.default;
});
</script>
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<style>
.icon {
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>
import { inject } from 'vue';
@@ -19,3 +10,12 @@ defineProps({
const pageContext = inject('pageContext');
</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>
<div
class="banner-container"
@@ -49,7 +86,7 @@
<Icon
v-if="(release.trailer || release.teaser).isRestricted"
v-tooltip="'Restricted video'"
v-tooltip="{ content: 'Restricted video', ariaId: restrictedTooltipId }"
icon="blocked"
class="restricted"
/>
@@ -102,42 +139,6 @@
</div>
</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>
.banner-container {
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>
import { ref, inject } from 'vue';
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 TagsFilter from '#/components/filters/tags.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 MovieTile from '#/components/movies/tile.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 { pageProps, routeParams, urlParsed } = pageContext;
@@ -236,6 +141,115 @@ function updateFilter(prop, value, reload = true) {
}
</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>
.movies-page {
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>
<VDropdown
:aria-id="dropdownId"
:disabled="disabled"
class="trigger"
@show="focus"
@@ -36,39 +69,6 @@
</VDropdown>
</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>
.trigger {
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>
<div
class="movie-tile"
@@ -118,35 +147,6 @@
</div>
</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>
.movie-tile {
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>
<div class="pagination-container">
<Campaign
@@ -91,122 +207,6 @@
</div>
</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>
.pagination-container {
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>
<div
v-if="timeline"
@@ -43,19 +68,19 @@
<span class="chapter-details">
<span
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"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span
v-if="chapter.duration"
v-tooltip="'Duration'"
v-tooltip="{ content: 'Duration', ariaId: `chapter-duration-${chapter.id}` }"
class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time
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"
class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time>
@@ -90,31 +115,6 @@
</ul>
</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>
.chapters {
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>
<div class="meta">
<div class="channel">
@@ -44,21 +59,6 @@
</div>
</template>
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.meta {
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>
<div
class="scenes-page"
@@ -12,12 +204,19 @@
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="filters.search"
type="search"
type="input"
placeholder="Search scenes"
class="search input"
@search="search"
@keydown.enter="search"
>
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@@ -200,195 +399,6 @@
</div>
</template>
<script setup>
import {
ref,
computed,
inject,
} from 'vue';
import Cookies from 'js-cookie';
import { parse } from 'path-to-regexp';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
// import events from '#/src/events.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
import Filters from '#/components/filters/filters.vue';
import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import SceneTile from '#/components/scenes/tile.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
const props = defineProps({
showFilters: {
type: Boolean,
default: true,
},
showMeta: {
type: Boolean,
default: true,
},
showScopeTabs: {
type: Boolean,
default: false,
},
defaultScope: {
type: String,
default: 'latest',
},
});
const {
pageProps,
routeParams,
urlParsed,
campaigns,
env,
} = inject('pageContext');
const {
actor: pageActor,
tag: pageTag,
entity: pageEntity,
stash: pageStash,
} = pageProps;
const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const 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>
.scenes-page {
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>
<div class="editor">
<div class="editor-header">
@@ -89,159 +244,6 @@
</div>
</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>
.editor {
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>
<div
class="tile"
@@ -115,33 +142,6 @@
</div>
</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>
.tile {
display: flex;
@@ -243,8 +243,8 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
}
.actors {
height: 1.15rem;
margin-bottom: .15rem;
height: 1lh;
margin-bottom: .3rem;
overflow: hidden;
white-space: pre-wrap;
}

View File

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

View File

@@ -0,0 +1,76 @@
<script setup>
import Cookies from 'js-cookie';
import { ref } from '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>
<div class="dialog-section">
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
<ul class="tags nolist">
<li
v-for="(label, slug) in tags"
:key="`tag-${slug}`"
class="tags-item"
>
<label class="tag noselect">
<Checkbox
:value="slug"
:checked="checkedTags.has(slug)"
class="minus"
@change="(checked) => toggleTag(slug, checked)"
/>{{ label }}
</label>
</li>
</ul>
</div>
</template>
<style scoped>
.tags-item {
display: block;
}
.tag {
display: flex;
padding: .5rem 0;
text-transform: capitalize;
.check-container {
margin-right: 1rem;
}
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import { ref } from 'vue';
// import Checkbox from '#/components/form/checkbox.vue';
</script>
<template>
<div class="dialog-section">
<div class="setting">
Combine e-mail alerts
<select
class="input"
>
<option>Never</option>
<option>Daily</option>
<option>Weekly</option>
<option>Monthly</option>
</select>
</div>
</div>
</template>
<style scoped>
.setting {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -1,90 +1,141 @@
<script setup>
// this component is set up as a generic settings panel, but as most account settings will be handled through the profile page, it is parading as a dedicated filters dialog
import { inject, ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Filters from '#/components/settings/filters.vue';
import Notifications from '#/components/settings/notifications.vue';
defineProps({
showNavigation: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const activeSection = ref('filters');
</script>
<template>
<Dialog title="Settings">
<div class="dialog-body">
<div class="dialog-section">
<h3 class="dialog-heading">Filter</h3>
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
<ul class="tags nolist">
<li
v-for="(label, slug) in tags"
:key="`tag-${slug}`"
class="tags-item"
<Dialog
title="Filters"
class="settings-dialog"
>
<label class="tag noselect">
<Checkbox
:value="slug"
:checked="checkedTags.has(slug)"
class="minus"
@change="(checked) => toggleTag(slug, checked)"
/>{{ label }}
</label>
<div class="dialog-body">
<ul
v-if="!activeSection"
class="sections"
>
<li
class="section"
@click="activeSection = 'filters'"
>
<Icon icon="filter" />
Filters
</li>
<li
v-if="user"
class="section"
@click="activeSection = 'notifications'"
>
<Icon icon="bell2" />
Notifications
</li>
</ul>
<div
v-else-if="showNavigation"
class="section-header"
>
<span
class="section-back"
@click="activeSection = null"
><Icon icon="arrow-left3" />Back to overview</span>
<strong class="section-label">{{ activeSection }}</strong>
</div>
<Filters v-if="activeSection === 'filters'" />
<Notifications v-if="activeSection === 'notifications'" />
</div>
</Dialog>
</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>
.dialog-body {
width: 30rem;
flex-grow: 1;
max-width: 100%;
box-sizing: border-box;
padding: 1rem;
overflow-y: auto;
}
.tags-item {
display: block;
.dialog-section {
padding: 1rem;
}
.tag {
display: flex;
padding: .5rem 0;
text-transform: capitalize;
.sections {
padding: 0;
margin: 0;
}
.check-container {
margin-right: 1rem;
.section {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: .25rem;
font-size: 1.1rem;
.icon {
fill: var(--glass-strong-10);
transform: translateY(-1px);
}
&:hover {
color: var(--primary);
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
.section-header {
display: flex;
justify-content: space-between;
box-shadow: 0 0 3px var(--shadow-weak-30);
}
.section-back {
display: flex;
align-items: center;
gap: 1rem;
padding: .75rem 1rem;
.icon {
fill: var(--glass);
transform: translateY(-1px);
}
&:hover {
color: var(--primary);
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
.section-label {
color: var(--primary);
text-transform: capitalize;
padding: .75rem 1rem;
}
</style>

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>
<div
class="sidebar-container"
@@ -85,17 +96,6 @@
</div>
</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>
.sidebar-container {
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>
<Dialog
title="New stash"
@@ -23,33 +50,6 @@
</Dialog>
</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>
.dialog-body {
padding: 1rem;

View File

@@ -1,16 +1,222 @@
<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,
},
showAlert: {
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] }),
...(props.domain === 'scenes' && { scenes: [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>
<div
v-if="user"
class="bookmarks"
>
<div
v-if="showSecondary && !!itemAlerts"
v-if="showSecondary && showAlert && !!itemAlerts"
class="alert-trigger"
>
<Icon
v-if="itemAlerted"
v-tooltip="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
icon="bell2"
:title="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
class="alert active noselect"
@click="removeAlert"
@contextmenu.prevent="showAlertDialog = true"
@@ -18,8 +224,8 @@
<Icon
v-else-if="itemAlerts.multi.length > 0"
v-tooltip="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
icon="bell-plus"
:title="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
class="alert partial noselect"
@click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
@@ -27,8 +233,8 @@
<Icon
v-else
v-tooltip="domain === 'scenes' ? `Set alert for the release of '${item.title}'` : `Set alert for '${item.title || item.name}' releases`"
icon="bell-plus"
:title="`Set alert for '${item.title || item.name}' releases`"
class="alert noselect"
@click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
@@ -37,6 +243,7 @@
<VDropdown
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes"
@hide="showStashes = false"
>
@@ -60,6 +267,7 @@
<VDropdown
v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)"
:aria-id="stashTogglesDropdownId"
:triggers="[]"
:shown="showStashes"
:disabled="showSecondary"
@@ -70,7 +278,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)"
:icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'"
class="heart favorited noselect"
@click.native.stop="unstashItem(pageStash)"
@click.stop="unstashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
@@ -78,7 +286,7 @@
v-else
:icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'"
class="heart noselect"
@click.native.stop="stashItem(pageStash)"
@click.stop="stashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
</template>
@@ -88,7 +296,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)"
icon="heart7"
class="heart favorited noselect"
@click.native.stop="unstashItem(primaryStash)"
@click.stop="unstashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
@@ -96,7 +304,7 @@
v-else
icon="heart8"
class="heart noselect"
@click.native.stop="stashItem(primaryStash)"
@click.stop="stashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
</template>
@@ -127,196 +335,6 @@
</div>
</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>
.bookmarks {
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>
<ul class="stash-menu nolist noselect">
<li
@@ -26,23 +43,6 @@
</ul>
</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>
.menu-item {
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>
<div class="stash">
<div class="header">
@@ -44,18 +56,6 @@
</div>
</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>
.stash {
display: flex;

View File

@@ -1,8 +1,28 @@
<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>
<section class="profile-section">
<div class="section-header">
<h3 class="heading">Stashes</h3>
<button
v-if="profile.id === user?.id"
class="button"
@@ -34,29 +54,13 @@
</section>
</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>
.profile-section .section-header {
display: flex;
justify-content: center;
margin-bottom: .5rem;
}
.stashes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));

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>
<div class="stash">
<div class="stash-header">
@@ -16,12 +111,12 @@
<Icon
v-if="!stash.isPublic"
v-tooltip="'This stash is private'"
v-tooltip="{ content: 'This stash is private', ariaId: privateTooltipId }"
icon="eye-blocked"
class="private noselect"
/>
<VDropdown>
<VDropdown :aria-id="menuDropdownId">
<Icon
v-if="profile.id === user?.id"
icon="more2"
@@ -122,99 +217,6 @@
</div>
</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>
.stash {
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>
<a
v-if="photo?.entity"
v-tooltip="photo.entity.name"
v-tooltip="{ content: photo.entity.name, ariaId: entityTooltipId }"
:to="`/${photo.entity.type}/${photo.entity.slug}`"
>
<img
@@ -30,19 +47,6 @@
</a>
</template>
<script setup>
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.album-logo {
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>
<div class="photos nobar">
<Campaign
@@ -40,44 +78,6 @@
</div>
</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>
.photos {
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>
<VDropdown
:aria-id="dropdownId"
:disabled="disabled"
class="trigger"
@show="focus"
@@ -30,49 +75,6 @@
</VDropdown>
</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>
.trigger {
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>
import {
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue';
// import { useFloating } from '@floating-ui/vue';
import { computePosition, shift } from '@floating-ui/dom';
import {
nextTick,
onBeforeUnmount,
onMounted,
ref,
} from 'vue';
import events from '#/src/events.js';
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
const handle = ref(null);
const tooltip = ref(null);
const opened = ref(false);
@@ -53,13 +26,6 @@ const position = ref({
y: 0,
});
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
async function open() {
events.emit('blur');
opened.value = true;
@@ -97,7 +63,8 @@ function close() {
function toggle() {
if (opened.value) {
close();
} else {
}
else {
open();
}
}
@@ -111,6 +78,39 @@ onBeforeUnmount(() => {
});
</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>
.tooltip-layer {
position: absolute;

View File

@@ -1,16 +1,61 @@
<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>
<section class="profile-section">
<div class="section-header">
<h3 class="heading">API Keys</h3>
<div class="keys-actions">
<Icon
v-tooltip="'Flush all keys'"
icon="stack-cancel"
class="keys-flush"
@click="flushKeys"
/>
<button
class="button"
@click="createKey"
@@ -18,6 +63,13 @@
<Icon icon="key" />
<span class="button-label">New key</span>
</button>
<Icon
v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
icon="stack-cancel"
class="keys-flush"
@click="flushKeys"
/>
</div>
</div>
@@ -63,7 +115,7 @@
<Icon icon="plus-circle" />
<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()"
>{{ formatDistanceStrict(key.createdAt, now) }} ago</time>
</span>
@@ -73,7 +125,7 @@
<template v-if="key.lastUsedAt">
<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()"
>{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time>
</template>
@@ -99,60 +151,12 @@
</section>
</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>
.profile-section .section-header {
justify-content: center;
margin-bottom: 1rem;
}
.keys-header {
display: flex;
justify-content: space-between;
@@ -163,6 +167,7 @@ function copyKey(event) {
.keys-actions {
display: flex;
align-items: center;
gap: 1rem;
> .icon {
padding: .5rem 1rem;

View File

@@ -0,0 +1,94 @@
<script setup>
import { inject, reactive } from 'vue';
import Checkbox from '#/components/form/checkbox.vue';
import { patch } from '#/src/api.js';
const pageContext = inject('pageContext');
const { user } = pageContext;
const settings = reactive({
...user.settings,
});
async function updateSettings() {
await patch('/me/settings', settings);
}
</script>
<template>
<form
class="profile-section"
@submit.prevent
>
<div class="setting">
<span class="setting-label">
<strong class="setting-title">Enable alert e-mails</strong>
<span class="setting-description">Whether you receive e-mail alerts</span>
</span>
<span class="setting-value">
<Checkbox
:checked="settings.alertEmailEnabled"
@change="(checked) => { settings.alertEmailEnabled = checked; updateSettings(); }"
/>
</span>
</div>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">Alert e-mail frequency</strong>
<span class="setting-description">How often you receive your e-mail alerts</span>
</span>
<span class="setting-value">
<select
v-model="settings.alertEmailFrequency"
class="input"
@change="updateSettings"
>
<option :value="null">Immediately</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</span>
</label>
</form>
</template>
<style scoped>
.input {
background: var(--background);
}
.setting {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: .75rem 1rem;
border-bottom: solid 1px var(--glass-weak-30);
&:hover {
border-color: var(--glass-weak-20);
}
}
.setting-label {
display: flex;
flex-direction: column;
}
.setting-title {
color: var(--glass-strong-20);
margin-bottom: .25rem;
}
.setting-description {
color: var(--glass);
font-size: .9rem;
}
</style>

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

View File

@@ -33,6 +33,7 @@ module.exports = {
},
},
web: {
address: 'https://traxxx.me',
host: '0.0.0.0',
port: 5000,
sfwHost: '0.0.0.0',
@@ -147,7 +148,7 @@ module.exports = {
login: true,
signup: true,
usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/,
usernamePattern: /^[\w-]+$/,
captcha: {
enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001',
@@ -178,7 +179,7 @@ module.exports = {
},
stashes: {
nameLength: [2, 24],
namePattern: /^[a-zA-Z0-9!?$&\s_-]+$/,
namePattern: /^[\w!?$&\s-]+$/,
viewRefreshCrontab: '* * * * *', // every minute
viewRefreshCooldowns: {
actors: 60, // minutes
@@ -196,4 +197,15 @@ module.exports = {
enabled: true,
crontab: '*/10 * * * *', // every 10 minutes
},
alerts: {
crontab: '*/10 * * * *', // every 10 minutes
seenExpiry: 30, // days
finalExpiry: 365, // days
},
email: {
// Resend
enabled: false,
apiKey: null,
from: 'noreply@alerts.traxxx.me',
},
};

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',
},
},
);

33559
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,96 +4,100 @@
"prod": "npm run build && npm run server:prod",
"build": "vite build",
"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": {
"@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5",
"@dicebear/core": "^7.0.5",
"@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2",
"@brillout/json-serializer": "^0.5.25",
"@dicebear/collection": "7.0.5",
"@dicebear/core": "7.0.5",
"@faker-js/faker": "^10.5.0",
"@floating-ui/dom": "^1.7.6",
"@floating-ui/vue": "^2.0.0",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@maxmind/geoip2-node": "^6.3.4",
"@resvg/resvg-js": "^2.6.0",
"@maxmind/geoip2-node": "^7.0.0",
"@resvg/resvg-js": "^2.6.2",
"@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.10",
"@vue/server-renderer": "^3.3.10",
"@vueuse/components": "^10.7.1",
"@vueuse/core": "^10.7.1",
"compression": "^1.7.4",
"config": "^3.3.9",
"connect-redis": "^7.1.1",
"convert": "^4.14.1",
"cookie": "^0.6.0",
"cron": "^3.1.6",
"cross-env": "^7.0.3",
"date-fns": "^3.0.0",
"ejs": "^4.0.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vue-email/compiler": "^0.8.14",
"@vue-email/components": "^0.0.21",
"@vue-email/render": "^0.0.9",
"@vue/compiler-sfc": "^3.5.39",
"@vue/server-renderer": "^3.5.39",
"@vueuse/components": "^14.3.0",
"@vueuse/core": "^14.3.0",
"compression": "^1.8.1",
"config": "^4.4.2",
"connect-redis": "^9.0.0",
"convert": "^7.0.0",
"cookie": "^2.0.1",
"cron": "^4.4.0",
"cross-env": "^10.1.0",
"date-fns": "^4.4.0",
"ejs": "^6.0.1",
"error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0",
"express": "^4.18.2",
"express-promise-router": "^4.1.1",
"express-query-boolean": "^2.0.0",
"express-session": "^1.18.0",
"express": "^5.2.1",
"express-session": "^1.19.0",
"floating-vue": "^5.2.2",
"graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2",
"graphql": "^16.14.2",
"graphql-parse-resolve-info": "^4.14.1",
"graphql-scalars": "^1.25.0",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5",
"knex": "^3.1.0",
"manticoresearch": "^4.0.0",
"markdown-it": "^14.0.0",
"mathjs": "^12.2.1",
"merkle-json": "^2.6.0",
"ip-cidr": "^4.0.2",
"js-cookie": "^3.0.8",
"knex": "^3.3.0",
"manticoresearch": "^10.0.0",
"markdown-it": "^14.3.0",
"mathjs": "^15.2.0",
"merkle-json": "^2.15.0",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"mysql": "^2.18.1",
"nanoid": "^5.0.4",
"nanoid": "^5.1.16",
"object.omit": "^3.0.0",
"obscenity": "^0.4.6",
"path-to-regexp": "^6.2.1",
"pg": "^8.11.3",
"redis": "^4.6.12",
"sharp": "^0.32.6",
"sirv": "^2.0.3",
"path-to-regexp": "6.3.0",
"pg": "^8.22.0",
"redis": "^6.1.0",
"resend": "^6.17.2",
"sharp": "^0.35.3",
"sirv": "^3.0.2",
"template-format": "^1.2.5",
"unprint": "^0.14.1",
"video.js": "^8.10.0",
"vike": "^0.4.220",
"vite": "^6.1.0",
"vue": "^3.3.10",
"vue-virtual-scroller": "^2.0.0-beta.8",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"yaml": "^2.4.2",
"yargs": "^17.7.2"
"unprint": "^0.19.20",
"video.js": "^8.23.9",
"vike": "^0.4.260",
"vite": "^8.1.4",
"vue": "^3.5.39",
"vue-virtual-scroller": "^3.0.4",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"yaml": "^2.9.0",
"yargs": "^18.0.0"
},
"type": "module",
"devDependencies": {
"@babel/cli": "^7.23.4",
"@babel/core": "^7.23.6",
"@babel/eslint-parser": "^7.23.3",
"@babel/plugin-syntax-import-attributes": "^7.23.3",
"@babel/plugin-transform-optional-chaining": "^7.23.4",
"@babel/preset-env": "^7.23.6",
"@csstools/postcss-global-data": "^2.1.1",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.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"
"@antfu/eslint-config": "^9.1.0",
"@babel/cli": "^8.0.4",
"@babel/core": "^8.0.1",
"@babel/plugin-transform-optional-chaining": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@csstools/postcss-global-data": "^4.0.0",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"eslint": "^10.7.0",
"postcss-custom-media": "^12.0.1",
"postcss-nesting": "^14.0.0"
},
"overrides": {
"vite": "$vite"
},
"version": "0.50.13",
"version": "0.54.0",
"imports": {
"#/*": "./*.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>
<div class="page">
<div v-if="is404">
@@ -14,20 +28,6 @@
</div>
</template>
<script setup>
import { inject } from 'vue';
defineProps({
is404: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { abortReason } = pageContext;
</script>
<style scoped>
.page {
display: flex;

View File

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

View File

@@ -1,3 +1,26 @@
<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();
</script>
<template>
<div class="actor">
<div class="actor-header">
@@ -5,6 +28,17 @@
<span v-if="actor.entity">{{ actor.name }} ({{ actor.entity.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="actor.gender"
class="header-gender"
@@ -61,28 +95,6 @@
</div>
</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>
.actor {
display: flex;
@@ -111,6 +123,14 @@ const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits
flex-shrink: 0;
}
.header-alias.icon {
height: 1.75rem;
display: flex;
align-items: center;
padding: 0 1rem;
fill: var(--primary);
}
.header-social {
overflow: hidden;
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 { fetchScenes } from '#/src/scenes.js';
import { fetchMovies } from '#/src/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { getCampaignIndex, getRandomCampaigns } from '#/src/campaigns.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) {
if (pageContext.routeParams.domain === 'movies') {
@@ -41,12 +41,15 @@ export async function onBeforeRender(pageContext) {
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
}
console.time('page fetch actor');
const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user);
console.timeEnd('page fetch actor');
if (!actor) {
throw render(404, `Cannot find actor '${pageContext.routeParams.actorId}'.`);
}
console.time('page fetch actor assets');
const [actorReleases, campaigns, countries] = await Promise.all([
fetchReleases(pageContext),
getRandomCampaigns([
@@ -58,6 +61,11 @@ export async function onBeforeRender(pageContext) {
].filter(Boolean), { tagFilter: pageContext.tagFilter }),
isEditing && fetchCountries(),
]);
console.timeEnd('page fetch actor assets');
if (actor.alias && actorReleases.scenes.length === 0 && !Object.hasOwn(pageContext.urlParsed.search, 'inspect')) {
throw redirect(`/actor/${actor.alias.id}/${actor.alias.slug}`);
}
const campaignIndex = getCampaignIndex(actorReleases.limit);
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>
<div class="content">
<div class="revs-header">
@@ -21,15 +30,6 @@
</div>
</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>
.content {
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) {
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>
<div class="editor">
<p
@@ -55,11 +402,26 @@
>
<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
:href="`/actor/${actor.id}/${actor.slug}`"
target="_blank"
class="link noshrink"
>Go to actor</a>
</span>
</div>
<div
@@ -81,7 +443,7 @@
<Icon
v-if="item.note"
v-tooltip="item.note"
v-tooltip="{ content: item.note, ariaId: `field-note-${item.key}` }"
icon="info2"
class="item-note"
/>
@@ -354,346 +716,6 @@
</div>
</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>
.editor {
flex-grow: 1;
@@ -703,9 +725,21 @@ async function submit() {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.header-actions {
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
.link {
cursor: pointer;
}
}
.heading {
margin: 0;
}
@@ -946,5 +980,9 @@ async function submit() {
flex-grow: 1;
}
}
.editor-header {
flex-direction: column;
}
}
</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 { fetchCountries } from '#/src/countries.js';

View File

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

View File

@@ -1,3 +1,7 @@
<script setup>
import Admin from '#/components/admin/admin.vue';
</script>
<template>
<Admin>
<h2 class="heading">Admin Panel</h2>
@@ -34,10 +38,6 @@
</Admin>
</template>
<script setup>
import Admin from '#/components/admin/admin.vue';
</script>
<style scoped>
.menu {
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) {
if (pageContext.user.role === 'user') {

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