Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ac366e87 | ||
|
|
a5e06b51ea | ||
|
|
b3dcb65871 | ||
|
|
4527bf2495 | ||
|
|
6c0e4d5456 | ||
|
|
8dd136446d | ||
|
|
1cc94d665a | ||
|
|
a9d9b7623d | ||
|
|
83c0f1e4c6 | ||
|
|
64b92536d5 | ||
|
|
cea5b98bda | ||
|
|
411cde7cf9 | ||
|
|
73d65ae873 | ||
|
|
b5e9886b9b | ||
|
|
cd10f14285 | ||
|
|
514ed235a4 | ||
|
|
00c6d17aaa | ||
|
|
de0b1c2baf | ||
|
|
e9b7bb03d0 | ||
|
|
1e7207160c | ||
|
|
37a919770a | ||
|
|
4e59994806 | ||
|
|
699f7f0944 | ||
|
|
bf220f6b4e | ||
|
|
02ee99d52f | ||
|
|
e50dedca66 | ||
|
|
760025f414 | ||
|
|
07d9dfaaaa | ||
|
|
d3f549c92f | ||
|
|
bf64a3d30e | ||
|
|
b27c4811da | ||
|
|
c462c2b11a | ||
|
|
a774254e71 | ||
|
|
0a665ab553 | ||
|
|
c2d8cc3a40 | ||
|
|
eed07ba84b | ||
|
|
e8f1dca2a0 | ||
|
|
4037098927 | ||
|
|
e007dd2b83 | ||
|
|
b86cf8e30f | ||
|
|
e46f174a1e | ||
|
|
32b8f00308 | ||
|
|
c9e698c906 | ||
|
|
286248e403 | ||
|
|
f9e03f47ba | ||
|
|
b54174eb5e | ||
|
|
62d19d1a04 | ||
|
|
6b3866a99f | ||
|
|
18c30b6f98 | ||
|
|
77b27a56f7 | ||
|
|
4c48700086 | ||
|
|
6f68a50eb7 | ||
|
|
ef9be8ef9a | ||
|
|
a81343c519 | ||
|
|
60ad068939 | ||
|
|
df6965cb3a | ||
|
|
9e95bec659 | ||
|
|
88b866c96a | ||
|
|
5784c08e1b | ||
|
|
753840f4f1 | ||
|
|
dbe27d68d3 | ||
|
|
9b058fd44b | ||
|
|
17d34f6a80 | ||
|
|
17fd285d67 | ||
|
|
097baed957 | ||
|
|
b983fbaa1f | ||
|
|
afe337d44a | ||
|
|
6fe8c84ef4 | ||
|
|
e71a6987fc | ||
|
|
938878d860 | ||
|
|
04abff06ec | ||
|
|
52d5a314de | ||
|
|
5b17eb15a2 | ||
|
|
49ef28a5bf | ||
|
|
0d481e7798 | ||
|
|
827566d57d | ||
|
|
6444cf21eb | ||
|
|
216e7f59ef | ||
|
|
479b044798 | ||
|
|
7c6bde690f | ||
|
|
981205efaa | ||
|
|
b6408f3244 | ||
|
|
fab895b85e | ||
|
|
9d869c4a37 | ||
|
|
583b6984d8 | ||
|
|
ad912e8f03 | ||
|
|
fe1170b22e | ||
|
|
b69e0a104c | ||
|
|
e23bc927e2 | ||
|
|
29a17b2e94 |
@@ -8,3 +8,5 @@
|
||||
@custom-media --small (max-width: 900px);
|
||||
@custom-media --compact (max-width: 1200px);
|
||||
@custom-media --big (max-width: 1500px);
|
||||
@custom-media --big-10 (max-width: 1750px);
|
||||
@custom-media --big-20 (max-width: 2000px);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.input {
|
||||
width: 15rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .75rem;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -65,3 +65,12 @@
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Content */
|
||||
|
||||
.v-popper__popper {
|
||||
z-index: 10000;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
outline: none;
|
||||
@@ -45,6 +45,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-outer-scroll .v-popper__inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-popper__inner > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -82,6 +86,10 @@
|
||||
border-width: 6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-container {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
|
||||
left: -2px;
|
||||
@@ -105,7 +113,7 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
|
||||
top: 0;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner,
|
||||
@@ -151,7 +159,11 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-container {
|
||||
right: -10px;
|
||||
right: -7px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-container {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- 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="M15.89 10.188l-4-5c-0.172-0.216-0.487-0.251-0.703-0.078s-0.251 0.487-0.078 0.703l3.89 4.863v0.325h-3.5l-2 2h-3l-2-2h-3.5v-0.325l3.89-4.863c0.173-0.216 0.138-0.53-0.078-0.703s-0.53-0.138-0.703 0.078l-4 5c-0.071 0.089-0.11 0.199-0.11 0.312v4.5c0 0.552 0.448 1 1 1h14c0.552 0 1-0.448 1-1v-4.5c0-0.114-0.039-0.224-0.11-0.312z"></path>
|
||||
<path d="M8 0.5l-3.5 3.5h2.5v5h2v-5h2.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -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 |
@@ -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 |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 7.5292969 L 11.556641 6.46875 A 0.14617623 0.14617623 0 0 0 11.556641 6.4667969 C 11.601003 6.4299705 11.650097 6.3991617 11.701172 6.375 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="M 16,5 C 16,2.243 13.757,0 11,0 8.243,0 6,2.243 6,5 V 5.5 C 6,5.776 6.224,6 6.5,6 6.776,6 7,5.776 7,5.5 V 5 c 0,-2.206 1.794,-4 4,-4 2.206,0 4,1.794 4,4 0,1.86 -1.277,3.428 -3,3.873 V 7 L 9,9.5 12,12 V 9.899 C 14.279,9.434 16,7.414 16,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 6.3671875 C 10.340222 6.3923929 10.394355 6.4261166 10.443359 6.4667969 A 0.14617623 0.14617623 0 0 1 10.443359 6.46875 L 11.701172 7.5175781 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="m 6,5 c 0,-2.757 2.243,-5 5,-5 2.757,0 5,2.243 5,5 V 5.5 C 16,5.776 15.776,6 15.5,6 15.224,6 15,5.776 15,5.5 V 5 C 15,2.794 13.206,1 11,1 8.794,1 7,2.794 7,5 7,6.86 8.277,8.428 10,8.873 V 7 L 13,9.5 10,12 V 9.899 C 7.721,9.434 6,7.414 6,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 14.334849,4.1517112 H 11.883382 V 1.785868 c 0,-0.1187227 -0.09983,-0.2150766 -0.222859,-0.2150766 h -1.337165 c -0.12302,0 -0.222861,0.096353 -0.222861,0.2150766 V 4.1517112 H 7.6490291 c -0.1230181,0 -0.2228598,0.096353 -0.2228598,0.2150768 v 1.2904597 c 0,0.1187229 0.099841,0.2150767 0.2228598,0.2150767 h 2.4514679 v 2.365843 c 0,0.1187227 0.09983,0.2150768 0.222861,0.2150768 h 1.337165 c 0.123019,0 0.222859,-0.096353 0.222859,-0.2150768 v -2.365843 h 2.451467 c 0.12302,0 0.22286,-0.096355 0.22286,-0.2150767 V 4.366788 c 0,-0.1187229 -0.09983,-0.2150768 -0.22286,-0.2150768 z"
|
||||
id="path1-3"
|
||||
style="stroke-width:0.437868" /></svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
sodipodi:docname="sort-heart.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="26.077075"
|
||||
inkscape:cx="4.0265253"
|
||||
inkscape:cy="4.640091"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1020"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3"
|
||||
showguides="true" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 13.78723,0 C 12.941692,0 12.207573,0.47431375 11.834786,1.1709458 11.461999,0.47431375 10.72788,0 9.8823418,0 c -1.2219697,0 -2.21277,0.99080032 -2.21277,2.2127699 0,2.9937477 4.1652142,5.0763547 4.1652142,5.0763547 0,0 4.165214,-2.082607 4.165214,-5.0763547 C 16,0.99080032 15.0092,0 13.78723,0 Z"
|
||||
id="path1-5"
|
||||
style="stroke-width:0.520651" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -303,6 +303,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
display: grid;
|
||||
flex-grow: 1;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ async function addActor() {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -77,12 +77,15 @@ function getSocialUrl(social) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
const socials = props.actor.socials
|
||||
.filter((social) => social.platform)
|
||||
.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) => {
|
||||
@@ -132,13 +135,18 @@ const aliases = Object
|
||||
<dfn class="bio-label"><Icon icon="cake" />Date of birth</dfn>
|
||||
|
||||
<span class="birthdate">
|
||||
<span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span>
|
||||
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span>
|
||||
<template v-if="actor.dateOfBirthPrecision === 'day'">
|
||||
<span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span>
|
||||
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span>
|
||||
</template>
|
||||
|
||||
<span v-else-if="actor.dateOfBirthPrecision === 'month'">{{ formatDate(actor.dateOfBirth, 'MMMM, yyyy') }}</span>
|
||||
<span v-else-if="actor.dateOfBirth.getFullYear() > 1">{{ formatDate(actor.dateOfBirth, 'yyyy') }}</span>
|
||||
|
||||
<span
|
||||
v-if="!actor.dateOfDeath && actor.dateOfBirth.getFullYear() > 1"
|
||||
class="age"
|
||||
>{{ actor.ageFromBirth }}</span>
|
||||
><template v-if="actor.ageFromBirthIsApproximate">~</template>{{ actor.ageFromBirth }}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -160,10 +168,16 @@ const aliases = Object
|
||||
>
|
||||
<dfn class="bio-label"><Icon icon="tombstone" />Date of death</dfn>
|
||||
|
||||
<span class="birthdate">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}<span
|
||||
v-if="actor.ageAtDeath"
|
||||
class="age"
|
||||
>{{ actor.ageAtDeath }}</span></span>
|
||||
<span class="birthdate">
|
||||
<span v-if="actor.dateOfDeathPrecision === 'day'">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}</span>
|
||||
<span v-else-if="actor.dateOfDeathPrecision === 'month'">{{ formatDate(actor.dateOfDeath, 'MMMM, yyyy') }}</span>
|
||||
<span v-else>{{ formatDate(actor.dateOfDeath, 'yyyy') }}</span>
|
||||
|
||||
<span
|
||||
v-if="actor.ageAtDeath"
|
||||
class="age"
|
||||
><template v-if="actor.ageAtDeathIsApproximate">~</template>{{ actor.ageAtDeath }}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li
|
||||
|
||||
@@ -52,7 +52,8 @@ function getActorNames() {
|
||||
async function merge() {
|
||||
submitted.value = true;
|
||||
|
||||
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, {
|
||||
await post(`/actors/${targetActor.value.id}/merge`, {
|
||||
actorIds: props.actors.map((actor) => actor.id),
|
||||
comment: comment.value || null,
|
||||
}, {
|
||||
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
|
||||
@@ -147,6 +148,7 @@ onMounted(() => {
|
||||
:triggers="[]"
|
||||
:shown="actorResults.length > 0"
|
||||
:auto-hide="false"
|
||||
placement="auto"
|
||||
>
|
||||
<input
|
||||
ref="actorInput"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ 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(() => {
|
||||
@@ -39,11 +40,15 @@ const filteredAlerts = computed(() => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length > 1) {
|
||||
if (filterScenes.value && alert.scenes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length === 1) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -67,6 +72,10 @@ const filteredAlerts = computed(() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.scenes.some((scene) => queryRegex.test(scene.title))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@@ -119,8 +128,6 @@ function toggleFilterCombined() {
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Alerts</h3>
|
||||
|
||||
<button
|
||||
class="button"
|
||||
@click="showAlertDialog = true"
|
||||
@@ -140,37 +147,45 @@ function toggleFilterCombined() {
|
||||
|
||||
<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"
|
||||
@@ -303,16 +318,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
|
||||
@@ -323,19 +344,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">
|
||||
@@ -362,6 +375,10 @@ function toggleFilterCombined() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
@@ -20,27 +21,34 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
presetScenes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['alert', 'close']);
|
||||
|
||||
const { assets } = inject('pageContext');
|
||||
const {
|
||||
assets,
|
||||
user,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const actors = ref(props.presetActors);
|
||||
const actorResults = ref([]);
|
||||
const actorInput = ref(null);
|
||||
const actorQuery = ref('');
|
||||
|
||||
const entities = ref(props.presetEntities);
|
||||
const entityResults = ref([]);
|
||||
const entityInput = ref(null);
|
||||
const entityQuery = ref('');
|
||||
|
||||
const tags = ref(props.presetTags);
|
||||
const tagResults = ref([]);
|
||||
const tagInput = ref(null);
|
||||
const tagQuery = ref('');
|
||||
|
||||
const scenes = ref(props.presetScenes);
|
||||
|
||||
const matches = ref([]);
|
||||
const matchProperty = ref('title');
|
||||
const matchExpression = ref('');
|
||||
@@ -50,6 +58,8 @@ const actorAnd = ref(true);
|
||||
const tagAnd = ref(true);
|
||||
const matchAnd = ref(true);
|
||||
|
||||
const inputs = {};
|
||||
|
||||
const fieldsLogicTooltipId = useId();
|
||||
const addActorDropdownId = useId();
|
||||
const addTagDropdownId = useId();
|
||||
@@ -57,8 +67,8 @@ const addEntityDropdownId = useId();
|
||||
const addMatchDropdownId = useId();
|
||||
const addStashDropdownId = useId();
|
||||
|
||||
const notify = ref(true);
|
||||
const email = ref(false);
|
||||
const notify = ref(JSON.parse(localStorage.getItem('alert_notify')) ?? true);
|
||||
const email = ref(JSON.parse(localStorage.getItem('alert_email')) ?? false);
|
||||
|
||||
const stashes = ref([]);
|
||||
|
||||
@@ -68,6 +78,7 @@ async function createAlert() {
|
||||
...tags.value.map((tag) => tag.name),
|
||||
...entities.value.map((entity) => entity.name),
|
||||
...matches.value.map((match) => match.expression),
|
||||
...scenes.value.map((scene) => scene.title),
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
const newAlert = await post('/alerts', {
|
||||
@@ -77,6 +88,7 @@ async function createAlert() {
|
||||
allMatches: matchAnd.value,
|
||||
actors: actors.value.map((actor) => actor.id),
|
||||
tagIds: tags.value.map((tag) => tag.id),
|
||||
sceneIds: scenes.value.map((scene) => scene.id),
|
||||
matches: matches.value,
|
||||
entityIds: entities.value.map((entity) => entity.id),
|
||||
notify: notify.value,
|
||||
@@ -119,9 +131,9 @@ async function searchTags() {
|
||||
tagResults.value = res;
|
||||
}
|
||||
|
||||
function focusActorInput() {
|
||||
function focusInput(target) {
|
||||
setTimeout(() => {
|
||||
actorInput.value.focus();
|
||||
inputs[target]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -158,6 +170,10 @@ function selectStash(selectedStash) {
|
||||
stashes.value.push(selectedStash);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberThen(domain, checked) {
|
||||
localStorage.setItem(`alert_${domain}`, checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,7 +185,33 @@ function selectStash(selectedStash) {
|
||||
class="dialog-body"
|
||||
@submit.prevent="createAlert"
|
||||
>
|
||||
<div class="dialog-section">
|
||||
<div
|
||||
v-if="scenes.length > 0"
|
||||
class="dialog-section"
|
||||
>
|
||||
<div class="section-header">
|
||||
<h3 class="heading">WHEN RELEASED</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="scene in scenes"
|
||||
:key="`alert-scene-${scene.id}`"
|
||||
class="scene"
|
||||
>
|
||||
<span class="scene-title">{{ scene.title }}</span>
|
||||
|
||||
<span class="scene-details">
|
||||
<strong class="scene-date"><time :datetime="scene.date.toISOString()">{{ format(scene.date, 'yyyy-MM-dd') }}</time></strong> on
|
||||
|
||||
<span class="scene-channel">{{ scene.channel?.name || scene.network?.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="dialog-section"
|
||||
>
|
||||
<div class="section-header">
|
||||
<h3 class="heading">IF</h3>
|
||||
</div>
|
||||
@@ -220,7 +262,8 @@ function selectStash(selectedStash) {
|
||||
<li class="field-add">
|
||||
<VDropdown
|
||||
:aria-id="addActorDropdownId"
|
||||
@show="focusActorInput"
|
||||
placement="auto"
|
||||
@show="focusInput('actor')"
|
||||
>
|
||||
<button
|
||||
class="button"
|
||||
@@ -229,7 +272,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="actorInput"
|
||||
:ref="(el) => inputs.actor = el"
|
||||
v-model="actorQuery"
|
||||
class="input"
|
||||
@input="searchActors"
|
||||
@@ -296,7 +339,11 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addTagDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addTagDropdownId"
|
||||
@show="focusInput('tag')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -304,7 +351,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="tagInput"
|
||||
:ref="(el) => inputs.tag = el"
|
||||
v-model="tagQuery"
|
||||
class="input"
|
||||
@input="searchTags"
|
||||
@@ -360,7 +407,11 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addEntityDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addEntityDropdownId"
|
||||
@show="focusInput('entity')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -368,7 +419,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="entityInput"
|
||||
:ref="(el) => inputs.entity = el"
|
||||
v-model="entityQuery"
|
||||
class="input"
|
||||
@input="searchEntities"
|
||||
@@ -422,7 +473,10 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addMatchDropdownId">
|
||||
<VDropdown
|
||||
:aria-id="addMatchDropdownId"
|
||||
@show="focusInput('expression')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -439,6 +493,7 @@ function selectStash(selectedStash) {
|
||||
</select>
|
||||
|
||||
<input
|
||||
:ref="(el) => inputs.expression = el"
|
||||
v-model="matchExpression"
|
||||
placeholder="Expression, // for regex"
|
||||
class="input"
|
||||
@@ -458,22 +513,26 @@ function selectStash(selectedStash) {
|
||||
<span>Notify me in traxxx</span>
|
||||
<Checkbox
|
||||
:checked="notify"
|
||||
@change="(checked) => notify = checked"
|
||||
@change="(checked) => { notify = checked; rememberThen('notify', checked); }"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!--
|
||||
<label class="field email">
|
||||
<label
|
||||
v-if="env.emailEnabled && user.email"
|
||||
class="field email"
|
||||
>
|
||||
<span>E-mail me</span>
|
||||
|
||||
<Checkbox
|
||||
:checked="email"
|
||||
@change="(checked) => email = checked"
|
||||
@change="(checked) => { email = checked; rememberThen('email', checked); }"
|
||||
/>
|
||||
</label>
|
||||
-->
|
||||
|
||||
<div class="stash">
|
||||
<div
|
||||
v-if="scenes.length === 0"
|
||||
class="stash"
|
||||
>
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
@@ -572,6 +631,20 @@ function selectStash(selectedStash) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.scene {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.scene-date {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -5,8 +5,10 @@ import { computed, inject, ref } from 'vue';
|
||||
import Avatar from '#/components/edit/avatar.vue';
|
||||
import Socials from '#/components/edit/revision-socials.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { formatDuration } from '#/utils/format.js';
|
||||
|
||||
defineProps({
|
||||
@@ -31,10 +33,14 @@ const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) =>
|
||||
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
|
||||
|
||||
const feedbacks = ref({});
|
||||
const showReviewed = ref(false);
|
||||
const showReviewed = ref(Object.hasOwn(pageContext.urlParsed.search, 'final'));
|
||||
const reviewedRevisions = ref(new Set());
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const total = ref(pageContext.pageProps.revisionTotal);
|
||||
const page = Number(pageContext.routeParams.page) || 1;
|
||||
const limit = Number(pageContext.urlParsed.search.limit) || 100;
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
// tags: tagsById,
|
||||
@@ -163,7 +169,8 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
async function reloadRevisions() {
|
||||
const updatedRevisions = await get(`/revisions/${domain}`, {
|
||||
isFinalized: showReviewed.value ? undefined : false,
|
||||
limit: 50,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
actors.value = updatedRevisions.actors;
|
||||
@@ -171,6 +178,14 @@ async function reloadRevisions() {
|
||||
movies.value = updatedRevisions.movies;
|
||||
avatars.value = updatedRevisions.avatars;
|
||||
revisions.value = updatedRevisions.revisions;
|
||||
total.value = updatedRevisions.total;
|
||||
|
||||
navigate(pageContext.urlParsed.pathname, {
|
||||
...pageContext.urlParsed.search,
|
||||
final: showReviewed.value || undefined,
|
||||
}, {
|
||||
redirect: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewRevision(revision, isApproved) {
|
||||
@@ -208,10 +223,15 @@ async function banEditor(revision) {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div
|
||||
v-if="context === 'admin'"
|
||||
class="revs-header"
|
||||
>
|
||||
<span
|
||||
v-if="typeof total === 'number'"
|
||||
class="revs-total"
|
||||
>{{ total }} revisions</span>
|
||||
|
||||
<Checkbox
|
||||
v-if="context === 'admin'"
|
||||
label="Show finalized"
|
||||
:checked="showReviewed"
|
||||
@change="(checked) => { showReviewed = checked; reloadRevisions(); }"
|
||||
@@ -388,13 +408,12 @@ async function banEditor(revision) {
|
||||
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>
|
||||
>{{ rev.base.title || rev.base.name }}</span>
|
||||
|
||||
<span class="rev-summary">
|
||||
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
|
||||
@@ -431,6 +450,13 @@ async function banEditor(revision) {
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
v-if="total"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -443,13 +469,20 @@ async function banEditor(revision) {
|
||||
|
||||
.revs-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .5rem .75rem .5rem;
|
||||
width: 100%;
|
||||
|
||||
.check-container {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.revs-total {
|
||||
color: var(--glass-strong-10);
|
||||
}
|
||||
|
||||
.revs {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
triggers: {
|
||||
type: Array,
|
||||
default: () => mockTriggers,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
|
||||
const mockTriggers = [
|
||||
{
|
||||
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',
|
||||
alertId: 123,
|
||||
alertActors: [],
|
||||
alertTags: [],
|
||||
alertEntities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
alertMatches: [],
|
||||
alertSceneIds: [],
|
||||
},
|
||||
{
|
||||
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',
|
||||
alertId: 123,
|
||||
alertActors: [],
|
||||
alertTags: [],
|
||||
alertEntities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
alertMatches: [],
|
||||
alertSceneIds: [],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>{{ triggers.length }} new scene{{ triggers.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, {{ user.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="(trigger, index) in triggers"
|
||||
:key="trigger.scene.id"
|
||||
style="margin-bottom: 15px; font-size: 14px;"
|
||||
:style="{
|
||||
'padding-bottom': index < triggers.length - 1 ? '15px' : 0,
|
||||
'border-bottom': index < triggers.length - 1 ? 'solid 1px #ddd' : 'none',
|
||||
}"
|
||||
>
|
||||
<Row style="margin-bottom: 8px;">
|
||||
<Column valign="top">
|
||||
{{ format(trigger.scene.date, 'yyyy-MM-dd') }} |
|
||||
<span v-if="trigger.alertSceneIds.length > 0">Scene is released</span>
|
||||
|
||||
<span v-else>
|
||||
New
|
||||
|
||||
<template
|
||||
v-if="trigger.alertTags.length > 0"
|
||||
>{{ trigger.alertTags.map((tag) => tag.name).join(', ') }}</template>
|
||||
|
||||
scene
|
||||
|
||||
<template
|
||||
v-if="trigger.alertActors.length > 0"
|
||||
>with {{ trigger.alertActors.map((actor) => actor.name).join(', ') }} </template>
|
||||
|
||||
<template
|
||||
v-if="trigger.alertEntities.length > 0"
|
||||
>for {{ trigger.alertEntities[0].name }} </template>
|
||||
|
||||
<template
|
||||
v-if="trigger.alertMatches.length > 0"
|
||||
>matching {{ trigger.alertMatches.map((match) => match.expression).join(', ') }}</template>
|
||||
</span>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
width="50"
|
||||
valign="top"
|
||||
style="text-align: right;"
|
||||
>
|
||||
<span style="font-size: 14px; color: #aaa;">#{{ trigger.alertId }}</span>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Row>
|
||||
<Column
|
||||
width="175"
|
||||
valign="top"
|
||||
>
|
||||
<Link :href="`${address}/scene/${trigger.scene.id}/${trigger.scene.slug}`">
|
||||
<Img
|
||||
v-if="trigger.posterUrl"
|
||||
:src="trigger.posterUrl"
|
||||
width="160"
|
||||
:alt="trigger.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/${trigger.scene.id}/${trigger.scene.slug}`"
|
||||
style="font-weight: bold; line-height: 1.5; color: inherit;"
|
||||
>{{ trigger.scene.title }}</Link>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px;">{{ trigger.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">{{ trigger.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>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
ip: {
|
||||
type: String,
|
||||
default: '127.0.0.1',
|
||||
},
|
||||
country: {
|
||||
type: String,
|
||||
default: 'Unknown',
|
||||
},
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: () => new Date(),
|
||||
},
|
||||
});
|
||||
</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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Your traxxx password has been changed</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, {{ user.username }}</Heading>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Your traxxx password was changed.</Heading>
|
||||
|
||||
<Text>Device used to change your password:</Text>
|
||||
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="font-size: 14px;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">IP:</td>
|
||||
<td style="padding: 4px 0;">{{ ip }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">Country:</td>
|
||||
<td style="padding: 4px 0;">{{ country }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">Timestamp:</td>
|
||||
<td style="padding: 4px 0;">{{ format(timestamp, 'yyyy-MM-dd hh:mm:ss') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">If this change was made by you, you can ignore this e-mail. If this was not you, please <a
|
||||
href="mailto:account@traxxx.me"
|
||||
style="color: #f65596; text-decoration: none;"
|
||||
>contact us</a> immediately.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/recovery?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Reset your traxxx password</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, {{ user.username }}</Heading>
|
||||
|
||||
<Text>
|
||||
<strong>You requested to reset the password for your traxxx account.</strong>
|
||||
<br>
|
||||
Please set a new password by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Reset password</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
|
||||
If you did not request a password reset, you can safely ignore this e-mail.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'change',
|
||||
},
|
||||
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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Sign up for 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,</Heading>
|
||||
|
||||
<Text style="font-size: 16px;">
|
||||
<strong>Thank you for signing up for traxxx!</strong>
|
||||
<br>
|
||||
Please complete your registration by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Create my account</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">The link will expire in {{ config.auth.signupTokenExpiry }} minutes. If you did not sign up, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Someone tried to use this e-mail address for 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, {{ user.username }}</Heading>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Someone tried to assign this e-mail address to a traxxx account.</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">If this was you with this account ({{ user.username }}), your e-mail address is already set correctly. If this was you with another account, you will need to use another e-mail address. If this was not you, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'change',
|
||||
},
|
||||
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 mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Please verify your traxxx e-mail address</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, {{ user.username }}</Heading>
|
||||
|
||||
<Text>
|
||||
<strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||
<br>
|
||||
Please confirm your e-mail address by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Verify e-mail address</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification. If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -7,44 +7,61 @@ const { env } = pageContext;
|
||||
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<span class="footer-segment">© traxxx</span>
|
||||
<div class="footer-row">
|
||||
<a
|
||||
v-if="env.links.matrix"
|
||||
:href="env.links.matrix"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="footer-segment footer-link nolink matrix"
|
||||
><Icon icon="matrix-full" /></a>
|
||||
|
||||
<a
|
||||
v-if="env.links.matrix"
|
||||
:href="env.links.matrix"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="footer-segment footer-link nolink matrix"
|
||||
><Icon icon="matrix-full" /></a>
|
||||
<a
|
||||
v-if="env.links.discord"
|
||||
:href="env.links.discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="footer-segment footer-link nolink discord"
|
||||
><Icon icon="discord-full" /></a>
|
||||
|
||||
<a
|
||||
v-if="env.links.discord"
|
||||
:href="env.links.discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="footer-segment footer-link nolink discord"
|
||||
><Icon icon="discord-full" /></a>
|
||||
<a
|
||||
v-if="env.links.content"
|
||||
:href="env.links.content"
|
||||
target="_blank"
|
||||
class="footer-segment footer-link"
|
||||
>Content Removal (DMCA)</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="env.links.content"
|
||||
:href="env.links.content"
|
||||
target="_blank"
|
||||
class="footer-segment footer-link"
|
||||
>Content Removal / DMCA</a>
|
||||
<div class="footer-row">
|
||||
<span class="footer-segment">© traxxx v{{ env.version }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { useId } from 'vue';
|
||||
|
||||
defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
@@ -16,7 +18,7 @@ defineProps({
|
||||
|
||||
defineEmits(['change']);
|
||||
|
||||
const uid = String(Math.random()).slice(2);
|
||||
const uid = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
password: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Password',
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: 'current-password',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change', 'input']);
|
||||
|
||||
const currentPassword = ref(props.password);
|
||||
const showPassword = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
minlength="3"
|
||||
class="password input"
|
||||
@change="emit('change', currentPassword)"
|
||||
@input="emit('input', currentPassword)"
|
||||
>
|
||||
|
||||
<div
|
||||
class="password-show"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Icon
|
||||
v-show="!showPassword"
|
||||
icon="eye"
|
||||
class="password-show"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-show="showPassword"
|
||||
icon="eye-blocked"
|
||||
class="password-show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
background: var(--background);
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.password-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.password-show {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
padding: 0 1rem 0 .5rem;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
|
||||
@@ -279,7 +279,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
.movies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
redirect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
||||
@@ -370,6 +370,7 @@ function setView(newView) {
|
||||
<Campaign
|
||||
:campaign="sceneCampaign"
|
||||
:backdrop="true"
|
||||
class="campaign-scene"
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -447,6 +448,7 @@ function setView(newView) {
|
||||
.scenes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
@@ -456,6 +458,11 @@ function setView(newView) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.campaign-scene {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1; /* ensures banner can't be taller than scene tile */
|
||||
}
|
||||
|
||||
.scopes {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -253,6 +253,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="serie-tile">
|
||||
<div
|
||||
class="serie-tile"
|
||||
:class="{ noposter: !serie.poster }"
|
||||
>
|
||||
<a
|
||||
v-if="serie.poster"
|
||||
:href="`/serie/${serie.id}/${serie.slug}`"
|
||||
@@ -39,8 +42,8 @@ defineProps({
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
background: var(--background-base);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||
@@ -53,6 +56,19 @@ defineProps({
|
||||
&.unstashed {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&.noposter {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
|
||||
.title {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poster-container {
|
||||
|
||||
@@ -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,27 @@
|
||||
<script setup>
|
||||
// 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>
|
||||
|
||||
@@ -125,6 +125,9 @@ async function alertItem() {
|
||||
...(props.domain === 'scenes' && { scenes: [props.item.id] }),
|
||||
notify: true,
|
||||
email: false,
|
||||
// probably too confusing and annoying to have quick alerts inherit email preference from dialog
|
||||
// notify: JSON.parse(localStorage.getItem('alert_notify')) ?? true,
|
||||
// email: JSON.parse(localStorage.getItem('alert_email')) ?? false,
|
||||
preset: true,
|
||||
}, {
|
||||
successFeedback: `Alert set for '${alertLabel}'`,
|
||||
@@ -173,7 +176,7 @@ async function removeAlert() {
|
||||
|
||||
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];
|
||||
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches, ...alert.scenes];
|
||||
|
||||
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
|
||||
itemAlerted.value = true;
|
||||
@@ -219,7 +222,6 @@ async function reloadStashes(newStash) {
|
||||
icon="bell2"
|
||||
class="alert active noselect"
|
||||
@click="removeAlert"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
@@ -228,7 +230,6 @@ async function reloadStashes(newStash) {
|
||||
icon="bell-plus"
|
||||
class="alert partial noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
@@ -245,6 +246,8 @@ async function reloadStashes(newStash) {
|
||||
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
||||
:aria-id="stashMenuDropdownId"
|
||||
:shown="showStashes"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<Icon
|
||||
@@ -258,6 +261,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
@@ -271,6 +275,8 @@ async function reloadStashes(newStash) {
|
||||
:triggers="[]"
|
||||
:shown="showStashes"
|
||||
:disabled="showSecondary"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<template v-if="pageStash?.user.id === user.id">
|
||||
@@ -313,6 +319,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
stashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -10,46 +12,141 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref(localStorage.getItem('stashMenuSorting') || 'used');
|
||||
|
||||
const domainKey = {
|
||||
actors: 'stashedActors',
|
||||
scenes: 'stashedScenes',
|
||||
movies: 'stashedMovies',
|
||||
}[props.domain];
|
||||
|
||||
const sortedStashes = computed(() => props.stashes
|
||||
.filter((stash) => !stash.isArchived)
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return stashB[domainKey] - stashA[domainKey];
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}));
|
||||
/*
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
*/
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
localStorage.setItem('stashMenuSorting', sorting.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="stash-menu nolist noselect">
|
||||
<li
|
||||
v-for="userStash in stashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
<div class="stash-menu noselect">
|
||||
<ul class="stash-list nolist noselect">
|
||||
<li
|
||||
v-for="userStash in sortedStashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<li class="menu-item create">
|
||||
<label
|
||||
<div class="menu-actions">
|
||||
<div
|
||||
v-close-popper
|
||||
class="menu-stash"
|
||||
class="menu-item menu-stash menu-create"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<Icon icon="plus3" />
|
||||
New
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="`By total stashed ${domain}`"
|
||||
icon="sort-amount-desc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stash-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stash-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* lets this flex child actually shrink and scroll instead of pushing the container taller */
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
fill: var(--shadow-weak-10);
|
||||
padding: 0 .55rem 0 .2rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +155,7 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: .5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
@@ -72,4 +170,21 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
border-top: solid 1px var(--glass-weak-40);
|
||||
}
|
||||
|
||||
.menu-sort {
|
||||
padding: .75rem 1rem;
|
||||
fill: var(--glass);
|
||||
border-left: solid 1px var(--glass-weak-40);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import StashTile from '#/components/stashes/tile.vue';
|
||||
@@ -13,18 +13,55 @@ const stashes = ref(pageContext.pageProps.stashes);
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
|
||||
const showStashDialog = ref(false);
|
||||
const showArchived = ref(false);
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref('size');
|
||||
|
||||
async function reloadStashes() {
|
||||
// profile.value = await get(`/users/${profile.value.id}`);
|
||||
stashes.value = await get(`/users/${profile.value.id}/stashes`);
|
||||
}
|
||||
|
||||
const sortedStashes = computed(() => stashes.value
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return (stashB.stashedScenes + stashB.stashedMovies + stashB.stashedActors)
|
||||
- (stashA.stashedScenes + stashA.stashedMovies + stashA.stashedActors);
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
// always put favorites first
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
|
||||
const activeStashes = computed(() => sortedStashes.value.filter((stash) => !stash.isArchived));
|
||||
const archivedStashes = computed(() => sortedStashes.value.filter((stash) => stash.isArchived));
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Stashes</h3>
|
||||
|
||||
<div class="section-header noselect">
|
||||
<button
|
||||
v-if="profile.id === user?.id"
|
||||
class="button"
|
||||
@@ -33,6 +70,38 @@ async function reloadStashes() {
|
||||
<Icon icon="folder-plus2" />
|
||||
<span class="button-label">New stash</span>
|
||||
</button>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="'By total stashed'"
|
||||
icon="sort-amount-desc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StashDialog
|
||||
@@ -43,7 +112,29 @@ async function reloadStashes() {
|
||||
|
||||
<ul class="stashes nolist">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
v-for="stash in activeStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
:stash="stash"
|
||||
:profile="profile"
|
||||
@reload="reloadStashes"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3
|
||||
v-if="archivedStashes.length > 0"
|
||||
class="archived-heading"
|
||||
@click="showArchived = !showArchived"
|
||||
><Icon icon="drawer3" />{{ showArchived ? 'Hide' : `Show ${archivedStashes.length}` }} archived {{ archivedStashes.length > 1 ? 'stashes' : 'stash' }}</h3>
|
||||
|
||||
<ul
|
||||
v-if="showArchived"
|
||||
class="stashes nolist"
|
||||
>
|
||||
<li
|
||||
v-for="stash in archivedStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
@@ -57,6 +148,13 @@ async function reloadStashes() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: .5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stashes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
@@ -64,6 +162,30 @@ async function reloadStashes() {
|
||||
padding: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
padding: .5rem 1rem;
|
||||
fill: var(--glass);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.archived-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
color: var(--glass);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
margin-right: .75rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(--compact) {
|
||||
.stashes {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
@@ -91,6 +91,30 @@ async function rename() {
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
|
||||
async function archive(isArchived = true) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { isArchived }, {
|
||||
...isArchived
|
||||
? {
|
||||
undoFeedback: `Stash archived`,
|
||||
}
|
||||
: {
|
||||
successFeedback: `Stash revived`,
|
||||
},
|
||||
errorFeedback: `Failed to ${isArchived ? 'archive' : 'revive'} stash`,
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -159,6 +183,28 @@ async function rename() {
|
||||
/>Rename
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="stash.isArchived"
|
||||
class="menu-item"
|
||||
@click="archive(false)"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-out"
|
||||
class="noselect"
|
||||
/>Revive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
class="menu-item"
|
||||
@click="archive()"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-in"
|
||||
class="noselect"
|
||||
/>Archive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="!stash.isPrimary"
|
||||
class="menu-item remove"
|
||||
@@ -306,6 +352,7 @@ async function rename() {
|
||||
display: flex;
|
||||
margin-right: .75rem;
|
||||
fill: var(--glass);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.remove {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup>
|
||||
import { inject, reactive, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
// import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { patch, post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
const allowEmailUpdate = pageContext.env.emailEnabled;
|
||||
|
||||
const credentials = reactive({
|
||||
email: null,
|
||||
password: null,
|
||||
});
|
||||
|
||||
const currentPassword = ref(null);
|
||||
const saving = ref(false);
|
||||
const submitted = ref(false);
|
||||
const verifying = ref(false);
|
||||
const verified = ref(false);
|
||||
const feedback = ref(null);
|
||||
|
||||
function getFeedback() {
|
||||
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
||||
return 'Account updated, please check your inbox to change e-mail.';
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
return 'Please check your inbox to change e-mail.';
|
||||
}
|
||||
|
||||
return 'Account updated.';
|
||||
}
|
||||
|
||||
async function updateAccount() {
|
||||
saving.value = true;
|
||||
feedback.value = null;
|
||||
|
||||
try {
|
||||
const feedback = getFeedback();
|
||||
|
||||
await patch('/me', {
|
||||
credentials,
|
||||
password: currentPassword.value,
|
||||
}, {
|
||||
successFeedback: feedback,
|
||||
errorFeedback: 'Failed to update account',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
currentPassword.value = null;
|
||||
credentials.email = null;
|
||||
credentials.password = null;
|
||||
|
||||
submitted.value = true;
|
||||
feedback.value = feedback;
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmail() {
|
||||
verifying.value = true;
|
||||
|
||||
try {
|
||||
await post('/me/email_verification', null, {
|
||||
successFeedback: 'E-mail verification sent, please check your inbox',
|
||||
errorFeedback: 'Failed to request e-mail verification',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
verified.value = true;
|
||||
}
|
||||
finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="profile-section"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">E-mail address</strong>
|
||||
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<span class="setting-group">
|
||||
<VDropdown v-if="!user.isEmailVerified">
|
||||
<Icon
|
||||
icon="warning2"
|
||||
class="unverified"
|
||||
/>
|
||||
|
||||
<template #popper>
|
||||
<div class="unverified-tooltip">
|
||||
Your e-mail address is unverified.
|
||||
|
||||
<Ellipsis v-if="verifying" />
|
||||
|
||||
<strong
|
||||
v-else-if="verified"
|
||||
class="success"
|
||||
>Verification e-mail sent</strong>
|
||||
|
||||
<button
|
||||
v-else-if="allowEmailUpdate"
|
||||
type="button"
|
||||
class="button button-primary"
|
||||
@click="verifyEmail"
|
||||
>Verify now</button>
|
||||
|
||||
<span v-else>Verification is currently unavailable.</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<input
|
||||
:value="user.email"
|
||||
type="email"
|
||||
class="input"
|
||||
disabled
|
||||
>
|
||||
</span>
|
||||
|
||||
<input
|
||||
v-if="allowEmailUpdate"
|
||||
v-model="credentials.email"
|
||||
type="email"
|
||||
placeholder="New e-mail"
|
||||
class="input"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">New password</strong>
|
||||
<span class="setting-description">Change the password you use for signing in</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Password
|
||||
:password="credentials.password"
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
@input="(password) => credentials.password = password"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Current password</strong>
|
||||
<span class="setting-description">Enter your current password to save your changes</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Password
|
||||
:password="currentPassword"
|
||||
placeholder="Current password"
|
||||
@input="(password) => currentPassword = password"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<Ellipsis v-if="saving" />
|
||||
|
||||
<strong
|
||||
v-else-if="submitted"
|
||||
class="success"
|
||||
>{{ feedback }}</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="(credentials.email || credentials.password) && currentPassword ? null : 'Please supply your new credentials and current password'"
|
||||
class="button button-primary"
|
||||
:disabled="!currentPassword"
|
||||
>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
background: var(--background);
|
||||
|
||||
&:disabled {
|
||||
color: var(--glass);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-section .heading {
|
||||
margin: .5rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.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-value {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
color: var(--glass);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
.button {
|
||||
flex-basis: 0;
|
||||
font-size: 1.1rem;
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unverified {
|
||||
fill: var(--error);
|
||||
padding: .5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unverified-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -55,16 +55,7 @@ function copyKey(event) {
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">API Keys</h3>
|
||||
|
||||
<div class="keys-actions">
|
||||
<Icon
|
||||
v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
|
||||
icon="stack-cancel"
|
||||
class="keys-flush"
|
||||
@click="flushKeys"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="button"
|
||||
@click="createKey"
|
||||
@@ -72,6 +63,13 @@ function copyKey(event) {
|
||||
<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>
|
||||
|
||||
@@ -154,6 +152,11 @@ function copyKey(event) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.keys-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -164,6 +167,7 @@ function copyKey(event) {
|
||||
.keys-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
> .icon {
|
||||
padding: .5rem 1rem;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<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({
|
||||
alertNotificationsEnabled: true,
|
||||
alertEmailsEnabled: true,
|
||||
alertEmailsFrequency: null,
|
||||
...user.settings,
|
||||
});
|
||||
|
||||
async function updateSettings() {
|
||||
await patch('/me/settings', settings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="profile-section"
|
||||
@submit.prevent
|
||||
>
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert notifications</strong>
|
||||
<span class="setting-description">Your alerts can trigger notifications in traxxx</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Checkbox
|
||||
:checked="settings.alertNotificationsEnabled"
|
||||
@change="(checked) => { settings.alertNotificationsEnabled = checked; updateSettings(); }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert e-mails</strong>
|
||||
<span class="setting-description">Your alerts can trigger e-mails</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Checkbox
|
||||
:checked="settings.alertEmailsEnabled"
|
||||
@change="(checked) => { settings.alertEmailsEnabled = checked; updateSettings(); }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Alert e-mail frequency</strong>
|
||||
<span class="setting-description">Alert e-mails are sent immediately, or accumulated and sent periodically</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<select
|
||||
v-model="settings.alertEmailsFrequency"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.profile-section .heading {
|
||||
margin: .5rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting:not(.noflip) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
web: {
|
||||
address: 'https://traxxx.me',
|
||||
host: '0.0.0.0',
|
||||
port: 5000,
|
||||
sfwHost: '0.0.0.0',
|
||||
@@ -153,6 +154,14 @@ module.exports = {
|
||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||
secretKey: '0x0000000000000000000000000000000000000000',
|
||||
},
|
||||
loginCredentialAttempts: 5,
|
||||
loginCredentialCooldown: 10, // minutes,
|
||||
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
|
||||
loginIpCooldown: 10, // minutes
|
||||
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||
emailTokenExpiry: 120, // minutes
|
||||
passwordTokenExpiry: 120, // minutes
|
||||
signupTokenExpiry: 120, // minutes
|
||||
},
|
||||
bans: {
|
||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||
@@ -197,8 +206,15 @@ module.exports = {
|
||||
crontab: '*/10 * * * *', // every 10 minutes
|
||||
},
|
||||
alerts: {
|
||||
enabled: true,
|
||||
crontab: '*/10 * * * *', // every 10 minutes
|
||||
seenExpiry: 30, // days
|
||||
finalExpiry: 365, // days
|
||||
},
|
||||
email: {
|
||||
// Resend
|
||||
enabled: false,
|
||||
apiKey: null,
|
||||
from: 'traxxx <noreply@alerts.traxxx.me>',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
@@ -18,11 +19,21 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: { 'import-x': importX },
|
||||
languageOptions: {
|
||||
globals: {
|
||||
CONFIG: 'readonly',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// mirrors the '#'/'#root' aliases in vite.config.js, which is what actually resolves these at build/runtime
|
||||
'import-x/resolver-next': [createNodeResolver({
|
||||
alias: {
|
||||
'#': [import.meta.dirname],
|
||||
'#root': [import.meta.dirname],
|
||||
},
|
||||
})],
|
||||
},
|
||||
rules: {
|
||||
'default-param-last': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
@@ -33,6 +44,7 @@ export default antfu(
|
||||
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
|
||||
}],
|
||||
'prefer-destructuring': 'off',
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import/prefer-default-export': 'off',
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
args: 'after-used',
|
||||
|
||||
@@ -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",
|
||||
@@ -34,6 +37,7 @@
|
||||
"ejs": "^6.0.1",
|
||||
"error-stack-parser": "^2.1.4",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -57,6 +61,7 @@
|
||||
"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",
|
||||
@@ -87,7 +92,7 @@
|
||||
"overrides": {
|
||||
"vite": "$vite"
|
||||
},
|
||||
"version": "0.52.3",
|
||||
"version": "0.57.25",
|
||||
"imports": {
|
||||
"#/*": "./*.js"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export default {
|
||||
meta: {
|
||||
title: {
|
||||
env: {
|
||||
server: true,
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
env: {
|
||||
server: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'actors',
|
||||
pageProps: {
|
||||
actors,
|
||||
countries,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default 'Actors';
|
||||
@@ -5,11 +5,13 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
avatars,
|
||||
} = await fetchActorRevisions(null, {
|
||||
actorId: actor.id,
|
||||
isFinalized: true,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -18,6 +20,7 @@ export async function onBeforeRender(pageContext) {
|
||||
pageProps: {
|
||||
actor,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
avatars,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -52,9 +52,12 @@ const fields = computed(() => [
|
||||
key: 'dateOfBirth',
|
||||
label: 'date of birth',
|
||||
type: 'date',
|
||||
value: actor.value?.dateOfBirth
|
||||
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
||||
: null,
|
||||
value: {
|
||||
date: actor.value?.dateOfBirth
|
||||
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
||||
: null,
|
||||
precision: actor.value.dateOfBirthPrecision,
|
||||
},
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
@@ -212,9 +215,12 @@ const fields = computed(() => [
|
||||
key: 'dateOfDeath',
|
||||
label: 'date of death',
|
||||
type: 'date',
|
||||
value: actor.value?.dateOfDeath
|
||||
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
||||
: null,
|
||||
value: {
|
||||
date: actor.value?.dateOfDeath
|
||||
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
||||
: null,
|
||||
precision: actor.value.dateOfDeathPrecision,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'allowGlobalMatch',
|
||||
@@ -270,6 +276,14 @@ const keyMap = {
|
||||
country: 'residenceCountry',
|
||||
place: 'residencePlace',
|
||||
},
|
||||
dateOfBirth: {
|
||||
date: 'dateOfBirth',
|
||||
precision: 'dateOfBirthPrecision',
|
||||
},
|
||||
dateOfDeath: {
|
||||
date: 'dateOfDeath',
|
||||
precision: 'dateOfDeathPrecision',
|
||||
},
|
||||
tattoos: {
|
||||
has: 'hasTattoos',
|
||||
description: 'tattoos',
|
||||
@@ -501,6 +515,7 @@ async function submit() {
|
||||
>{{ item.unit }}
|
||||
</template>
|
||||
|
||||
<!--
|
||||
<input
|
||||
v-if="item.type === 'date'"
|
||||
v-model="edits[item.key]"
|
||||
@@ -508,6 +523,30 @@ async function submit() {
|
||||
class="date input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
-->
|
||||
|
||||
<div
|
||||
v-if="item.type === 'date'"
|
||||
class="date"
|
||||
>
|
||||
<input
|
||||
v-model="edits[item.key].date"
|
||||
type="date"
|
||||
class="date input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
|
||||
<select
|
||||
v-if="item.value.precision"
|
||||
v-model="edits[item.key].precision"
|
||||
class="input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="year">Year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-if="item.type === 'select'"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -10,10 +10,12 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
avatars,
|
||||
} = await fetchActorRevisions(null, {
|
||||
isFinalized: false,
|
||||
limit: 50,
|
||||
isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -21,6 +23,7 @@ export async function onBeforeRender(pageContext) {
|
||||
title: pageContext.routeParams.section,
|
||||
pageProps: {
|
||||
revisions,
|
||||
revisionTotal,
|
||||
avatars,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/:section/:domain(actors)';
|
||||
const path = '/admin/:section/:domain(actors)/:page?';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
@@ -11,6 +11,8 @@ export default (pageContext) => {
|
||||
routeParams: {
|
||||
section: matched.params.section,
|
||||
domain: matched.params.domain,
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
isFinalized: false,
|
||||
limit: 50,
|
||||
isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -23,6 +25,7 @@ export async function onBeforeRender(pageContext) {
|
||||
title: pageContext.routeParams.section,
|
||||
pageProps: {
|
||||
revisions,
|
||||
revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/:section/:domain(scenes)';
|
||||
const path = '/admin/:section/:domain(scenes)/:page?';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
@@ -11,6 +11,8 @@ export default (pageContext) => {
|
||||
routeParams: {
|
||||
section: matched.params.section,
|
||||
domain: matched.params.domain,
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
const allowSignup = pageContext.env.allowSignup;
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -14,7 +17,6 @@ const password = ref('');
|
||||
const submitted = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
|
||||
async function login() {
|
||||
submitted.value = true;
|
||||
@@ -88,31 +90,10 @@ onMounted(() => {
|
||||
class="input"
|
||||
>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
placeholder="Password"
|
||||
class="password input"
|
||||
>
|
||||
|
||||
<div
|
||||
class="password-show"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Icon
|
||||
v-show="!showPassword"
|
||||
icon="eye"
|
||||
class="password-show"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-show="showPassword"
|
||||
icon="eye-blocked"
|
||||
class="password-show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="button button-submit"
|
||||
@@ -124,11 +105,21 @@ onMounted(() => {
|
||||
href="/signup"
|
||||
class="link"
|
||||
>Create an account</a>
|
||||
|
||||
<a
|
||||
v-if="allowRecovery"
|
||||
href="/recovery"
|
||||
class="link"
|
||||
>I forgot my password</a>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
@@ -200,4 +191,8 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default 'Login';
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { userId, token } = pageContext.urlParsed.search;
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const credential = ref('');
|
||||
|
||||
const submitted = ref(false);
|
||||
const success = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const password = ref(null);
|
||||
|
||||
async function request() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/request', {
|
||||
credential: credential.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset requested, please check your e-mail inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/reset', {
|
||||
userId,
|
||||
token,
|
||||
password: password.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset successfully',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recovery-container">
|
||||
<h3 class="heading">
|
||||
<template v-if="userId && token">Password reset</template>
|
||||
<template v-else>Account recovery</template>
|
||||
</h3>
|
||||
|
||||
<template v-if="allowRecovery">
|
||||
<strong
|
||||
v-if="errorMsg"
|
||||
class="error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<form
|
||||
v-else-if="userId && token"
|
||||
class="login-panel"
|
||||
@submit.prevent="reset"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset successfully</strong>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>Go to login</a>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Reset password</button>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="allowRecovery"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset requested</strong>
|
||||
If the account exists, instructions have been sent to the associated e-mail address.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="credential"
|
||||
placeholder="Username or e-mail"
|
||||
class="input"
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Request password reset</button>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="error"
|
||||
>Password recovery is currently unavailable</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: center;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.recovery-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--background-base-10);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1rem 1rem 1rem;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
background: var(--background-base);
|
||||
font-size: 1rem;
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem .5rem 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export default '/recovery';
|
||||
@@ -0,0 +1 @@
|
||||
export default `Account recovery`;
|
||||
@@ -2,32 +2,30 @@
|
||||
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, env } = pageContext;
|
||||
const { token } = pageContext.urlParsed.search;
|
||||
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const passwordConfirm = ref('');
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitted = ref(false);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
const success = ref(false);
|
||||
const mainInput = ref(null);
|
||||
const captcha = ref(null);
|
||||
|
||||
async function signup() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
errorMsg.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.captcha.enabled && !captcha.value) {
|
||||
errorMsg.value = 'Please complete the CAPTCHA';
|
||||
return;
|
||||
@@ -35,11 +33,14 @@ async function signup() {
|
||||
|
||||
try {
|
||||
const newUser = await post('/users', {
|
||||
token,
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
redirect: pageContext.urlParsed.search.r,
|
||||
captcha: captcha.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up successful!',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
||||
@@ -52,8 +53,30 @@ async function signup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function request() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
try {
|
||||
await post('/signups', {
|
||||
email: email.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up requested, please check your inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userInput.value.focus();
|
||||
mainInput.value.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -87,7 +110,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
v-else-if="token"
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="signup"
|
||||
@@ -98,78 +121,18 @@ onMounted(() => {
|
||||
>{{ errorMsg }}</div>
|
||||
|
||||
<input
|
||||
ref="userInput"
|
||||
ref="mainInput"
|
||||
v-model="username"
|
||||
placeholder="Username"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
|
||||
<div
|
||||
class="password-show"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Icon
|
||||
v-show="!showPassword"
|
||||
icon="eye"
|
||||
class="password-show"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-show="showPassword"
|
||||
icon="eye-blocked"
|
||||
class="password-show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Confirm password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
|
||||
<div
|
||||
class="password-show"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Icon
|
||||
v-show="!showPassword"
|
||||
icon="eye"
|
||||
class="password-show"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-show="showPassword"
|
||||
icon="eye-blocked"
|
||||
class="password-show"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Password
|
||||
:password="password"
|
||||
autocomplete="new-password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<VueHCaptcha
|
||||
v-if="env.captcha.enabled"
|
||||
@@ -179,20 +142,62 @@ onMounted(() => {
|
||||
@expired="captcha = null"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Sign up</button>
|
||||
</form>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>I already have an account</a>
|
||||
<form
|
||||
v-else
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<span
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Thank you for signing up!</strong>
|
||||
Please check your e-mail inbox to continue.
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
ref="mainInput"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Send signup e-mail</button>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link login"
|
||||
>I already have an account</a>
|
||||
</template>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
@@ -219,7 +224,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: .5rem;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +266,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@@ -274,4 +280,18 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default 'Sign up';
|
||||
@@ -4,7 +4,7 @@ export async function onBeforeRender(pageContext) {
|
||||
const networks = await fetchEntities(pageContext.urlParsed.search.q
|
||||
? { query: pageContext.urlParsed.search.q }
|
||||
: { type: 'primary' }, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function onBeforeRender(pageContext) {
|
||||
entityReleases,
|
||||
] = await Promise.all([
|
||||
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
fetchReleases(pageContext, entityId),
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 50,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@ function getTitle(movie) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [[movie], movieScenes] = await Promise.all([
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.restriction }),
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchScenes(await curateScenesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: 'oldest',
|
||||
@@ -28,7 +28,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default 'Account recovery';
|
||||
@@ -18,7 +18,7 @@ export async function onBeforeRender(pageContext) {
|
||||
aggregate: true,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function onBeforeRender(pageContext) {
|
||||
includeAssets: true,
|
||||
includePartOf: true,
|
||||
actorStashes: true,
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
if (!scene) {
|
||||
|
||||
@@ -10,13 +10,15 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
sceneId: scene.id,
|
||||
isFinalized: true,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -25,6 +27,7 @@ export async function onBeforeRender(pageContext) {
|
||||
pageProps: {
|
||||
scene,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
|
||||
@@ -16,24 +16,24 @@ export async function onBeforeRender(pageContext) {
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 15,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchActors(curateActorsQuery(pageContext.urlQuery), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 10,
|
||||
order: ['results', 'desc'],
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchMovies(await curateMoviesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: pageContext.routeParams.scope || 'results',
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 5,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchEntities({
|
||||
query: pageContext.urlParsed.search.q,
|
||||
limit: 5,
|
||||
}, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ onMounted(() => {
|
||||
.tags {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: 1rem .5rem;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const tagSlugs = {
|
||||
};
|
||||
|
||||
async function searchTags(pageContext) {
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.geo.restriction });
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
@@ -137,13 +137,13 @@ export async function onBeforeRender(pageContext) {
|
||||
return searchTags(pageContext);
|
||||
}
|
||||
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
const filteredTags = tags.filter((tag) => !pageContext.tagFilter.includes(tag.name) && !pageContext.tagFilter.includes(tag.slug));
|
||||
const tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
|
||||
|
||||
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [
|
||||
censor(category, pageContext.restriction),
|
||||
censor(category, pageContext.geo.restriction),
|
||||
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
|
||||
]));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const tagSlug = pageContext.routeParams.tagSlug;
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
if (tag.aliasFor) {
|
||||
throw redirect(`/tag/${tag.aliasFor}`);
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 29,
|
||||
aggregate: withQuery,
|
||||
dedupe: true,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 2.0, maxRatio: 5 },
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -6,9 +6,12 @@ import mockupRelease from '#/assets/mockup-release.ts';
|
||||
import Alerts from '#/components/alerts/alerts.vue';
|
||||
import Revisions from '#/components/edit/revisions.vue';
|
||||
import Summaries from '#/components/scenes/summaries.vue';
|
||||
// filters are implemented as generic settings panel in case we want to extend it in the future
|
||||
import Settings from '#/components/settings/settings.vue';
|
||||
import Stashes from '#/components/stashes/stashes.vue';
|
||||
|
||||
import Account from '#/components/user/account.vue';
|
||||
import ApiKeys from '#/components/user/api-keys.vue';
|
||||
import Preferences from '#/components/user/preferences.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
@@ -17,6 +20,9 @@ const domain = pageContext.routeParams.domain;
|
||||
|
||||
const user = pageContext.user;
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
|
||||
const showFilters = ref(false);
|
||||
|
||||
function scrollHorizontal(event) {
|
||||
event.currentTarget.scrollLeft += event.deltaY; // eslint-disable-line no-param-reassign
|
||||
@@ -27,60 +33,106 @@ function scrollHorizontal(event) {
|
||||
<div class="page">
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
<div class="user">
|
||||
<a
|
||||
:href="`/user/${profile.username}/`"
|
||||
:title="`#${profile.id}`"
|
||||
class="user nolink"
|
||||
>
|
||||
<Icon
|
||||
v-if="section && profile.id === user?.id"
|
||||
icon="arrow-left3"
|
||||
class="profile-back"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="profile.avatar"
|
||||
v-else-if="profile.avatar"
|
||||
:src="profile.avatar"
|
||||
class="avatar"
|
||||
>
|
||||
|
||||
<h2 class="username ellipsis">{{ profile.username }}</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<span class="age">{{ formatDistanceStrict(Date.now(), profile.createdAt) }}</span>
|
||||
<strong
|
||||
v-if="section && profile.id === user?.id"
|
||||
class="profile-crumb"
|
||||
>
|
||||
<template v-if="domain">{{ domain }}</template>
|
||||
{{ section }}
|
||||
</strong>
|
||||
|
||||
<span
|
||||
v-else
|
||||
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"
|
||||
>
|
||||
<a
|
||||
v-if="primaryStash"
|
||||
:href="`/stash/${user.username}/${primaryStash.slug}`"
|
||||
class="domain nolink"
|
||||
><Icon :icon="primaryStash.slug === 'favorites' ? 'heart7' : 'folder-heart'" />{{ primaryStash.name }}</a>
|
||||
|
||||
<a
|
||||
: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>
|
||||
><Icon icon="paste3" />Templates</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/scenes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'scenes' }"
|
||||
>Scene Revisions</a>
|
||||
><Icon icon="file-video2" />Scene Revisions</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/actors`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'actors' }"
|
||||
>Actor Revisions</a>
|
||||
><Icon icon="profile" />Actor Revisions</a>
|
||||
|
||||
<span
|
||||
:href="`/user/${profile.username}/preferences`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'filters' }"
|
||||
@click="showFilters = true"
|
||||
><Icon icon="filter" />Filters</span>
|
||||
|
||||
<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}/preferences`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'preferences' }"
|
||||
><Icon icon="equalizer" />Preferences</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/account`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'account' }"
|
||||
><Icon icon="user-tie" />Account</a>
|
||||
</nav>
|
||||
|
||||
<Stashes v-if="section === 'stashes'" />
|
||||
@@ -91,18 +143,22 @@ function scrollHorizontal(event) {
|
||||
:release="mockupRelease"
|
||||
/>
|
||||
|
||||
<ApiKeys
|
||||
v-if="section === 'api'"
|
||||
/>
|
||||
<ApiKeys v-if="section === 'api'" />
|
||||
<Preferences v-if="section === 'preferences'" />
|
||||
<Account v-if="section === 'account'" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="section === 'revisions' && profile.id === user?.id"
|
||||
class="profile-section revisions"
|
||||
>
|
||||
<h3 class="section-header heading">{{ domain.slice(0, -1) }} Revisions</h3>
|
||||
<Revisions context="user" />
|
||||
</div>
|
||||
|
||||
<Settings
|
||||
v-if="showFilters"
|
||||
@close="showFilters = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,11 +216,16 @@ function scrollHorizontal(event) {
|
||||
color: var(--highlight-strong-30);
|
||||
background: var(--shadow-strong-30);
|
||||
border-radius: 0 0 .5rem .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover .profile-back {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
@@ -192,26 +253,53 @@ function scrollHorizontal(event) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.profile-back {
|
||||
padding: .25rem 1.25rem .25rem .25rem;
|
||||
fill: var(--highlight);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.profile-crumb {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.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-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +317,7 @@ function scrollHorizontal(event) {
|
||||
@media(--compact) {
|
||||
.domains {
|
||||
padding: .5rem 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr));
|
||||
}
|
||||
|
||||
.profile {
|
||||
@@ -240,6 +329,12 @@ function scrollHorizontal(event) {
|
||||
}
|
||||
}
|
||||
|
||||
@media(--small) {
|
||||
.domains {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.profile-header {
|
||||
padding: .5rem;
|
||||
|
||||
@@ -15,14 +15,16 @@ async function fetchRevisions(pageContext) {
|
||||
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'scenes') {
|
||||
return fetchSceneRevisions(null, {
|
||||
userId: pageContext.user.id,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
}
|
||||
|
||||
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors') {
|
||||
return fetchActorRevisions(null, {
|
||||
userId: pageContext.user.id,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ async function fetchRevisions(pageContext) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [profile, alerts, userRevisions, keys] = await Promise.all([
|
||||
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
|
||||
fetchUser(pageContext.routeParams.username, { throwError: false }, pageContext.user),
|
||||
pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
|
||||
? fetchAlerts(pageContext.user)
|
||||
: [],
|
||||
@@ -47,6 +49,7 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
@@ -63,6 +66,7 @@ export async function onBeforeRender(pageContext) {
|
||||
stashes,
|
||||
alerts,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
keys,
|
||||
actors,
|
||||
tags,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
import { redirect } from 'vike/abort';
|
||||
|
||||
const path = '/user/:username/:section?/:domain?';
|
||||
const path = '/user/:username/:section?/:domain?/:page?';
|
||||
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',
|
||||
domain: matched.params.domain || 'scenes',
|
||||
section: matched.params.section || '',
|
||||
domain: matched.params.domain || '',
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const { urlParsed } = inject('pageContext');
|
||||
const { userId, token } = urlParsed.search;
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
|
||||
async function verify() {
|
||||
errorMsg.value = null;
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
await post('/emails/verify', {
|
||||
userId,
|
||||
token,
|
||||
}, {
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<Ellipsis v-if="submitting" />
|
||||
|
||||
<strong
|
||||
v-else-if="errorMsg"
|
||||
class="feedback error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<strong
|
||||
v-else-if="success"
|
||||
class="feedback success"
|
||||
>Verification successful</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-primary"
|
||||
@click="verify"
|
||||
>Click here to verify your e-mail address</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -9,6 +9,6 @@ export default {
|
||||
'assets',
|
||||
'campaigns',
|
||||
'meta',
|
||||
'restriction',
|
||||
'geo',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
|
||||
function getTitle(location) {
|
||||
function getTitle(location, pageContext) {
|
||||
if (!location) {
|
||||
return config.title;
|
||||
}
|
||||
|
||||
if (typeof location === 'function') {
|
||||
return getTitle(location(pageContext), pageContext);
|
||||
}
|
||||
|
||||
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
|
||||
}
|
||||
|
||||
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
|
||||
const appHtml = await renderToString(app);
|
||||
|
||||
// See https://vike.dev/head
|
||||
const { documentProps } = pageContext.exports;
|
||||
const title = getTitle(documentProps?.title || pageContext.title);
|
||||
const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
|
||||
|
||||
const documentHtml = escapeInject`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -64,7 +67,7 @@ async function onRenderHtml(pageContext) {
|
||||
<meta name="theme-color" content="#f65596">
|
||||
|
||||
<meta property="og:title" content="traxxx" />
|
||||
<meta property="og:image" content="https://traxxx.me/img/og_logo.png" />
|
||||
<meta property="og:description" content="${pageContext.description || 'Never miss new adult videos with traxxx'}" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
||||
|
||||
|
||||
@@ -32,7 +32,14 @@ function createApp(Page, pageProps, pageContext) {
|
||||
|
||||
app.provide('pageContext', pageContext);
|
||||
|
||||
app.use(FloatingVue, { container: '#container' });
|
||||
app.use(FloatingVue, {
|
||||
container: '#container',
|
||||
autoBoundaryMaxSize: true,
|
||||
shiftCrossAxis: true,
|
||||
overflowPadding: 10,
|
||||
arrowPadding: 10,
|
||||
});
|
||||
|
||||
app.use(VueVirtualScroller);
|
||||
|
||||
app.component('Link', Link);
|
||||
|
||||
@@ -189,7 +189,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ const mj = new MerkleJson();
|
||||
const keyMap = {
|
||||
avatar: 'avatar_media_id',
|
||||
dateOfBirth: 'date_of_birth',
|
||||
dateOfBirthPrecision: 'date_of_birth_precision',
|
||||
dateOfDeath: 'date_of_death',
|
||||
dateOfDeathPrecision: 'date_of_death_precision',
|
||||
originCountry: 'birth_country_alpha2',
|
||||
originState: 'birth_state',
|
||||
originCity: 'birth_city',
|
||||
@@ -62,6 +64,24 @@ const keyMap = {
|
||||
|
||||
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
|
||||
|
||||
// with month (but not day) precision on either side, the age is still certain unless both dates fall in the same month,
|
||||
// since only then could the unknown day(s) put the birthday on either side of the reference date
|
||||
function isAgeApproximate(baseDate, basePrecision, referenceDate, referencePrecision = 'day') {
|
||||
if (!baseDate || !referenceDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basePrecision === 'year' || referencePrecision === 'year') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (basePrecision === 'day' && referencePrecision === 'day') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return baseDate.getMonth() === referenceDate.getMonth();
|
||||
}
|
||||
|
||||
export function curateActor(actor, context = {}) {
|
||||
if (!actor) {
|
||||
return null;
|
||||
@@ -97,14 +117,20 @@ export function curateActor(actor, context = {}) {
|
||||
ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(Date.now(), actor.date_of_birth)
|
||||
: null,
|
||||
ageFromBirthIsApproximate: actor.date_of_birth_precision !== 'day'
|
||||
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, new Date()),
|
||||
ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(actor.date_of_death, actor.date_of_birth)
|
||||
: null,
|
||||
ageAtDeathIsApproximate: (actor.date_of_birth_precision !== 'day' || actor.date_of_death_precision !== 'day')
|
||||
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, actor.date_of_death, actor.date_of_death_precision),
|
||||
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(context.sceneDate, actor.date_of_birth)
|
||||
: null,
|
||||
dateOfBirth: actor.date_of_birth,
|
||||
dateOfBirthPrecision: actor.date_of_birth_precision,
|
||||
dateOfDeath: actor.date_of_death,
|
||||
dateOfDeathPrecision: actor.date_of_death_precision,
|
||||
bust: actor.bust,
|
||||
cup: actor.cup,
|
||||
waist: actor.waist,
|
||||
@@ -304,18 +330,18 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
||||
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
||||
knex('actors_socials')
|
||||
.whereIn('actor_id', actorIds),
|
||||
reqUser
|
||||
reqUser && !options.shallow
|
||||
? knex('stashes_actors')
|
||||
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
|
||||
.where('stashes.user_id', reqUser.id)
|
||||
.whereIn('stashes_actors.actor_id', actorIds)
|
||||
: [],
|
||||
reqUser
|
||||
reqUser && !options.shallow
|
||||
? knex('alerts_users_actors')
|
||||
.where('user_id', reqUser.id)
|
||||
.whereIn('actor_id', actorIds)
|
||||
: [],
|
||||
].slice(0, options.shallow ? 1 : -1));
|
||||
].slice(0, options.shallow ? 1 : undefined));
|
||||
|
||||
if (options.order) {
|
||||
return actors.map((actorEntry) => curateActor(actorEntry, {
|
||||
@@ -413,7 +439,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.includeAliased) {
|
||||
if (!filters.includeAliased && !filters.stashId) {
|
||||
builder.where('alias_for', 0);
|
||||
}
|
||||
|
||||
@@ -867,7 +893,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
|
||||
.update('alias_for', targetActorId)
|
||||
.whereIn('id', sourceActorIds)
|
||||
.returning(['id', 'alias_for']),
|
||||
// some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field
|
||||
trx('actors_avatars')
|
||||
.update('actor_id', targetActorId)
|
||||
.whereIn('actor_id', sourceActorIds),
|
||||
@@ -1060,6 +1085,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
||||
'actors_revisions.*',
|
||||
'users.username as username',
|
||||
'reviewers.username as reviewer_username',
|
||||
knex.raw('count(*) over() as total'),
|
||||
)
|
||||
.leftJoin('users', 'users.id', 'actors_revisions.user_id')
|
||||
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
|
||||
@@ -1106,6 +1132,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
||||
return {
|
||||
revisions: curatedRevisions,
|
||||
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
|
||||
total: Number(revisions[0]?.total) || 0,
|
||||
avatars,
|
||||
};
|
||||
}
|
||||
@@ -1184,7 +1211,9 @@ async function applyActorRevision(revisionIds, reqUser) {
|
||||
'gender',
|
||||
'avatar',
|
||||
'dateOfBirth',
|
||||
'dateOfBirthPrecision',
|
||||
'dateOfDeath',
|
||||
'dateOfDeathPrecision',
|
||||
'originCountry',
|
||||
'originState',
|
||||
'originCity',
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import config from 'config';
|
||||
import { CronJob } from 'cron';
|
||||
import { add } from 'date-fns';
|
||||
import escapeRegexp from 'escape-string-regexp';
|
||||
import markdownIt from 'markdown-it';
|
||||
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import { getIdsBySlug } from './cache.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger from './logger.js';
|
||||
import { indexApi } from './manticore.js';
|
||||
import { fetchScenesById } from './scenes.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
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 {
|
||||
id: alert.id,
|
||||
@@ -227,7 +249,7 @@ export async function createAlert(alert, reqUser) {
|
||||
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
|
||||
alert_id: alertId,
|
||||
entity_id: entityId,
|
||||
})).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode
|
||||
})).slice(0, alert.allEntities ? 1 : undefined)), // one scene can never match multiple entities in AND mode
|
||||
sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
|
||||
alert_id: alertId,
|
||||
scene_id: sceneId,
|
||||
@@ -351,6 +373,7 @@ export async function fetchNotifications(reqUser, options = {}) {
|
||||
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
|
||||
.leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id')
|
||||
.where('notifications.user_id', reqUser.id)
|
||||
.where('notifications.notify', true) // notification table is also used to defer e-mail alerts
|
||||
.limit(options.limit)
|
||||
.groupBy('notifications.id', 'alerts.id')
|
||||
.orderBy('created_at', 'desc'),
|
||||
@@ -393,18 +416,377 @@ export async function updateNotifications(updatedNotification, reqUser) {
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
function groupByKey(data, byKey, itemKey) {
|
||||
return data.reduce((acc, item) => {
|
||||
if (!acc[item[byKey]]) {
|
||||
acc[item[byKey]] = [];
|
||||
}
|
||||
|
||||
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const frequencies = {
|
||||
daily: { days: 1 },
|
||||
weekly: { weeks: 1 },
|
||||
monthly: { months: 1 },
|
||||
};
|
||||
|
||||
async function sendEmailAlerts() {
|
||||
if (!config.email.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qualifyingUsers = await knex('notifications')
|
||||
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
||||
.where('notifications.email', true)
|
||||
.innerJoin('users', 'users.id', 'notifications.user_id')
|
||||
.groupBy('users.id')
|
||||
.having(knex.raw('bool_or(emailed_at is null)'));
|
||||
|
||||
const dueUserIds = qualifyingUsers
|
||||
.filter(({ settings, last_email }) => {
|
||||
const frequency = frequencies[settings?.alertEmailFrequency];
|
||||
|
||||
if (!frequency || !last_email) {
|
||||
// email setting set to immediate or invalid, or no recent emails sent
|
||||
return true;
|
||||
}
|
||||
|
||||
// is this last e-mail sent before the frequency setting timeframe?
|
||||
return add(last_email, frequency) <= new Date();
|
||||
})
|
||||
.map((row) => row.user_id);
|
||||
|
||||
// immediately mark as e-mailed to prevent double e-mails if cronjob coincides with direct trigger
|
||||
const markedNotifications = await knex('notifications')
|
||||
.where('email', true)
|
||||
.whereNull('emailed_at')
|
||||
.whereIn('user_id', dueUserIds)
|
||||
.update('emailed_at', knex.fn.now())
|
||||
.returning('*');
|
||||
|
||||
const userIds = Array.from(new Set(markedNotifications.map((notification) => notification.user_id)));
|
||||
const sceneIds = Array.from(new Set(markedNotifications.map((notification) => notification.scene_id)));
|
||||
const alertIds = Array.from(new Set(markedNotifications.map((notification) => notification.alert_id)));
|
||||
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
const [
|
||||
users,
|
||||
alertsActors,
|
||||
alertsTags,
|
||||
alertsEntities,
|
||||
alertsScenes,
|
||||
alertsMatches,
|
||||
] = await Promise.all([
|
||||
knex('users')
|
||||
.select('users.id', 'users.username', 'users.email', 'users.settings')
|
||||
.whereIn('users.id', userIds),
|
||||
knex('alerts_actors')
|
||||
.select('alerts_actors.alert_id', 'actors.id', 'actors.name', 'actors.slug')
|
||||
.whereIn('alerts_actors.alert_id', alertIds)
|
||||
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
|
||||
knex('alerts_tags')
|
||||
.select('alerts_tags.alert_id', 'tags.id', 'tags.name', 'tags.slug')
|
||||
.whereIn('alerts_tags.alert_id', alertIds)
|
||||
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
|
||||
knex('alerts_entities')
|
||||
.select('alerts_entities.alert_id', 'entities.id', 'entities.type', 'entities.name', 'entities.slug')
|
||||
.whereIn('alerts_entities.alert_id', alertIds)
|
||||
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
|
||||
knex('alerts_scenes')
|
||||
.whereIn('alerts_scenes.alert_id', alertIds),
|
||||
knex('alerts_matches')
|
||||
.whereIn('alerts_matches.alert_id', alertIds),
|
||||
]);
|
||||
|
||||
const usersByUserId = Object.fromEntries(users.map((user) => [user.id, user]));
|
||||
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
|
||||
const alertActorsByAlertId = groupByKey(alertsActors, 'alert_id');
|
||||
const alertTagsByAlertId = groupByKey(alertsTags, 'alert_id');
|
||||
const alertEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id');
|
||||
const alertMatchesByAlertId = groupByKey(alertsMatches, 'alert_id');
|
||||
const alertSceneIdsByAlertId = groupByKey(alertsScenes, 'alert_id', 'scene_id');
|
||||
|
||||
const triggers = markedNotifications
|
||||
.map((notification) => ({
|
||||
notificationId: notification.id,
|
||||
scene: scenesBySceneId[notification.scene_id] || null,
|
||||
posterUrl: getImagePath(scenesBySceneId[notification.scene_id]?.poster),
|
||||
user: usersByUserId[notification.user_id] || null,
|
||||
alertId: notification.alert_id,
|
||||
alertTags: alertTagsByAlertId[notification.alert_id] || [],
|
||||
alertActors: alertActorsByAlertId[notification.alert_id] || [],
|
||||
alertEntities: alertEntitiesByAlertId[notification.alert_id] || [],
|
||||
alertMatches: alertMatchesByAlertId[notification.alert_id] || [],
|
||||
alertSceneIds: alertSceneIdsByAlertId[notification.alert_id] || [],
|
||||
}))
|
||||
.filter((notification) => !!notification.user
|
||||
&& !!notification.scene
|
||||
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
|
||||
|
||||
const emailItems = Object.values(usersByUserId).map((user) => {
|
||||
const userTriggers = Object.values(Object.fromEntries(triggers
|
||||
.filter((trigger) => trigger.user.id === user.id)
|
||||
.map((trigger) => [trigger.scene.id, trigger])))
|
||||
.toSorted((triggerA, triggerB) => triggerA.scene.effectiveDate - triggerB.scene.effectiveDate);
|
||||
|
||||
if (userTriggers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: 'alert',
|
||||
to: user.email,
|
||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||
props: {
|
||||
user,
|
||||
triggers: userTriggers,
|
||||
},
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
if (emailItems.length === 0) {
|
||||
logger.info(`No alert e-mails sent`);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmails(emailItems);
|
||||
}
|
||||
|
||||
export async function notify(sceneIds, options = {}) {
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
const [
|
||||
rawAlerts,
|
||||
alertsActors,
|
||||
alertsTags,
|
||||
alertsEntities,
|
||||
alertsMatches,
|
||||
alertsStashes,
|
||||
] = await Promise.all([
|
||||
knex('alerts')
|
||||
.select('alerts.*', 'users.username', 'users.email as user_email', 'users.settings as user_settings')
|
||||
.leftJoin('users', 'users.id', 'alerts.user_id'),
|
||||
knex('alerts_actors').select('alert_id', 'actor_id'),
|
||||
knex('alerts_tags').select('alert_id', 'tag_id'),
|
||||
knex('alerts_entities').select('alert_id', 'entity_id'),
|
||||
knex('alerts_matches'),
|
||||
knex('alerts_stashes'),
|
||||
]);
|
||||
|
||||
const alertActorIdsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
|
||||
const alertTagIdsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
|
||||
const alertEntityIdsByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
|
||||
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
|
||||
|
||||
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
|
||||
if (!acc[alertMatch.alert_id]) {
|
||||
acc[alertMatch.alert_id] = [];
|
||||
}
|
||||
|
||||
acc[alertMatch.alert_id].push({
|
||||
property: alertMatch.property,
|
||||
expression: new RegExp(alertMatch.expression, 'iu'),
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const alerts = rawAlerts
|
||||
.map((alert) => ({
|
||||
id: alert.id,
|
||||
userId: alert.user_id,
|
||||
username: alert.username,
|
||||
userEmail: alert.user_email,
|
||||
userSettings: alert.user_settings,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
all: alert.all,
|
||||
allActors: alert.all_actors,
|
||||
allEntities: alert.all_entities,
|
||||
allTags: alert.all_tags,
|
||||
allMatches: alert.all_matches,
|
||||
actorIds: alertActorIdsByAlertId[alert.id] || [],
|
||||
tagIds: alertTagIdsByAlertId[alert.id] || [],
|
||||
entityIds: alertEntityIdsByAlertId[alert.id] || [],
|
||||
matches: alertsMatchesByAlertId[alert.id] || [],
|
||||
stashIds: alertsStashesByAlertId[alert.id] || [],
|
||||
}))
|
||||
.filter((alert) => options.spoofUserIds ? options.spoofUserIds.includes(alert.userId) : true);
|
||||
|
||||
const curatedScenes = scenes
|
||||
.filter((scene) => scene.isNew || options.spoofNew)
|
||||
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
|
||||
.map((scene) => ({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
description: scene.description,
|
||||
actorIds: scene.actors.map((actor) => actor.id),
|
||||
tagIds: scene.tags.map((tag) => tag.id),
|
||||
entityId: scene.channel?.id || scene.network?.id || null,
|
||||
parentEntityId: scene.network?.id,
|
||||
}));
|
||||
|
||||
const triggers = alerts.flatMap((alert) => {
|
||||
const alertScenes = curatedScenes.filter((scene) => {
|
||||
if (alert.all) {
|
||||
if (alert.actorIds.length === 0
|
||||
&& alert.tagIds.length === 0
|
||||
&& alert.entityIds.length === 0
|
||||
&& alert.matches.length === 0) {
|
||||
// we must match on *something*, this is likely a release date alert handled separately
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.actorIds.length > 0 && !alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.tagIds.length > 0 && !alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entityIds.length > 0 && !alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.matches.length > 0 && !alert.matches[alert.allMatches ? 'every' : 'some']((match) => match.expression.test(scene[match.property]))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.matches.length > 0 && alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.tagIds.length > 0 && alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entityIds.length > 0 && alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.matches.length > 0 && alert.matches[alert.allMatches ? 'every' : 'some']((match) => match.expression.test(scene[match.property]))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return alertScenes.map((scene) => ({
|
||||
sceneId: scene.id,
|
||||
alert,
|
||||
}));
|
||||
});
|
||||
|
||||
// e-mails are stored as notifications to facilitate the e-mail frequency buffer
|
||||
const notifications = Object.values(triggers // prevent multiple notifications for the same scene
|
||||
.map((trigger) => ({
|
||||
...trigger,
|
||||
notify: trigger.alert.notify && trigger.alert.userSettings?.alertNotificationsEnabled !== false,
|
||||
email: trigger.alert.email && trigger.alert.userEmail && trigger.alert.userSettings?.alertEmailsEnabled !== false,
|
||||
}))
|
||||
.filter((trigger) => trigger.notify || trigger.email)
|
||||
.reduce((acc, trigger) => {
|
||||
const key = `${trigger.alert.userId}:${trigger.sceneId}`;
|
||||
|
||||
acc[key] = {
|
||||
...trigger,
|
||||
notify: acc[key]?.notify || trigger.notify,
|
||||
email: acc[key]?.email || trigger.email,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {}))
|
||||
.map((trigger) => ({
|
||||
user_id: trigger.alert.userId,
|
||||
alert_id: trigger.alert.id,
|
||||
scene_id: trigger.sceneId,
|
||||
// we assign the alert outputs to this specific notification, instead of joining them from the alerts table
|
||||
// this ensures changing the alert outputs later doesn't trigger a barrage of e-mails from old notifications
|
||||
notify: trigger.notify,
|
||||
email: trigger.email,
|
||||
}));
|
||||
|
||||
if (notifications.length === 0) {
|
||||
logger.verbose(`No alert notifications triggered`);
|
||||
}
|
||||
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
|
||||
stashId,
|
||||
sceneId: trigger.sceneId,
|
||||
userId: trigger.alert.userId,
|
||||
}))).map((stash) => [`${stash.stashId}:${stash.sceneId}`, stash])));
|
||||
|
||||
const stashEntries = uniqueStashes.map((stash) => ({
|
||||
scene_id: stash.sceneId,
|
||||
stash_id: stash.stashId,
|
||||
}));
|
||||
|
||||
const [stashed] = await Promise.all([
|
||||
stashEntries.length > 0
|
||||
? knex('stashes_scenes')
|
||||
.insert(stashEntries)
|
||||
.onConflict(['stash_id', 'scene_id'])
|
||||
.ignore()
|
||||
.returning('id')
|
||||
: [],
|
||||
notifications.length > 0
|
||||
? knex('notifications')
|
||||
.insert(notifications)
|
||||
: [],
|
||||
]);
|
||||
|
||||
if (stashed.length > 0) {
|
||||
// we need created_at from the databased, but user_id is not returned. it's easier to query it than to try and merge it with the input data
|
||||
const stashedEntries = await knex('stashes_scenes')
|
||||
.select('stashes_scenes.*', 'stashes.user_id')
|
||||
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
|
||||
.whereIn('stashes_scenes.id', stashed.map((stash) => stash.id));
|
||||
|
||||
const docs = stashedEntries.map((stash) => ({
|
||||
replace: {
|
||||
table: 'scenes_stashed',
|
||||
id: stash.id,
|
||||
doc: {
|
||||
scene_id: stash.scene_id,
|
||||
user_id: stash.user_id,
|
||||
stash_id: stash.stash_id,
|
||||
created_at: Math.round(stash.created_at.getTime() / 1000),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (docs.length > 0) {
|
||||
await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailAlerts();
|
||||
|
||||
return triggers;
|
||||
}
|
||||
|
||||
async function alertSceneReleases() {
|
||||
const alerts = await knex('alerts_scenes')
|
||||
.select(
|
||||
'alerts.id',
|
||||
'alerts.user_id',
|
||||
'alerts.*',
|
||||
'releases.id as scene_id',
|
||||
'releases.title as scene_title',
|
||||
'releases.slug as scene_slug',
|
||||
'releases.date as scene_date',
|
||||
)
|
||||
.leftJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id')
|
||||
.leftJoin('releases', 'releases.id', 'alerts_scenes.scene_id')
|
||||
.innerJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id')
|
||||
.innerJoin('releases', 'releases.id', 'alerts_scenes.scene_id')
|
||||
.where('alerts.is_expired', false)
|
||||
.where('releases.date', '<=', knex.fn.now());
|
||||
|
||||
@@ -420,6 +802,8 @@ async function alertSceneReleases() {
|
||||
user_id: alert.user_id,
|
||||
scene_id: alert.scene_id,
|
||||
alert_id: alert.id,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
})));
|
||||
|
||||
await trx('alerts')
|
||||
@@ -427,7 +811,13 @@ async function alertSceneReleases() {
|
||||
.update('is_expired', true);
|
||||
|
||||
await trx.commit();
|
||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
||||
|
||||
await Promise.all([
|
||||
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||
]);
|
||||
}
|
||||
catch (error) {
|
||||
await trx.rollback();
|
||||
@@ -457,7 +847,12 @@ async function purgeExpiredAlerts() {
|
||||
)
|
||||
.delete();
|
||||
|
||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
||||
await Promise.all([
|
||||
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||
]);
|
||||
|
||||
logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
|
||||
}
|
||||
@@ -470,6 +865,9 @@ export function initAlertCron() {
|
||||
alertSceneReleases(),
|
||||
purgeExpiredAlerts(),
|
||||
]);
|
||||
|
||||
// allow release alerts to set e-mail notifications
|
||||
await sendEmailAlerts();
|
||||
},
|
||||
start: config.alerts.enabled,
|
||||
runOnInit: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initAlertCron } from './alerts.js';
|
||||
import { initCaches } from './cache.js';
|
||||
import { initStashViewsCron } from './stashes.js';
|
||||
import { initSyncCron } from './sync.js';
|
||||
import initServer from './web/server.js';
|
||||
|
||||
@@ -9,6 +10,7 @@ async function init() {
|
||||
initServer();
|
||||
initSyncCron();
|
||||
initAlertCron();
|
||||
initStashViewsCron();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import util from 'node:util';
|
||||
@@ -5,9 +6,10 @@ import { shapes } from '@dicebear/collection';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import config from 'config';
|
||||
import { verify } from 'hcaptcha';
|
||||
import { verify as verifyCaptcha } from 'hcaptcha';
|
||||
|
||||
import slugify from '../utils/slugify.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger, { initAccessLogger } from './logger.js';
|
||||
@@ -44,19 +46,71 @@ async function generateAvatar(user) {
|
||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
||||
}
|
||||
|
||||
async function checkLoginCooldown(identifier, attemptThreshold) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
||||
|
||||
if (attempts >= attemptThreshold) {
|
||||
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function setLoginCooldown(identifier, attemptCooldown) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
await redis.incr(cooldownKey);
|
||||
await redis.expire(cooldownKey, attemptCooldown * 60);
|
||||
}
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 405);
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.username) {
|
||||
throw new HttpError('Please provide a username or e-mail', 400);
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError('Please provide a password', 400);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
||||
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
||||
]);
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
}).catch(async () => {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
try {
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
}
|
||||
catch (error) {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await knex('users')
|
||||
.update('last_login', 'NOW()')
|
||||
@@ -73,12 +127,281 @@ export async function login(credentials, userIp) {
|
||||
}
|
||||
|
||||
// fetched the raw user for password verification, don't return directly to user
|
||||
return curateUser(user);
|
||||
return curateUser(user, user);
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
|
||||
return {
|
||||
storedPassword,
|
||||
hashedPassword,
|
||||
salt,
|
||||
};
|
||||
}
|
||||
|
||||
async function tokenCooldown(identifier) {
|
||||
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
if (await redis.exists(cooldownKey)) {
|
||||
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
||||
}
|
||||
|
||||
await redis.set(cooldownKey, JSON.stringify(true));
|
||||
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('E-mail verification is disabled', 503);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new HttpError('No user provided', 401);
|
||||
}
|
||||
|
||||
const email = customEmail || user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'verify',
|
||||
to: email,
|
||||
subject: 'Please verify your traxxx e-mail address',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
trigger,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(credential) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('Password reset is disabled', 503);
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
throw new HttpError('No credential provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(credential);
|
||||
|
||||
const user = await fetchUser(credential, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
||||
if (!user) {
|
||||
logger.info(`Password reset requested for unknown user '${credential}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'recovery',
|
||||
to: user.email,
|
||||
subject: 'Reset your traxxx password',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function resetPassword(userId, emailToken, newPassword) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new HttpError('New password not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { token } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(newPassword);
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update('password', storedPassword);
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('Password reset failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSignup(email) {
|
||||
if (!config.auth.signup || !config.email.enabled) {
|
||||
throw new HttpError('Sign-ups are disabled', 503);
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
||||
|
||||
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenKey = `traxxx:token_signup:${token}`;
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'signup',
|
||||
to: email,
|
||||
subject: 'Sign up for traxxx',
|
||||
props: {
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 405);
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.token) {
|
||||
throw new HttpError('Token required', 400);
|
||||
}
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
@@ -99,41 +422,44 @@ export async function signup(credentials, userIp) {
|
||||
throw new HttpError('Username contains invalid characters', 400);
|
||||
}
|
||||
|
||||
if (!credentials.email) {
|
||||
throw new HttpError('E-mail required', 400);
|
||||
}
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.first();
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username already in use', 409);
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
||||
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||
|
||||
if (!captchaVerification.success) {
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||
throw new HttpError('Invalid CAPTCHA', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
||||
.first();
|
||||
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
const { email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
username: curatedUsername,
|
||||
email: credentials.email,
|
||||
email,
|
||||
password: storedPassword,
|
||||
email_verified: true,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
@@ -145,14 +471,138 @@ export async function signup(credentials, userIp) {
|
||||
primary: true,
|
||||
});
|
||||
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||
|
||||
await generateAvatar({
|
||||
id: userId,
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
return fetchUser(userId);
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
// no longer needed with token-first signup flow
|
||||
// await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user, // not reqUser, we don't want to leak details about the requester
|
||||
},
|
||||
}]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
}
|
||||
|
||||
if (!credentials?.email && !credentials.password) {
|
||||
throw new HttpError('No changeable credentials provided', 400);
|
||||
}
|
||||
|
||||
if (credentials.email && !config.email.enabled) {
|
||||
throw new HttpError('E-mail is currently disabled', 503);
|
||||
}
|
||||
|
||||
if (credentials.password && credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpError('No current password provided', 401);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(reqUser.id, {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(password, user.password);
|
||||
|
||||
if (credentials.password) {
|
||||
await updatePassword(credentials.password, reqUser, reqSession);
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
await updateEmail(credentials.email, reqUser);
|
||||
}
|
||||
|
||||
return curateUser(user, reqUser);
|
||||
}
|
||||
|
||||
const validSettingKeys = [
|
||||
'alertNotificationsEnabled',
|
||||
'alertEmailsEnabled',
|
||||
'alertEmailsFrequency',
|
||||
];
|
||||
|
||||
export async function updateSettings(settings, reqUser) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||
}
|
||||
|
||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||
throw new HttpError('Invalid email frequency setting', 400);
|
||||
}
|
||||
|
||||
const [{ settings: updatedSettings }] = await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||
.returning('settings');
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
function curateKey(key) {
|
||||
@@ -178,6 +628,14 @@ export async function verifyKey(userId, key, req) {
|
||||
throw new HttpError('The API credentials are not provided.', 401);
|
||||
}
|
||||
|
||||
if (typeof userId !== 'number') {
|
||||
throw new HttpError('User ID is not a number', 400);
|
||||
}
|
||||
|
||||
if (typeof key !== 'string') {
|
||||
throw new HttpError('Key is not a string', 400);
|
||||
}
|
||||
|
||||
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
||||
|
||||
const storedKey = await knex('users_keys')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
||||
|
||||
export const emailsDir = path.join(process.cwd(), '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$/, ''));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import config from 'config';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import initLogger from './logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
const resend = (() => {
|
||||
if (config.email.enabled) {
|
||||
try {
|
||||
return new Resend(config.email.apiKey);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to initialize Resend: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
export async function renderEmail(template, { to, subject, props }) {
|
||||
const { html, text } = await emailTemplates.render(`${template}.vue`, {
|
||||
props: {
|
||||
...props,
|
||||
address: config.web.address,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
|
||||
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
|
||||
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
|
||||
export async function sendEmails(items) {
|
||||
if (!config.email.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resend) {
|
||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rendered = await Promise.all(items.map(async (item) => {
|
||||
try {
|
||||
return await renderEmail(item.template, item);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
const sentItems = items.filter((item, index) => rendered[index]);
|
||||
const emails = rendered.filter(Boolean);
|
||||
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
data.errors?.forEach(({ index, message }) => {
|
||||
const item = sentItems[index];
|
||||
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`);
|
||||
});
|
||||
|
||||
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pageContext } from '../renderer/usePageContext.js';
|
||||
|
||||
function getBasePath(media, options) {
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return pageContext.env.media.assetPath;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ function getBasePath(media, options) {
|
||||
}
|
||||
|
||||
function getFilename(media, type, options) {
|
||||
if (pageContext.restriction && type && !options?.original) {
|
||||
if (pageContext.geo.restriction && type && !options?.original) {
|
||||
return media.sfw?.[type];
|
||||
}
|
||||
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return media.sfw?.path;
|
||||
}
|
||||
|
||||
|
||||