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 | ||
|
|
18693faa5f | ||
|
|
378f81efe4 | ||
|
|
05e3cf811a | ||
|
|
5815ac2bf4 | ||
|
|
cafa6476ca | ||
|
|
3007150aeb | ||
|
|
2199bb3f39 |
@@ -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"
|
||||
|
||||
@@ -114,7 +114,11 @@ const aliasTooltipId = useId();
|
||||
|
||||
<Icon
|
||||
v-if="actor.alias && actor.alias.name !== actor.name"
|
||||
v-tooltip="{ content: `Credited as '${actor.alias.name}'`, ariaId: aliasTooltipId }"
|
||||
v-tooltip="{
|
||||
content: `Credited as '${actor.alias.name}'`,
|
||||
triggers: ['hover', 'click'],
|
||||
ariaId: aliasTooltipId,
|
||||
}"
|
||||
icon="at-sign"
|
||||
class="alias"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -243,8 +243,8 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
||||
}
|
||||
|
||||
.actors {
|
||||
height: 1.15rem;
|
||||
margin-bottom: .15rem;
|
||||
height: 1lh;
|
||||
margin-bottom: .3rem;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -215,26 +218,24 @@ async function reloadStashes(newStash) {
|
||||
>
|
||||
<Icon
|
||||
v-if="itemAlerted"
|
||||
v-tooltip="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
|
||||
icon="bell2"
|
||||
:title="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
|
||||
class="alert active noselect"
|
||||
@click="removeAlert"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="itemAlerts.multi.length > 0"
|
||||
v-tooltip="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
|
||||
icon="bell-plus"
|
||||
:title="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
|
||||
class="alert partial noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip="domain === 'scenes' ? `Set alert for the release of '${item.title}'` : `Set alert for '${item.title || item.name}' releases`"
|
||||
icon="bell-plus"
|
||||
:title="`Set alert for '${item.title || item.name}' releases`"
|
||||
class="alert noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
@@ -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.0",
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ const tagSlugs = {
|
||||
'anal',
|
||||
'lesbian',
|
||||
// 'interracial',
|
||||
'mff',
|
||||
'mfm',
|
||||
'threesome',
|
||||
// 'teen',
|
||||
'milf',
|
||||
'blowjob',
|
||||
@@ -48,6 +47,7 @@ const tagSlugs = {
|
||||
'atm',
|
||||
],
|
||||
group: [
|
||||
'threesome',
|
||||
'mfm',
|
||||
'mff',
|
||||
'orgy',
|
||||
@@ -73,7 +73,7 @@ const tagSlugs = {
|
||||
'nurse',
|
||||
'maid',
|
||||
],
|
||||
extreme: [
|
||||
penetration: [
|
||||
'dp',
|
||||
'airtight',
|
||||
'dap',
|
||||
@@ -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);
|
||||
|
||||