Compare commits
4
Commits
fab895b85e
...
479b044798
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
479b044798 | ||
|
|
7c6bde690f | ||
|
|
981205efaa | ||
|
|
b6408f3244 |
Executable
+6
@@ -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 |
Executable
+4
@@ -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 |
@@ -49,6 +49,17 @@ async function initCaches() {
|
||||
: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
|
||||
|
||||
@@ -320,16 +320,22 @@ function toggleFilterCombined() {
|
||||
<div class="alert-triggers">
|
||||
<Icon
|
||||
v-if="alert.notify && alert.isFromPreset"
|
||||
v-tooltip="{ content: 'Notify in traxxx, added as quick alert', ariaId: `alert-notify-preset-${alert.id}` }"
|
||||
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="{ content: 'Notify in traxxx', ariaId: `alert-notify-${alert.id}` }"
|
||||
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
|
||||
@@ -340,19 +346,11 @@ function toggleFilterCombined() {
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="alert.stashes.length > 0"
|
||||
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">
|
||||
|
||||
@@ -24,7 +24,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['alert', 'close']);
|
||||
|
||||
const { assets } = inject('pageContext');
|
||||
const {
|
||||
assets,
|
||||
user,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const actors = ref(props.presetActors);
|
||||
const actorResults = ref([]);
|
||||
@@ -462,8 +466,10 @@ function selectStash(selectedStash) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!--
|
||||
<label class="field email">
|
||||
<label
|
||||
v-if="env.emailEnabled && user.email"
|
||||
class="field email"
|
||||
>
|
||||
<span>E-mail me</span>
|
||||
|
||||
<Checkbox
|
||||
@@ -471,7 +477,6 @@ function selectStash(selectedStash) {
|
||||
@change="(checked) => email = checked"
|
||||
/>
|
||||
</label>
|
||||
-->
|
||||
|
||||
<div class="stash">
|
||||
<ul class="field-items nolist noselect">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -31,6 +33,7 @@ onMounted(() => emit('open'));
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
v-bind="$attrs"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dialog-header">
|
||||
|
||||
@@ -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 Blake’s 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 what’s 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, Vanessa’s thick body bouncing with every thrust. When she’s 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 Skylar’s 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 there’s 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(', ') }} </template>
|
||||
|
||||
<template
|
||||
v-if="alert.alert.entities.length > 0"
|
||||
>for {{ alert.alert.entities[0].name }} </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>
|
||||
@@ -265,7 +265,7 @@ function blurSearch(event) {
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-item"
|
||||
class="menu-item menu-div"
|
||||
>
|
||||
<a
|
||||
:href="`/user/${user.username}/alerts`"
|
||||
@@ -280,10 +280,29 @@ function blurSearch(event) {
|
||||
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
|
||||
@@ -300,28 +319,6 @@ function blurSearch(event) {
|
||||
</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"
|
||||
@@ -571,6 +568,7 @@ function blurSearch(event) {
|
||||
align-items: center;
|
||||
padding: .75rem .5rem .75rem .75rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
@@ -580,7 +578,6 @@ function blurSearch(event) {
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.logout) .icon {
|
||||
fill: var(--primary);
|
||||
@@ -592,6 +589,10 @@ function blurSearch(event) {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-div {
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
|
||||
/*
|
||||
.favorites .icon {
|
||||
fill: var(--primary);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,67 +1,66 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
import { ref } from 'vue';
|
||||
// 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 Checkbox from '#/components/form/checkbox.vue';
|
||||
import Filters from '#/components/settings/filters.vue';
|
||||
import Notifications from '#/components/settings/notifications.vue';
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
defineProps({
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
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 pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
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
|
||||
}
|
||||
const activeSection = ref('filters');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog title="Settings">
|
||||
<Dialog
|
||||
title="Filters"
|
||||
class="settings-dialog"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-section">
|
||||
<h3 class="dialog-heading">Filter</h3>
|
||||
<ul
|
||||
v-if="!activeSection"
|
||||
class="sections"
|
||||
>
|
||||
<li
|
||||
class="section"
|
||||
@click="activeSection = 'filters'"
|
||||
>
|
||||
<Icon icon="filter" />
|
||||
Filters
|
||||
</li>
|
||||
|
||||
<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>
|
||||
<li
|
||||
v-if="user"
|
||||
class="section"
|
||||
@click="activeSection = 'notifications'"
|
||||
>
|
||||
<Icon icon="bell2" />
|
||||
Notifications
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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
|
||||
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>
|
||||
@@ -69,23 +68,74 @@ function toggleTag(tag, isChecked) {
|
||||
<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>
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
web: {
|
||||
address: 'https://traxxx.me',
|
||||
host: '0.0.0.0',
|
||||
port: 5000,
|
||||
sfwHost: '0.0.0.0',
|
||||
@@ -203,6 +204,8 @@ module.exports = {
|
||||
},
|
||||
email: {
|
||||
// Resend
|
||||
enabled: false,
|
||||
apiKey: null,
|
||||
from: 'noreply@alerts.traxxx.me',
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+3602
-129
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -19,6 +19,9 @@
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@toycode/markdown-it-class": "^1.2.4",
|
||||
"@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",
|
||||
@@ -88,7 +91,7 @@
|
||||
"overrides": {
|
||||
"vite": "$vite"
|
||||
},
|
||||
"version": "0.53.0",
|
||||
"version": "0.54.0",
|
||||
"imports": {
|
||||
"#/*": "./*.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const { templates } = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<ul
|
||||
v-if="templates.length > 0"
|
||||
class="menu"
|
||||
>
|
||||
<li v-for="template in templates" :key="template">
|
||||
<a
|
||||
:href="`/admin/emails/${template}`"
|
||||
class="link"
|
||||
>{{ template }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else>No e-mail templates found in components/emails.</p>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
margin: 0;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
padding: .25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'E-mail Previews',
|
||||
pageProps: {
|
||||
templates: listEmailTemplates(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
} = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<div class="header">
|
||||
<h2 class="heading">{{ template }}</h2>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">Failed to render template: {{ error }}</p>
|
||||
|
||||
<template v-else>
|
||||
<iframe
|
||||
:srcdoc="html"
|
||||
class="frame"
|
||||
title="E-mail preview"
|
||||
/>
|
||||
|
||||
<details v-if="text" class="text">
|
||||
<summary>Plain-text version</summary>
|
||||
<pre>{{ text }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--warn);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.frame {
|
||||
width: 100%;
|
||||
min-height: 60rem;
|
||||
border: solid 1px var(--shadow-weak-30);
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-top: 1rem;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 1rem;
|
||||
background: var(--background-dark-20);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { emailTemplates, listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default async function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
const { template } = pageContext.routeParams;
|
||||
|
||||
if (!listEmailTemplates().includes(template)) {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
let html = null;
|
||||
let text = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
({ html, text } = await emailTemplates.render(`${template}.vue`));
|
||||
}
|
||||
catch (renderError) {
|
||||
error = renderError.message;
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: `E-mail Preview: ${template}`,
|
||||
pageProps: {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/emails/:template';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
const matched = urlMatch(pageContext.urlPathname);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
routeParams: {
|
||||
template: matched.params.template,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -27,7 +27,10 @@ function scrollHorizontal(event) {
|
||||
<div class="page">
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
<div class="user">
|
||||
<a
|
||||
:href="`/user/${profile.username}/`"
|
||||
class="user nolink"
|
||||
>
|
||||
<img
|
||||
v-if="profile.avatar"
|
||||
:src="profile.avatar"
|
||||
@@ -35,13 +38,13 @@ function scrollHorizontal(event) {
|
||||
>
|
||||
|
||||
<h2 class="username ellipsis">{{ profile.username }}</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<span class="age">{{ formatDistanceStrict(Date.now(), profile.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="profile.id === user?.id"
|
||||
v-if="!section && profile.id === user?.id"
|
||||
class="domains nobar"
|
||||
@wheel.prevent="scrollHorizontal"
|
||||
>
|
||||
@@ -49,38 +52,38 @@ function scrollHorizontal(event) {
|
||||
:href="`/user/${profile.username}/stashes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'stashes' }"
|
||||
>Stashes</a>
|
||||
><Icon icon="folder-heart" />Stashes</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/alerts`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'alerts' }"
|
||||
>Alerts</a>
|
||||
><Icon icon="alarm" />Alerts</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/templates`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'templates' }"
|
||||
>Templates</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/scenes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'scenes' }"
|
||||
>Scene Revisions</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/actors`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'actors' }"
|
||||
>Actor Revisions</a>
|
||||
><Icon icon="paste3" />Templates</a>
|
||||
|
||||
<a
|
||||
v-if="profile.isIdentityVerified"
|
||||
:href="`/user/${profile.username}/api`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'api' }"
|
||||
>API Keys</a>
|
||||
><Icon icon="key" />API Keys</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/scenes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'scenes' }"
|
||||
><Icon icon="file-video2" />Scene Revisions</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/actors`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'actors' }"
|
||||
><Icon icon="profile" />Actor Revisions</a>
|
||||
</nav>
|
||||
|
||||
<Stashes v-if="section === 'stashes'" />
|
||||
@@ -160,6 +163,7 @@ function scrollHorizontal(event) {
|
||||
color: var(--highlight-strong-30);
|
||||
background: var(--shadow-strong-30);
|
||||
border-radius: 0 0 .5rem .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.user {
|
||||
@@ -193,25 +197,41 @@ function scrollHorizontal(event) {
|
||||
}
|
||||
|
||||
.domains {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr));
|
||||
gap: .5rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem 0;
|
||||
margin-top: .25rem;
|
||||
overflow-x: auto;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.domain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--glass-strong-20);
|
||||
background: var(--background-dark-20);
|
||||
padding: .5rem 1rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-radius: 1rem;
|
||||
font-size: .9rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
|
||||
&.active {
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
transform: translateY(-1px);
|
||||
fill: var(--glass-strong-20);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
|
||||
.icon {
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,17 @@ const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
const matched = urlMatch(pageContext.urlPathname);
|
||||
const isSelf = pageContext.user?.username === matched.params?.username;
|
||||
|
||||
if (matched) {
|
||||
if (![undefined, 'stashes'].includes(matched.params.domain) && pageContext.user?.username !== matched.params.username) {
|
||||
throw redirect(`/user/${matched.params.username}`);
|
||||
if (!isSelf && matched.params.section !== 'stashes') {
|
||||
throw redirect(`/user/${matched.params.username}/stashes`);
|
||||
}
|
||||
|
||||
return {
|
||||
routeParams: {
|
||||
username: matched.params.username,
|
||||
section: matched.params.section || 'stashes',
|
||||
section: matched.params.section || '',
|
||||
domain: matched.params.domain || 'scenes',
|
||||
},
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
+127
-22
@@ -1,9 +1,12 @@
|
||||
import config from 'config';
|
||||
import { CronJob } from 'cron';
|
||||
import escapeRegexp from 'escape-string-regexp';
|
||||
import markdownIt from 'markdown-it';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import { getIdsBySlug } from './cache.js';
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger from './logger.js';
|
||||
@@ -11,6 +14,25 @@ import { indexApi } from './manticore.js';
|
||||
import { fetchScenesById } from './scenes.js';
|
||||
|
||||
const logger = initLogger();
|
||||
const resend = new Resend(config.email.apiKey);
|
||||
|
||||
function getImagePath(media) {
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.isS3) {
|
||||
return new URL(media.lazy, config.media.s3Path).href;
|
||||
}
|
||||
|
||||
return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href;
|
||||
}
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
});
|
||||
|
||||
md.disable('code');
|
||||
|
||||
function curateAlert(alert, context = {}) {
|
||||
return {
|
||||
@@ -400,12 +422,68 @@ function groupByKey(data, byKey, itemKey) {
|
||||
acc[item[byKey]] = [];
|
||||
}
|
||||
|
||||
acc[item[byKey]].push(item[itemKey]);
|
||||
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function sendEmailAlerts(triggers, scenes) {
|
||||
if (!config.email.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
|
||||
|
||||
const triggersByUserId = Object.values(triggers // prevent multiple emails for the same scene
|
||||
.filter((trigger) => trigger.alert.email && trigger.alert.userEmail)
|
||||
.reduce((acc, trigger) => {
|
||||
if (!acc[trigger.alert.userId]) {
|
||||
acc[trigger.alert.userId] = {
|
||||
userId: trigger.alert.userId,
|
||||
username: trigger.alert.username,
|
||||
userEmail: trigger.alert.userEmail,
|
||||
alerts: [],
|
||||
};
|
||||
}
|
||||
|
||||
acc[trigger.alert.userId].alerts.push({
|
||||
...trigger,
|
||||
scene: scenesBySceneId[trigger.sceneId] || null,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {}));
|
||||
|
||||
const emails = await Promise.all(triggersByUserId.map(async (trigger) => {
|
||||
const { html, text } = await emailTemplates.render('alert.vue', {
|
||||
props: {
|
||||
trigger,
|
||||
address: config.web.address,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to: [trigger.userEmail],
|
||||
subject: `Notification for ${trigger.alerts.length} scenes on traxxx`,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}));
|
||||
|
||||
const { data, error } = await resend.batch.send(emails);
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`);
|
||||
}
|
||||
else {
|
||||
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
|
||||
}
|
||||
}
|
||||
|
||||
const genderOrder = ['female', 'transsexual', 'male'];
|
||||
|
||||
export async function notify(sceneIds) {
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
@@ -419,21 +497,36 @@ export async function notify(sceneIds) {
|
||||
alertsMatches,
|
||||
alertsStashes,
|
||||
] = await Promise.all([
|
||||
knex('releases_actors').whereIn('release_id', sceneIds),
|
||||
knex('releases_tags').whereIn('release_id', sceneIds),
|
||||
knex('alerts'),
|
||||
knex('alerts_actors'),
|
||||
knex('alerts_tags'),
|
||||
knex('alerts_entities'),
|
||||
knex('releases_actors')
|
||||
.select('releases_actors.*', 'actors.name as actor_name', 'actors.gender as actor_gender')
|
||||
.whereIn('release_id', sceneIds)
|
||||
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id'),
|
||||
knex('releases_tags')
|
||||
.select('releases_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug')
|
||||
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
|
||||
.whereIn('release_id', sceneIds)
|
||||
.orderBy('tags.priority', 'desc'),
|
||||
knex('alerts')
|
||||
.select('alerts.*', 'users.username', 'users.email as user_email')
|
||||
.leftJoin('users', 'users.id', 'alerts.user_id'),
|
||||
knex('alerts_actors')
|
||||
.select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug')
|
||||
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
|
||||
knex('alerts_tags')
|
||||
.select('alerts_tags.*', 'tags.name as tag_name')
|
||||
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
|
||||
knex('alerts_entities')
|
||||
.select('alerts_entities.*', 'entities.name as entity_name')
|
||||
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
|
||||
knex('alerts_matches'),
|
||||
knex('alerts_stashes'),
|
||||
]);
|
||||
|
||||
const actorIdsByReleaseId = groupByKey(releasesActors, 'release_id', 'actor_id');
|
||||
const tagIdsByReleaseId = groupByKey(releasesTags, 'release_id', 'tag_id');
|
||||
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
|
||||
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
|
||||
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
|
||||
const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null);
|
||||
const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null);
|
||||
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null);
|
||||
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null);
|
||||
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null);
|
||||
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
|
||||
|
||||
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
|
||||
@@ -452,6 +545,8 @@ export async function notify(sceneIds) {
|
||||
const alerts = rawAlerts.map((alert) => ({
|
||||
id: alert.id,
|
||||
userId: alert.user_id,
|
||||
username: alert.username,
|
||||
userEmail: alert.user_email,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
all: alert.all,
|
||||
@@ -459,23 +554,31 @@ export async function notify(sceneIds) {
|
||||
allEntities: alert.all_entities,
|
||||
allTags: alert.all_tags,
|
||||
allMatches: alert.all_matches,
|
||||
actors: alertsActorsByAlertId[alert.id] || [],
|
||||
tags: alertsTagsByAlertId[alert.id] || [],
|
||||
entities: alertsEntitiesByAlertId[alert.id] || [],
|
||||
actors: alertsActorsByAlertId[alert.id]
|
||||
?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender }))
|
||||
.toSorted((actorA, actorB) => genderOrder.indexOf(actorA.gender) - genderOrder.indexOf(actorB.gender)) || [],
|
||||
tags: alertsTagsByAlertId[alert.id]?.map((tag) => ({ id: tag.tag_id, name: tag.tag_name })) || [],
|
||||
entities: alertsEntitiesByAlertId[alert.id]?.map((entity) => ({ id: entity.entity_id, name: entity.entity_name })) || [],
|
||||
matches: alertsMatchesByAlertId[alert.id] || [],
|
||||
stashes: alertsStashesByAlertId[alert.id] || [],
|
||||
stashIds: alertsStashesByAlertId[alert.id] || [],
|
||||
}));
|
||||
|
||||
const curatedScenes = scenes
|
||||
.filter((scene) => scene.isNew)
|
||||
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
|
||||
.map((scene) => ({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
slug: scene.slug,
|
||||
date: scene.date,
|
||||
description: scene.description,
|
||||
actorIds: actorIdsByReleaseId[scene.id] || [],
|
||||
tagIds: tagIdsByReleaseId[scene.id] || [],
|
||||
actors: (actorsByReleaseId[scene.id] || []).filter((actor) => !!actor.actor_name).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })),
|
||||
actorIds: (actorsByReleaseId[scene.id] || []).map((actor) => actor.actor_id),
|
||||
tagIds: (tagsByReleaseId[scene.id] || []).map((tag) => tag.id),
|
||||
tags: (tagsByReleaseId[scene.id] || []).filter((tag) => !!tag.tag_name).map((tag) => ({ slug: tag.tag_slug, name: tag.tag_name })),
|
||||
entityId: scene.channel?.id || scene.network?.id || null,
|
||||
parentEntityId: scene.network?.id,
|
||||
posterUrl: getImagePath(scene.poster),
|
||||
}));
|
||||
|
||||
const triggers = alerts.flatMap((alert) => {
|
||||
@@ -489,16 +592,16 @@ export async function notify(sceneIds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actor) => scene.actorIds.includes(actor.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entities.length > 0 && !alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) {
|
||||
if (alert.entities.length > 0 && !alert.entities.some((alertEntity) => alertEntity.id === scene.entityId || alertEntity.id === scene.parentEntityId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -544,7 +647,7 @@ export async function notify(sceneIds) {
|
||||
scene_id: trigger.sceneId,
|
||||
}));
|
||||
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashes.map((stashId) => ({
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
|
||||
stashId,
|
||||
sceneId: trigger.sceneId,
|
||||
userId: trigger.alert.userId,
|
||||
@@ -593,6 +696,8 @@ export async function notify(sceneIds) {
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailAlerts(triggers, curatedScenes);
|
||||
|
||||
return triggers;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
||||
|
||||
export const emailsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../components/emails');
|
||||
|
||||
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
|
||||
|
||||
export function listEmailTemplates() {
|
||||
return readdirSync(emailsDir)
|
||||
.filter((file) => file.endsWith('.vue'))
|
||||
.map((file) => file.replace(/\.vue$/, ''));
|
||||
}
|
||||
+8
-4
@@ -14,7 +14,7 @@ function curateTemplate(template) {
|
||||
};
|
||||
}
|
||||
|
||||
export function curateUser(user, _assets = {}) {
|
||||
export function curateUser(user, _assets = {}, reqUser) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
@@ -24,13 +24,17 @@ export function curateUser(user, _assets = {}) {
|
||||
const curatedUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isEmailVerified: user.email_verified,
|
||||
isIdentityVerified: user.identity_verified,
|
||||
avatar: `/media/avatars/${user.id}_${user.username}.png`,
|
||||
role: user.role,
|
||||
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
||||
createdAt: user.created_at,
|
||||
...(reqUser?.id === user.id
|
||||
? {
|
||||
email: user.email,
|
||||
}
|
||||
: null),
|
||||
};
|
||||
|
||||
return curatedUser;
|
||||
@@ -50,7 +54,7 @@ function whereUser(builder, userId, options = {}) {
|
||||
builder.where('users.id', Number(userId));
|
||||
}
|
||||
|
||||
export async function fetchUser(userId, options = {}, _reqUser) {
|
||||
export async function fetchUser(userId, options = {}, reqUser) {
|
||||
const user = await knex('users')
|
||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
||||
.modify((builder) => whereUser(builder, userId, options))
|
||||
@@ -68,7 +72,7 @@ export async function fetchUser(userId, options = {}, _reqUser) {
|
||||
}
|
||||
|
||||
// return curateUser(user, { stashes, templates });
|
||||
return curateUser(user, {});
|
||||
return curateUser(user, {}, reqUser);
|
||||
}
|
||||
|
||||
export async function fetchUserTemplates(reqUser) {
|
||||
|
||||
+6
-5
@@ -1,3 +1,4 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { Router } from 'express';
|
||||
import omit from 'object.omit';
|
||||
|
||||
@@ -44,12 +45,12 @@ export async function fetchActorsApi(req, res) {
|
||||
order: req.query.order?.split('.') || ['likes', 'desc'],
|
||||
}, req.user);
|
||||
|
||||
res.send({
|
||||
res.send(stringify({
|
||||
actors,
|
||||
countries,
|
||||
limit,
|
||||
total,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export const actorsSchema = `
|
||||
@@ -188,7 +189,7 @@ export async function fetchActorsByIdGraphql(query, _req, _info) {
|
||||
export async function createActorApi(req, res) {
|
||||
const actor = await createActor(req.body.actor, omit(req.body, ['actor']), req.user);
|
||||
|
||||
res.send({ actor });
|
||||
res.send(stringify({ actor }));
|
||||
}
|
||||
|
||||
export async function mergeActorsApi(req, res) {
|
||||
@@ -199,13 +200,13 @@ export async function mergeActorsApi(req, res) {
|
||||
req.user,
|
||||
);
|
||||
|
||||
res.send(result);
|
||||
res.send(stringify(result));
|
||||
}
|
||||
|
||||
async function fetchActorRevisionsApi(req, res) {
|
||||
const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user);
|
||||
|
||||
res.send(revisions);
|
||||
res.send(stringify(revisions));
|
||||
}
|
||||
|
||||
async function createActorRevisionApi(req, res) {
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
@@ -146,7 +147,7 @@ export const alertsSchema = `
|
||||
export async function fetchAlertsApi(req, res) {
|
||||
const alerts = await fetchAlerts(req.user);
|
||||
|
||||
res.send(alerts);
|
||||
res.send(stringify(alerts));
|
||||
}
|
||||
|
||||
export async function fetchAlertsGraphql(query, req) {
|
||||
@@ -158,7 +159,7 @@ export async function fetchAlertsGraphql(query, req) {
|
||||
export async function createAlertApi(req, res) {
|
||||
const alert = await createAlert(req.body, req.user);
|
||||
|
||||
res.send(alert);
|
||||
res.send(stringify(alert));
|
||||
}
|
||||
|
||||
export async function createAlertGraphql(query, req) {
|
||||
@@ -184,7 +185,7 @@ export async function fetchNotificationsApi(req, res) {
|
||||
limit: req.query.limit || 10,
|
||||
});
|
||||
|
||||
res.send(notifications);
|
||||
res.send(stringify(notifications));
|
||||
}
|
||||
|
||||
export async function fetchNotificationsGraphql(query, req) {
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@ export async function loginApi(req, res) {
|
||||
const user = await login(req.body, req.userIp);
|
||||
|
||||
req.session.user = user;
|
||||
res.send(user);
|
||||
res.send(stringify(user));
|
||||
}
|
||||
|
||||
export async function logoutApi(req, res) {
|
||||
@@ -84,7 +84,7 @@ export async function signupApi(req, res) {
|
||||
const user = await signup(req.body, req.userIp);
|
||||
|
||||
req.session.user = user;
|
||||
res.send(user);
|
||||
res.send(stringify(user));
|
||||
}
|
||||
|
||||
export async function fetchUserKeysApi(req, res) {
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { parseResolveInfo } from 'graphql-parse-resolve-info';
|
||||
|
||||
import { getIdsBySlug } from '../cache.js';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
export async function fetchEntitiesApi(req, res) {
|
||||
const entities = await fetchEntities(req.query);
|
||||
|
||||
res.send(entities);
|
||||
res.send(stringify(entities));
|
||||
}
|
||||
|
||||
export const entitiesSchema = `
|
||||
|
||||
+6
-4
@@ -1,3 +1,5 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
|
||||
import argv from '../argv.js';
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
@@ -11,16 +13,16 @@ export default function errorHandler(error, req, res, _next) {
|
||||
}
|
||||
|
||||
if (error.httpCode) {
|
||||
res.status(error.httpCode).send({
|
||||
res.status(error.httpCode).send(stringify({
|
||||
statusCode: error.httpCode,
|
||||
statusMessage: error.message,
|
||||
});
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send({
|
||||
res.status(500).send(stringify({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Oops... our server messed up. We will be investigating this incident, our apologies for the inconvenience.',
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export default async function mainHandler(req, res, next) {
|
||||
psa: config.psa,
|
||||
links: config.links,
|
||||
socials,
|
||||
emailEnabled: config.email.enabled,
|
||||
captcha: {
|
||||
enabled: config.auth.captcha.enabled,
|
||||
siteKey: config.auth.captcha.siteKey,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ export async function fetchMovieApi(req, res) {
|
||||
throw new HttpError(`No movie with ID ${req.params.movieId} found`, 404);
|
||||
}
|
||||
|
||||
res.send(movie);
|
||||
res.send(stringify(movie));
|
||||
}
|
||||
|
||||
export const moviesSchema = `
|
||||
|
||||
+2
-2
@@ -261,7 +261,7 @@ async function fetchSceneApi(req, res) {
|
||||
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
||||
}
|
||||
|
||||
res.send(scene);
|
||||
res.send(stringify(scene));
|
||||
}
|
||||
|
||||
export async function fetchScenesByIdGraphql(query, req) {
|
||||
@@ -281,7 +281,7 @@ export async function fetchScenesByIdGraphql(query, req) {
|
||||
async function fetchSceneRevisionsApi(req, res) {
|
||||
const revisions = await fetchSceneRevisions(Number(req.params.revisionId) || null, req.query, req.user);
|
||||
|
||||
res.send(revisions);
|
||||
res.send(stringify(revisions));
|
||||
}
|
||||
|
||||
async function createSceneRevisionApi(req, res) {
|
||||
|
||||
+10
-9
@@ -1,3 +1,4 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { Router } from 'express';
|
||||
|
||||
import {
|
||||
@@ -100,7 +101,7 @@ export const stashesSchema = `
|
||||
export async function fetchUserStashesApi(req, res) {
|
||||
const stashes = await fetchUserStashes(req.user.id, req.user);
|
||||
|
||||
res.send(stashes);
|
||||
res.send(stringify(stashes));
|
||||
}
|
||||
|
||||
export async function fetchUserStashesGraphql(query, req) {
|
||||
@@ -118,7 +119,7 @@ export async function fetchStashGraphql(query, req) {
|
||||
export async function createStashApi(req, res) {
|
||||
const stash = await createStash(req.body, req.user);
|
||||
|
||||
res.send(stash);
|
||||
res.send(stringify(stash));
|
||||
}
|
||||
|
||||
export async function createStashGraphql(query, req) {
|
||||
@@ -130,7 +131,7 @@ export async function createStashGraphql(query, req) {
|
||||
export async function updateStashApi(req, res) {
|
||||
const stash = await updateStash(Number(req.params.stashId), req.body, req.user);
|
||||
|
||||
res.send(stash);
|
||||
res.send(stringify(stash));
|
||||
}
|
||||
|
||||
export async function updateStashGraphql(query, req) {
|
||||
@@ -154,7 +155,7 @@ export async function removeStashGraphql(query, req) {
|
||||
export async function stashActorApi(req, res) {
|
||||
const stashed = await stashActor(req.body.actorId, Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(stashed);
|
||||
res.send(stringify(stashed));
|
||||
}
|
||||
|
||||
export async function stashActorGraphql(query, req) {
|
||||
@@ -166,7 +167,7 @@ export async function stashActorGraphql(query, req) {
|
||||
export async function unstashActorApi(req, res) {
|
||||
const unstashed = await unstashActor(Number(req.params.actorId), Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(unstashed);
|
||||
res.send(stringify(unstashed));
|
||||
}
|
||||
|
||||
export async function unstashActorGraphql(query, req) {
|
||||
@@ -178,7 +179,7 @@ export async function unstashActorGraphql(query, req) {
|
||||
export async function stashSceneApi(req, res) {
|
||||
const stashed = await stashScene(req.body.sceneId, Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(stashed);
|
||||
res.send(stringify(stashed));
|
||||
}
|
||||
|
||||
export async function stashSceneGraphql(query, req) {
|
||||
@@ -190,7 +191,7 @@ export async function stashSceneGraphql(query, req) {
|
||||
export async function unstashSceneApi(req, res) {
|
||||
const unstashed = await unstashScene(Number(req.params.sceneId), Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(unstashed);
|
||||
res.send(stringify(unstashed));
|
||||
}
|
||||
|
||||
export async function unstashSceneGraphql(query, req) {
|
||||
@@ -202,7 +203,7 @@ export async function unstashSceneGraphql(query, req) {
|
||||
export async function stashMovieApi(req, res) {
|
||||
const stashed = await stashMovie(req.body.movieId, Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(stashed);
|
||||
res.send(stringify(stashed));
|
||||
}
|
||||
|
||||
export async function stashMovieGraphql(query, req) {
|
||||
@@ -214,7 +215,7 @@ export async function stashMovieGraphql(query, req) {
|
||||
export async function unstashMovieApi(req, res) {
|
||||
const unstashed = await unstashMovie(Number(req.params.movieId), Number(req.params.stashId), req.user);
|
||||
|
||||
res.send(unstashed);
|
||||
res.send(stringify(unstashed));
|
||||
}
|
||||
|
||||
export async function unstashMovieGraphql(query, req) {
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
|
||||
import { fetchTags } from '../tags.js';
|
||||
|
||||
export async function fetchTagsApi(req, res) {
|
||||
@@ -7,5 +9,5 @@ export async function fetchTagsApi(req, res) {
|
||||
restriction: req.restriction,
|
||||
});
|
||||
|
||||
res.send(tags);
|
||||
res.send(stringify(tags));
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ async function fetchUserApi(req, res) {
|
||||
async function fetchUserTemplatesApi(req, res) {
|
||||
const templates = await fetchUserTemplates(req.user);
|
||||
|
||||
res.send(templates);
|
||||
res.send(stringify(templates));
|
||||
}
|
||||
|
||||
async function createTemplateApi(req, res) {
|
||||
|
||||
Reference in New Issue
Block a user