Compare commits

...
88 Commits
Author SHA1 Message Date
DebaucheryLibrarian 10ac366e87 0.57.25 2026-09-22 02:22:21 +02:00
DebaucheryLibrarian a5e06b51ea Fixed scene serie poster curation and tile display. 2026-09-22 02:22:19 +02:00
DebaucheryLibrarian b3dcb65871 0.57.24 2026-09-22 00:54:23 +02:00
DebaucheryLibrarian 4527bf2495 Fixed alerts cron never initializing due missing config. 2026-09-22 00:54:22 +02:00
DebaucheryLibrarian 6c0e4d5456 0.57.23 2026-09-20 19:11:32 +02:00
DebaucheryLibrarian 8dd136446d Changed Chapter GraphQL type to accommodate video-type chapters without a starting time. 2026-09-20 19:11:30 +02:00
DebaucheryLibrarian 1cc94d665a 0.57.22 2026-09-18 23:25:51 +02:00
DebaucheryLibrarian a9d9b7623d Added actor date of birth and death precision. 2026-09-18 23:25:49 +02:00
DebaucheryLibrarian 83c0f1e4c6 0.57.21 2026-09-18 16:11:29 +02:00
DebaucheryLibrarian 64b92536d5 Fixed dark theme text color for revision count. 2026-09-18 16:11:27 +02:00
DebaucheryLibrarian cea5b98bda 0.57.20 2026-09-18 03:37:06 +02:00
DebaucheryLibrarian 411cde7cf9 Extended revision pagination to scene and actor pages, fixes 'revisions' label without count. 2026-09-18 03:37:03 +02:00
DebaucheryLibrarian 73d65ae873 0.57.19 2026-09-18 03:31:32 +02:00
DebaucheryLibrarian b5e9886b9b Added pagination and total to revisions. 2026-09-18 03:31:28 +02:00
DebaucheryLibrarian cd10f14285 0.57.18 2026-09-17 02:17:34 +02:00
DebaucheryLibrarian 514ed235a4 Added a default og:description. 2026-09-17 02:17:32 +02:00
DebaucheryLibrarian 00c6d17aaa 0.57.17 2026-09-17 01:17:06 +02:00
DebaucheryLibrarian de0b1c2baf Added scene chapters to GraphQL. Verifying API credential types. 2026-09-17 01:17:03 +02:00
DebaucheryLibrarian e9b7bb03d0 0.57.16 2026-08-17 00:06:56 +02:00
DebaucheryLibrarian 1e7207160c Fixed alert stash not syncing to manticore. 2026-08-17 00:06:53 +02:00
DebaucheryLibrarian 37a919770a 0.57.15 2026-07-28 23:46:12 +02:00
DebaucheryLibrarian 4e59994806 Fixed notify stash manticoresearch call still using obsolete 'index' key. 2026-07-28 23:46:10 +02:00
DebaucheryLibrarian 699f7f0944 0.57.14 2026-07-26 03:40:37 +02:00
DebaucheryLibrarian bf220f6b4e Added stash archiving. 2026-07-26 03:40:32 +02:00
DebaucheryLibrarian 02ee99d52f 0.57.13 2026-07-26 03:15:52 +02:00
DebaucheryLibrarian e50dedca66 Fixed profile page breaking when logged out. 2026-07-26 03:15:50 +02:00
DebaucheryLibrarian 760025f414 0.57.12 2026-07-26 00:26:51 +02:00
DebaucheryLibrarian 07d9dfaaaa Added stash sorting to stash tooltip menu and profile page. 2026-07-26 00:26:49 +02:00
DebaucheryLibrarian d3f549c92f 0.57.11 2026-07-25 03:11:39 +02:00
DebaucheryLibrarian bf64a3d30e Only listing social links from known platforms in bio. 2026-07-25 03:11:36 +02:00
DebaucheryLibrarian b27c4811da 0.57.10 2026-07-25 03:02:42 +02:00
DebaucheryLibrarian c462c2b11a Tweaked merge and alert search result tooltips. 2026-07-25 03:02:40 +02:00
DebaucheryLibrarian a774254e71 0.57.9 2026-07-25 02:28:09 +02:00
DebaucheryLibrarian 0a665ab553 Ordering stashes by last stashed item. 2026-07-25 02:28:06 +02:00
DebaucheryLibrarian c2d8cc3a40 0.57.8 2026-07-25 01:17:15 +02:00
DebaucheryLibrarian eed07ba84b Improved tooltip positioning and sizing, in particular for stashing (heart). 2026-07-25 01:17:12 +02:00
DebaucheryLibrarian e8f1dca2a0 0.57.7 2026-07-24 02:06:47 +02:00
DebaucheryLibrarian 4037098927 Fixed scene banner pushing grid row spacing. 2026-07-24 02:06:45 +02:00
DebaucheryLibrarian e007dd2b83 0.57.6 2026-07-24 00:33:58 +02:00
DebaucheryLibrarian b86cf8e30f Added grid rule to prevent row gaps on large screens. 2026-07-24 00:33:56 +02:00
DebaucheryLibrarian e46f174a1e 0.57.5 2026-07-23 23:36:21 +02:00
DebaucheryLibrarian 32b8f00308 Removed inop actor merge avatar conflict ignore. 2026-07-23 23:36:18 +02:00
DebaucheryLibrarian c9e698c906 0.57.4 2026-07-23 23:06:21 +02:00
DebaucheryLibrarian 286248e403 Widened actor merge avatar conflict ignore scope. 2026-07-23 23:06:18 +02:00
DebaucheryLibrarian f9e03f47ba 0.57.3 2026-07-23 22:54:39 +02:00
DebaucheryLibrarian b54174eb5e Ignoring actor merge avatar conflicts, put actor merge source IDs in post body. 2026-07-23 22:54:35 +02:00
DebaucheryLibrarian 62d19d1a04 0.57.2 2026-07-23 22:12:34 +02:00
DebaucheryLibrarian 6b3866a99f Changed sign-up flow to require e-mail verification up front, preventing account existence leakage. 2026-07-23 22:12:33 +02:00
DebaucheryLibrarian 18c30b6f98 0.57.1 2026-07-23 05:34:32 +02:00
DebaucheryLibrarian 77b27a56f7 Determining location with restrictions disabled, cleaned up. 2026-07-23 05:34:28 +02:00
DebaucheryLibrarian 4c48700086 0.57.0 2026-07-23 04:40:27 +02:00
DebaucheryLibrarian 6f68a50eb7 Added password recovery and login attempt guards. 2026-07-23 04:40:25 +02:00
DebaucheryLibrarian ef9be8ef9a 0.56.4 2026-07-22 17:07:26 +02:00
DebaucheryLibrarian a81343c519 Improved mobile scaling of preferences and account settings. 2026-07-22 17:07:23 +02:00
DebaucheryLibrarian 60ad068939 0.56.3 2026-07-22 06:06:46 +02:00
DebaucheryLibrarian df6965cb3a Fixed stray headings on account page, fixed summary editor not full-width. 2026-07-22 06:06:43 +02:00
DebaucheryLibrarian 9e95bec659 0.56.2 2026-07-22 06:02:44 +02:00
DebaucheryLibrarian 88b866c96a Setting e-mail web address in email module. 2026-07-22 06:02:42 +02:00
DebaucheryLibrarian 5784c08e1b 0.56.1 2026-07-22 05:59:13 +02:00
DebaucheryLibrarian 753840f4f1 Disabling account e-mail fields if e-mail service is disabled (verification e-mail couldn't be sent). 2026-07-22 05:59:11 +02:00
DebaucheryLibrarian dbe27d68d3 0.56.0 2026-07-22 05:49:43 +02:00
DebaucheryLibrarian 9b058fd44b Added account update form and e-mail verification. 2026-07-22 05:49:39 +02:00
DebaucheryLibrarian 17d34f6a80 0.55.4 2026-07-21 05:54:45 +02:00
DebaucheryLibrarian 17fd285d67 Fixed actor alert button malfunctioning due missing query. Refreshing alert meta views after batch processing alerts. 2026-07-21 05:54:43 +02:00
DebaucheryLibrarian 097baed957 0.55.3 2026-07-21 05:29:47 +02:00
DebaucheryLibrarian b983fbaa1f Fixed address prop not being passed to email template. 2026-07-21 05:29:45 +02:00
DebaucheryLibrarian afe337d44a 0.55.2 2026-07-21 05:17:40 +02:00
DebaucheryLibrarian 6fe8c84ef4 Added profile ID as username title on profile page. 2026-07-21 05:17:36 +02:00
DebaucheryLibrarian e71a6987fc 0.55.1 2026-07-21 05:14:34 +02:00
DebaucheryLibrarian 938878d860 Fixed email component import path. 2026-07-21 05:14:32 +02:00
DebaucheryLibrarian 04abff06ec Separated Resend initialization failure in email handler. 2026-07-21 04:59:05 +02:00
DebaucheryLibrarian 52d5a314de Fixed failed Resend initialization crashing entire application. 2026-07-21 04:57:48 +02:00
DebaucheryLibrarian 5b17eb15a2 Added user ID requirement to notify tool to prevent mass-notifying all users. 2026-07-21 04:41:29 +02:00
DebaucheryLibrarian 49ef28a5bf Added warn log for failed e-mail renders. 2026-07-21 04:34:21 +02:00
DebaucheryLibrarian 0d481e7798 0.55.0 2026-07-21 04:31:23 +02:00
DebaucheryLibrarian 827566d57d Integrated e-mail alerts, added dialog option for release alerts. 2026-07-21 04:31:12 +02:00
DebaucheryLibrarian 6444cf21eb Added preferences and serialize to repo. Stale sessions are refreshed on restart or after a period of time now. 2026-07-18 06:24:44 +02:00
DebaucheryLibrarian 216e7f59ef Added e-mail alert settings, saveable but not integrated yet. 2026-07-18 06:24:07 +02:00
DebaucheryLibrarian 479b044798 0.54.0 2026-07-18 03:21:02 +02:00
DebaucheryLibrarian 7c6bde690f Replaced profile pill bar with portal page. 2026-07-18 03:20:50 +02:00
DebaucheryLibrarian 981205efaa Enabled e-mail action in alert dialog and alerts overview action indicators. 2026-07-18 01:02:03 +02:00
DebaucheryLibrarian b6408f3244 Implemented e-mail notification trigger. 2026-07-18 00:01:18 +02:00
DebaucheryLibrarian fab895b85e 0.53.0 2026-07-16 02:21:58 +02:00
DebaucheryLibrarian 9d869c4a37 Integrated notify from legacy triggered by sync. 2026-07-16 02:21:55 +02:00
DebaucheryLibrarian 583b6984d8 0.52.6 2026-07-13 22:05:08 +02:00
DebaucheryLibrarian ad912e8f03 Integrated scene alerts in alert filters. 2026-07-13 22:05:06 +02:00
DebaucheryLibrarian fe1170b22e 0.52.5 2026-07-13 21:52:24 +02:00
DebaucheryLibrarian b69e0a104c Fixed empty studio returned for scenes without studio. 2026-07-13 21:52:17 +02:00
118 changed files with 11065 additions and 701 deletions
+2
View File
@@ -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
View File
@@ -1,4 +1,5 @@
.input {
width: 15rem;
box-sizing: border-box;
padding: .5rem .75rem;
font-size: 1rem;
+15 -3
View File
@@ -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,
+5
View File
@@ -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

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

After

Width:  |  Height:  |  Size: 756 B

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

After

Width:  |  Height:  |  Size: 488 B

+18
View File
@@ -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

+18
View File
@@ -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

+16
View File
@@ -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

+38
View File
@@ -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

+1 -1
Submodule common updated: ec0812ad9d...7df69021ea
+1
View File
@@ -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;
}
+4
View File
@@ -148,6 +148,10 @@ async function addActor() {
gap: 1.25rem;
}
.input {
width: 100%;
}
.load-container {
width: 100%;
display: inline-flex;
+27 -13
View File
@@ -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
+3 -1
View File
@@ -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"
+11
View File
@@ -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
+39 -22
View File
@@ -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;
+95 -22
View File
@@ -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;
+3
View File
@@ -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">
+39 -6
View File
@@ -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;
+286
View File
@@ -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(', ') }}&nbsp;</template>
<template
v-if="trigger.alertEntities.length > 0"
>for {{ trigger.alertEntities[0].name }}&nbsp;</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>
+106
View File
@@ -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>
+79
View File
@@ -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>
+80
View File
@@ -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>
+62
View File
@@ -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>
+81
View File
@@ -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>
+3 -1
View File
@@ -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>
+92
View File
@@ -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>
+28 -27
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+4
View File
@@ -13,6 +13,10 @@ const props = defineProps({
type: Number,
default: null,
},
limit: {
type: Number,
default: null,
},
redirect: {
type: Boolean,
default: true,
+7
View File
@@ -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;
+1
View File
@@ -253,6 +253,7 @@ onMounted(() => {
}
.input {
width: 100%;
resize: none;
background: var(--background);
}
+18 -2
View File
@@ -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 {
+76
View File
@@ -0,0 +1,76 @@
<script setup>
import Cookies from 'js-cookie';
import { ref } from 'vue';
import Checkbox from '#/components/form/checkbox.vue';
const cookies = Cookies.withConverter({
write: (value) => value,
});
const tags = {
'anal': 'anal',
'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
'pissing': 'pissing',
'gay': 'gay',
'transsexual': 'transsexual',
'bisexual': 'bisexual',
'compilation': 'compilation',
'bts': 'behind the scenes',
'vr': 'virtual reality',
};
const storedTags = cookies.get('tags');
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
function toggleTag(tag, isChecked) {
if (isChecked) {
checkedTags.value.add(tag);
}
else {
checkedTags.value.delete(tag);
}
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
}
</script>
<template>
<div class="dialog-section">
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
<ul class="tags nolist">
<li
v-for="(label, slug) in tags"
:key="`tag-${slug}`"
class="tags-item"
>
<label class="tag noselect">
<Checkbox
:value="slug"
:checked="checkedTags.has(slug)"
class="minus"
@change="(checked) => toggleTag(slug, checked)"
/>{{ label }}
</label>
</li>
</ul>
</div>
</template>
<style scoped>
.tags-item {
display: block;
}
.tag {
display: flex;
padding: .5rem 0;
text-transform: capitalize;
.check-container {
margin-right: 1rem;
}
}
</style>
+27
View File
@@ -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>
+109 -59
View File
@@ -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>
+10 -3
View File
@@ -125,6 +125,9 @@ async function alertItem() {
...(props.domain === 'scenes' && { scenes: [props.item.id] }),
notify: true,
email: false,
// probably too confusing and annoying to have quick alerts inherit email preference from dialog
// notify: JSON.parse(localStorage.getItem('alert_notify')) ?? true,
// email: JSON.parse(localStorage.getItem('alert_email')) ?? false,
preset: true,
}, {
successFeedback: `Alert set for '${alertLabel}'`,
@@ -173,7 +176,7 @@ async function removeAlert() {
function setAlerted(alert) {
// only set alerted status if the current item is the only one in the new stash
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches, ...alert.scenes];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true;
@@ -219,7 +222,6 @@ async function reloadStashes(newStash) {
icon="bell2"
class="alert active noselect"
@click="removeAlert"
@contextmenu.prevent="showAlertDialog = true"
/>
<Icon
@@ -228,7 +230,6 @@ async function reloadStashes(newStash) {
icon="bell-plus"
class="alert partial noselect"
@click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
/>
<Icon
@@ -245,6 +246,8 @@ async function reloadStashes(newStash) {
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false"
>
<Icon
@@ -258,6 +261,7 @@ async function reloadStashes(newStash) {
<StashMenu
:stashes="stashes"
:item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true"
@@ -271,6 +275,8 @@ async function reloadStashes(newStash) {
:triggers="[]"
:shown="showStashes"
:disabled="showSecondary"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false"
>
<template v-if="pageStash?.user.id === user.id">
@@ -313,6 +319,7 @@ async function reloadStashes(newStash) {
<StashMenu
:stashes="stashes"
:item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true"
+135 -20
View File
@@ -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>
+127 -5
View File
@@ -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;
+47
View File
@@ -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 {
+300
View File
@@ -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>
+13 -9
View File
@@ -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;
+126
View File
@@ -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>
+16
View File
@@ -33,6 +33,7 @@ module.exports = {
},
},
web: {
address: 'https://traxxx.me',
host: '0.0.0.0',
port: 5000,
sfwHost: '0.0.0.0',
@@ -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>',
},
};
+12
View File
@@ -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',
+6585 -167
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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.4",
"version": "0.57.25",
"imports": {
"#/*": "./*.js"
},
+6
View File
@@ -1,5 +1,11 @@
export default {
meta: {
title: {
env: {
server: true,
client: true,
},
},
data: {
env: {
server: true,
-1
View File
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
return {
pageContext: {
title: 'actors',
pageProps: {
actors,
countries,
+1
View File
@@ -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,
},
},
+45 -6
View File
@@ -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'"
+38
View File
@@ -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>
+18
View File
@@ -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(),
},
},
};
}
+70
View File
@@ -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,
},
},
};
}
+18
View File
@@ -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,
},
},
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
},
};
}
+21 -26
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export default 'Login';
+218
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export default '/recovery';
+1
View File
@@ -0,0 +1 @@
export default `Account recovery`;
+102 -82
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export default 'Sign up';
+1 -1
View File
@@ -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),
]);
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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,
}),
]);
+1
View File
@@ -0,0 +1 @@
export default 'Account recovery';
+1 -1
View File
@@ -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 },
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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,
}),
]);
+1
View File
@@ -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;
}
+3 -3
View File
@@ -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),
]));
+3 -3
View File
@@ -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}`);
+1 -1
View File
@@ -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 },
+117 -22
View File
@@ -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;
+7 -3
View File
@@ -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,
+8 -5
View File
@@ -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,
},
};
}
+85
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+1 -1
View File
@@ -9,6 +9,6 @@ export default {
'assets',
'campaigns',
'meta',
'restriction',
'geo',
],
};
+7 -4
View File
@@ -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" />
+8 -1
View File
@@ -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);
+1 -1
View File
@@ -189,7 +189,7 @@ onMounted(() => {
justify-content: center;
position: fixed;
bottom: 2rem;
z-index: 1000;
z-index: 10000;
pointer-events: none;
}
+34 -5
View File
@@ -32,7 +32,9 @@ const mj = new MerkleJson();
const keyMap = {
avatar: 'avatar_media_id',
dateOfBirth: 'date_of_birth',
dateOfBirthPrecision: 'date_of_birth_precision',
dateOfDeath: 'date_of_death',
dateOfDeathPrecision: 'date_of_death_precision',
originCountry: 'birth_country_alpha2',
originState: 'birth_state',
originCity: 'birth_city',
@@ -62,6 +64,24 @@ const keyMap = {
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
// with month (but not day) precision on either side, the age is still certain unless both dates fall in the same month,
// since only then could the unknown day(s) put the birthday on either side of the reference date
function isAgeApproximate(baseDate, basePrecision, referenceDate, referencePrecision = 'day') {
if (!baseDate || !referenceDate) {
return false;
}
if (basePrecision === 'year' || referencePrecision === 'year') {
return true;
}
if (basePrecision === 'day' && referencePrecision === 'day') {
return false;
}
return baseDate.getMonth() === referenceDate.getMonth();
}
export function curateActor(actor, context = {}) {
if (!actor) {
return null;
@@ -97,14 +117,20 @@ export function curateActor(actor, context = {}) {
ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(Date.now(), actor.date_of_birth)
: null,
ageFromBirthIsApproximate: actor.date_of_birth_precision !== 'day'
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, new Date()),
ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
? differenceInYears(actor.date_of_death, actor.date_of_birth)
: null,
ageAtDeathIsApproximate: (actor.date_of_birth_precision !== 'day' || actor.date_of_death_precision !== 'day')
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, actor.date_of_death, actor.date_of_death_precision),
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(context.sceneDate, actor.date_of_birth)
: null,
dateOfBirth: actor.date_of_birth,
dateOfBirthPrecision: actor.date_of_birth_precision,
dateOfDeath: actor.date_of_death,
dateOfDeathPrecision: actor.date_of_death_precision,
bust: actor.bust,
cup: actor.cup,
waist: actor.waist,
@@ -304,18 +330,18 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
knex('actors_socials')
.whereIn('actor_id', actorIds),
reqUser
reqUser && !options.shallow
? knex('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actorIds)
: [],
reqUser
reqUser && !options.shallow
? knex('alerts_users_actors')
.where('user_id', reqUser.id)
.whereIn('actor_id', actorIds)
: [],
].slice(0, options.shallow ? 1 : -1));
].slice(0, options.shallow ? 1 : undefined));
if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, {
@@ -413,7 +439,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
}
}
if (!filters.includeAliased) {
if (!filters.includeAliased && !filters.stashId) {
builder.where('alias_for', 0);
}
@@ -867,7 +893,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
.update('alias_for', targetActorId)
.whereIn('id', sourceActorIds)
.returning(['id', 'alias_for']),
// some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field
trx('actors_avatars')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds),
@@ -1060,6 +1085,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
'actors_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
knex.raw('count(*) over() as total'),
)
.leftJoin('users', 'users.id', 'actors_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
@@ -1106,6 +1132,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
total: Number(revisions[0]?.total) || 0,
avatars,
};
}
@@ -1184,7 +1211,9 @@ async function applyActorRevision(revisionIds, reqUser) {
'gender',
'avatar',
'dateOfBirth',
'dateOfBirthPrecision',
'dateOfDeath',
'dateOfDeathPrecision',
'originCountry',
'originState',
'originCity',
+405 -7
View File
@@ -1,16 +1,38 @@
import config from 'config';
import { CronJob } from 'cron';
import { add } from 'date-fns';
import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it';
import promiseProps from '../utils/promise-props.js';
import { getIdsBySlug } from './cache.js';
import { sendEmails } from './email.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js';
import { indexApi } from './manticore.js';
import { fetchScenesById } from './scenes.js';
const logger = initLogger();
function getImagePath(media) {
if (!media) {
return null;
}
if (media.isS3) {
return new URL(media.lazy, config.media.s3Path).href;
}
return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href;
}
const md = markdownIt({
html: true,
});
md.disable('code');
function curateAlert(alert, context = {}) {
return {
id: alert.id,
@@ -227,7 +249,7 @@ export async function createAlert(alert, reqUser) {
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
alert_id: alertId,
entity_id: entityId,
})).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode
})).slice(0, alert.allEntities ? 1 : undefined)), // one scene can never match multiple entities in AND mode
sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
alert_id: alertId,
scene_id: sceneId,
@@ -351,6 +373,7 @@ export async function fetchNotifications(reqUser, options = {}) {
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
.leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id')
.where('notifications.user_id', reqUser.id)
.where('notifications.notify', true) // notification table is also used to defer e-mail alerts
.limit(options.limit)
.groupBy('notifications.id', 'alerts.id')
.orderBy('created_at', 'desc'),
@@ -393,18 +416,377 @@ export async function updateNotifications(updatedNotification, reqUser) {
return updatedCount;
}
function groupByKey(data, byKey, itemKey) {
return data.reduce((acc, item) => {
if (!acc[item[byKey]]) {
acc[item[byKey]] = [];
}
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
return acc;
}, {});
}
const frequencies = {
daily: { days: 1 },
weekly: { weeks: 1 },
monthly: { months: 1 },
};
async function sendEmailAlerts() {
if (!config.email.enabled) {
return;
}
const qualifyingUsers = await knex('notifications')
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
.where('notifications.email', true)
.innerJoin('users', 'users.id', 'notifications.user_id')
.groupBy('users.id')
.having(knex.raw('bool_or(emailed_at is null)'));
const dueUserIds = qualifyingUsers
.filter(({ settings, last_email }) => {
const frequency = frequencies[settings?.alertEmailFrequency];
if (!frequency || !last_email) {
// email setting set to immediate or invalid, or no recent emails sent
return true;
}
// is this last e-mail sent before the frequency setting timeframe?
return add(last_email, frequency) <= new Date();
})
.map((row) => row.user_id);
// immediately mark as e-mailed to prevent double e-mails if cronjob coincides with direct trigger
const markedNotifications = await knex('notifications')
.where('email', true)
.whereNull('emailed_at')
.whereIn('user_id', dueUserIds)
.update('emailed_at', knex.fn.now())
.returning('*');
const userIds = Array.from(new Set(markedNotifications.map((notification) => notification.user_id)));
const sceneIds = Array.from(new Set(markedNotifications.map((notification) => notification.scene_id)));
const alertIds = Array.from(new Set(markedNotifications.map((notification) => notification.alert_id)));
const scenes = await fetchScenesById(sceneIds);
const [
users,
alertsActors,
alertsTags,
alertsEntities,
alertsScenes,
alertsMatches,
] = await Promise.all([
knex('users')
.select('users.id', 'users.username', 'users.email', 'users.settings')
.whereIn('users.id', userIds),
knex('alerts_actors')
.select('alerts_actors.alert_id', 'actors.id', 'actors.name', 'actors.slug')
.whereIn('alerts_actors.alert_id', alertIds)
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
knex('alerts_tags')
.select('alerts_tags.alert_id', 'tags.id', 'tags.name', 'tags.slug')
.whereIn('alerts_tags.alert_id', alertIds)
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
knex('alerts_entities')
.select('alerts_entities.alert_id', 'entities.id', 'entities.type', 'entities.name', 'entities.slug')
.whereIn('alerts_entities.alert_id', alertIds)
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
knex('alerts_scenes')
.whereIn('alerts_scenes.alert_id', alertIds),
knex('alerts_matches')
.whereIn('alerts_matches.alert_id', alertIds),
]);
const usersByUserId = Object.fromEntries(users.map((user) => [user.id, user]));
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
const alertActorsByAlertId = groupByKey(alertsActors, 'alert_id');
const alertTagsByAlertId = groupByKey(alertsTags, 'alert_id');
const alertEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id');
const alertMatchesByAlertId = groupByKey(alertsMatches, 'alert_id');
const alertSceneIdsByAlertId = groupByKey(alertsScenes, 'alert_id', 'scene_id');
const triggers = markedNotifications
.map((notification) => ({
notificationId: notification.id,
scene: scenesBySceneId[notification.scene_id] || null,
posterUrl: getImagePath(scenesBySceneId[notification.scene_id]?.poster),
user: usersByUserId[notification.user_id] || null,
alertId: notification.alert_id,
alertTags: alertTagsByAlertId[notification.alert_id] || [],
alertActors: alertActorsByAlertId[notification.alert_id] || [],
alertEntities: alertEntitiesByAlertId[notification.alert_id] || [],
alertMatches: alertMatchesByAlertId[notification.alert_id] || [],
alertSceneIds: alertSceneIdsByAlertId[notification.alert_id] || [],
}))
.filter((notification) => !!notification.user
&& !!notification.scene
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
const emailItems = Object.values(usersByUserId).map((user) => {
const userTriggers = Object.values(Object.fromEntries(triggers
.filter((trigger) => trigger.user.id === user.id)
.map((trigger) => [trigger.scene.id, trigger])))
.toSorted((triggerA, triggerB) => triggerA.scene.effectiveDate - triggerB.scene.effectiveDate);
if (userTriggers.length === 0) {
return null;
}
return {
template: 'alert',
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
props: {
user,
triggers: userTriggers,
},
};
}).filter(Boolean);
if (emailItems.length === 0) {
logger.info(`No alert e-mails sent`);
return;
}
await sendEmails(emailItems);
}
export async function notify(sceneIds, options = {}) {
const scenes = await fetchScenesById(sceneIds);
const [
rawAlerts,
alertsActors,
alertsTags,
alertsEntities,
alertsMatches,
alertsStashes,
] = await Promise.all([
knex('alerts')
.select('alerts.*', 'users.username', 'users.email as user_email', 'users.settings as user_settings')
.leftJoin('users', 'users.id', 'alerts.user_id'),
knex('alerts_actors').select('alert_id', 'actor_id'),
knex('alerts_tags').select('alert_id', 'tag_id'),
knex('alerts_entities').select('alert_id', 'entity_id'),
knex('alerts_matches'),
knex('alerts_stashes'),
]);
const alertActorIdsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
const alertTagIdsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
const alertEntityIdsByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
if (!acc[alertMatch.alert_id]) {
acc[alertMatch.alert_id] = [];
}
acc[alertMatch.alert_id].push({
property: alertMatch.property,
expression: new RegExp(alertMatch.expression, 'iu'),
});
return acc;
}, {});
const alerts = rawAlerts
.map((alert) => ({
id: alert.id,
userId: alert.user_id,
username: alert.username,
userEmail: alert.user_email,
userSettings: alert.user_settings,
notify: alert.notify,
email: alert.email,
all: alert.all,
allActors: alert.all_actors,
allEntities: alert.all_entities,
allTags: alert.all_tags,
allMatches: alert.all_matches,
actorIds: alertActorIdsByAlertId[alert.id] || [],
tagIds: alertTagIdsByAlertId[alert.id] || [],
entityIds: alertEntityIdsByAlertId[alert.id] || [],
matches: alertsMatchesByAlertId[alert.id] || [],
stashIds: alertsStashesByAlertId[alert.id] || [],
}))
.filter((alert) => options.spoofUserIds ? options.spoofUserIds.includes(alert.userId) : true);
const curatedScenes = scenes
.filter((scene) => scene.isNew || options.spoofNew)
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
.map((scene) => ({
id: scene.id,
title: scene.title,
description: scene.description,
actorIds: scene.actors.map((actor) => actor.id),
tagIds: scene.tags.map((tag) => tag.id),
entityId: scene.channel?.id || scene.network?.id || null,
parentEntityId: scene.network?.id,
}));
const triggers = alerts.flatMap((alert) => {
const alertScenes = curatedScenes.filter((scene) => {
if (alert.all) {
if (alert.actorIds.length === 0
&& alert.tagIds.length === 0
&& alert.entityIds.length === 0
&& alert.matches.length === 0) {
// we must match on *something*, this is likely a release date alert handled separately
return false;
}
if (alert.actorIds.length > 0 && !alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
return false;
}
if (alert.tagIds.length > 0 && !alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
return false;
}
// multiple entities can only be matched in OR mode
if (alert.entityIds.length > 0 && !alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return false;
}
if (alert.matches.length > 0 && !alert.matches[alert.allMatches ? 'every' : 'some']((match) => match.expression.test(scene[match.property]))) {
return false;
}
return true;
}
if (alert.matches.length > 0 && alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
return true;
}
if (alert.tagIds.length > 0 && alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
return true;
}
// multiple entities can only be matched in OR mode
if (alert.entityIds.length > 0 && alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return true;
}
if (alert.matches.length > 0 && alert.matches[alert.allMatches ? 'every' : 'some']((match) => match.expression.test(scene[match.property]))) {
return true;
}
return false;
});
return alertScenes.map((scene) => ({
sceneId: scene.id,
alert,
}));
});
// e-mails are stored as notifications to facilitate the e-mail frequency buffer
const notifications = Object.values(triggers // prevent multiple notifications for the same scene
.map((trigger) => ({
...trigger,
notify: trigger.alert.notify && trigger.alert.userSettings?.alertNotificationsEnabled !== false,
email: trigger.alert.email && trigger.alert.userEmail && trigger.alert.userSettings?.alertEmailsEnabled !== false,
}))
.filter((trigger) => trigger.notify || trigger.email)
.reduce((acc, trigger) => {
const key = `${trigger.alert.userId}:${trigger.sceneId}`;
acc[key] = {
...trigger,
notify: acc[key]?.notify || trigger.notify,
email: acc[key]?.email || trigger.email,
};
return acc;
}, {}))
.map((trigger) => ({
user_id: trigger.alert.userId,
alert_id: trigger.alert.id,
scene_id: trigger.sceneId,
// we assign the alert outputs to this specific notification, instead of joining them from the alerts table
// this ensures changing the alert outputs later doesn't trigger a barrage of e-mails from old notifications
notify: trigger.notify,
email: trigger.email,
}));
if (notifications.length === 0) {
logger.verbose(`No alert notifications triggered`);
}
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
stashId,
sceneId: trigger.sceneId,
userId: trigger.alert.userId,
}))).map((stash) => [`${stash.stashId}:${stash.sceneId}`, stash])));
const stashEntries = uniqueStashes.map((stash) => ({
scene_id: stash.sceneId,
stash_id: stash.stashId,
}));
const [stashed] = await Promise.all([
stashEntries.length > 0
? knex('stashes_scenes')
.insert(stashEntries)
.onConflict(['stash_id', 'scene_id'])
.ignore()
.returning('id')
: [],
notifications.length > 0
? knex('notifications')
.insert(notifications)
: [],
]);
if (stashed.length > 0) {
// we need created_at from the databased, but user_id is not returned. it's easier to query it than to try and merge it with the input data
const stashedEntries = await knex('stashes_scenes')
.select('stashes_scenes.*', 'stashes.user_id')
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
.whereIn('stashes_scenes.id', stashed.map((stash) => stash.id));
const docs = stashedEntries.map((stash) => ({
replace: {
table: 'scenes_stashed',
id: stash.id,
doc: {
scene_id: stash.scene_id,
user_id: stash.user_id,
stash_id: stash.stash_id,
created_at: Math.round(stash.created_at.getTime() / 1000),
},
},
}));
if (docs.length > 0) {
await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
}
}
await sendEmailAlerts();
return triggers;
}
async function alertSceneReleases() {
const alerts = await knex('alerts_scenes')
.select(
'alerts.id',
'alerts.user_id',
'alerts.*',
'releases.id as scene_id',
'releases.title as scene_title',
'releases.slug as scene_slug',
'releases.date as scene_date',
)
.leftJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id')
.leftJoin('releases', 'releases.id', 'alerts_scenes.scene_id')
.innerJoin('alerts', 'alerts.id', 'alerts_scenes.alert_id')
.innerJoin('releases', 'releases.id', 'alerts_scenes.scene_id')
.where('alerts.is_expired', false)
.where('releases.date', '<=', knex.fn.now());
@@ -420,6 +802,8 @@ async function alertSceneReleases() {
user_id: alert.user_id,
scene_id: alert.scene_id,
alert_id: alert.id,
notify: alert.notify,
email: alert.email,
})));
await trx('alerts')
@@ -427,7 +811,13 @@ async function alertSceneReleases() {
.update('is_expired', true);
await trx.commit();
await knex.schema.refreshMaterializedView('alerts_users_scenes');
await Promise.all([
knex.schema.refreshMaterializedView('alerts_users_actors'),
knex.schema.refreshMaterializedView('alerts_users_tags'),
knex.schema.refreshMaterializedView('alerts_users_entities'),
knex.schema.refreshMaterializedView('alerts_users_scenes'),
]);
}
catch (error) {
await trx.rollback();
@@ -457,7 +847,12 @@ async function purgeExpiredAlerts() {
)
.delete();
await knex.schema.refreshMaterializedView('alerts_users_scenes');
await Promise.all([
knex.schema.refreshMaterializedView('alerts_users_actors'),
knex.schema.refreshMaterializedView('alerts_users_tags'),
knex.schema.refreshMaterializedView('alerts_users_entities'),
knex.schema.refreshMaterializedView('alerts_users_scenes'),
]);
logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
}
@@ -470,6 +865,9 @@ export function initAlertCron() {
alertSceneReleases(),
purgeExpiredAlerts(),
]);
// allow release alerts to set e-mail notifications
await sendEmailAlerts();
},
start: config.alerts.enabled,
runOnInit: true,
+2
View File
@@ -1,5 +1,6 @@
import { initAlertCron } from './alerts.js';
import { initCaches } from './cache.js';
import { initStashViewsCron } from './stashes.js';
import { initSyncCron } from './sync.js';
import initServer from './web/server.js';
@@ -9,6 +10,7 @@ async function init() {
initServer();
initSyncCron();
initAlertCron();
initStashViewsCron();
}
init();
+483 -25
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import util from 'node:util';
@@ -5,9 +6,10 @@ import { shapes } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core';
import { faker } from '@faker-js/faker';
import config from 'config';
import { verify } from 'hcaptcha';
import { verify as verifyCaptcha } from 'hcaptcha';
import slugify from '../utils/slugify.js';
import { sendEmails } from './email.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger, { initAccessLogger } from './logger.js';
@@ -44,19 +46,71 @@ async function generateAvatar(user) {
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
}
async function checkLoginCooldown(identifier, attemptThreshold) {
if (!identifier) {
return false;
}
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
if (attempts >= attemptThreshold) {
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
}
}
async function setLoginCooldown(identifier, attemptCooldown) {
if (!identifier) {
return false;
}
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
await redis.incr(cooldownKey);
await redis.expire(cooldownKey, attemptCooldown * 60);
}
export async function login(credentials, userIp) {
if (!config.auth.login) {
throw new HttpError('Logins are currently disabled', 405);
throw new HttpError('Logins are currently disabled', 503);
}
if (!credentials.username) {
throw new HttpError('Please provide a username or e-mail', 400);
}
if (!credentials.password) {
throw new HttpError('Please provide a password', 400);
}
await Promise.all([
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
]);
const { user } = await fetchUser(credentials.username.trim(), {
email: true,
raw: true,
}).catch(() => {
}).catch(async () => {
await Promise.all([
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
setLoginCooldown(userIp, config.auth.loginIpCooldown),
]);
throw new HttpError('Username or password incorrect', 401);
});
await verifyPassword(credentials.password, user.password);
try {
await verifyPassword(credentials.password, user.password);
}
catch (error) {
await Promise.all([
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
setLoginCooldown(userIp, config.auth.loginIpCooldown),
]);
throw error;
}
await knex('users')
.update('last_login', 'NOW()')
@@ -73,12 +127,281 @@ export async function login(credentials, userIp) {
}
// fetched the raw user for password verification, don't return directly to user
return curateUser(user);
return curateUser(user, user);
}
async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`;
return {
storedPassword,
hashedPassword,
salt,
};
}
async function tokenCooldown(identifier) {
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
if (await redis.exists(cooldownKey)) {
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
}
await redis.set(cooldownKey, JSON.stringify(true));
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
}
export async function sendEmailVerification(user, customEmail, trigger) {
if (!config.email.enabled) {
throw new HttpError('E-mail verification is disabled', 503);
}
if (!user) {
throw new HttpError('No user provided', 401);
}
const email = customEmail || user.email;
if (!email) {
throw new HttpError('No e-mail address provided', 400);
}
await tokenCooldown(email);
const tokenKey = `traxxx:token_email:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
const tokenData = {
email,
token,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
userId: user.id,
token,
}).toString()}`;
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
await sendEmails([{
template: 'verify',
to: email,
subject: 'Please verify your traxxx e-mail address',
props: {
user,
verifyUrl,
trigger,
},
}]);
}
export async function verifyEmail(userId, emailToken) {
if (!userId) {
throw new HttpError('User ID not provided', 400);
}
if (!emailToken) {
throw new HttpError('Token not provided', 400);
}
const tokenKey = `traxxx:token_email:${userId}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid verification link', 401);
}
const { token, email } = JSON.parse(tokenEntry);
if (!token
|| !email
|| emailToken.length !== token.length
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
throw new HttpError('Invalid verification link', 401);
}
const updated = await knex('users')
.where('id', userId)
.update({
email,
email_verified: true,
});
if (updated === 0) {
throw new HttpError('E-mail verification failed because the user could not be found', 404);
}
}
export async function sendPasswordReset(credential) {
if (!config.email.enabled) {
throw new HttpError('Password reset is disabled', 503);
}
if (!credential) {
throw new HttpError('No credential provided', 400);
}
await tokenCooldown(credential);
const user = await fetchUser(credential, {
email: true,
includePrivate: true,
throwError: false,
});
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
if (!user) {
logger.info(`Password reset requested for unknown user '${credential}'`);
return;
}
if (!user.email) {
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
return;
}
const tokenKey = `traxxx:token_password:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
const tokenData = {
token,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
userId: user.id,
token,
}).toString()}`;
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
await sendEmails([{
template: 'recovery',
to: user.email,
subject: 'Reset your traxxx password',
props: {
user,
verifyUrl,
},
}]);
}
export async function resetPassword(userId, emailToken, newPassword) {
if (!userId) {
throw new HttpError('User ID not provided', 400);
}
if (!emailToken) {
throw new HttpError('Token not provided', 400);
}
if (!newPassword) {
throw new HttpError('New password not provided', 400);
}
const tokenKey = `traxxx:token_password:${userId}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid recovery link', 401);
}
const { token } = JSON.parse(tokenEntry);
if (!token
|| emailToken.length !== token.length
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
throw new HttpError('Invalid recovery link', 401);
}
const { storedPassword } = await hashPassword(newPassword);
const updated = await knex('users')
.where('id', userId)
.update('password', storedPassword);
if (updated === 0) {
throw new HttpError('Password reset failed because the user could not be found', 404);
}
}
export async function sendSignup(email) {
if (!config.auth.signup || !config.email.enabled) {
throw new HttpError('Sign-ups are disabled', 503);
}
if (!email) {
throw new HttpError('No e-mail address provided', 400);
}
await tokenCooldown(email);
const user = await fetchUser(email, {
email: true,
includePrivate: true,
throwError: false,
});
if (user) {
await sendEmails([{
template: 'unavailable',
to: email,
subject: 'Someone tried to use your e-mail address for traxxx',
props: {
user,
},
}]);
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
return;
}
const token = crypto.randomBytes(32).toString('hex');
const tokenKey = `traxxx:token_signup:${token}`;
const tokenData = {
email,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
token,
}).toString()}`;
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
await sendEmails([{
template: 'signup',
to: email,
subject: 'Sign up for traxxx',
props: {
verifyUrl,
},
}]);
}
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 405);
throw new HttpError('Sign-ups are currently disabled', 503);
}
if (!credentials.token) {
throw new HttpError('Token required', 400);
}
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
const curatedUsername = credentials.username.trim();
@@ -99,41 +422,44 @@ export async function signup(credentials, userIp) {
throw new HttpError('Username contains invalid characters', 400);
}
if (!credentials.email) {
throw new HttpError('E-mail required', 400);
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.first();
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
if (existingUser) {
throw new HttpError('Username already in use', 409);
}
if (config.auth.captcha.enabled) {
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
if (!captchaVerification.success) {
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
throw new HttpError('Invalid CAPTCHA', 400);
}
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
.first();
const tokenKey = `traxxx:token_signup:${credentials.token}`;
const tokenEntry = await redis.getDel(tokenKey);
if (existingUser) {
throw new HttpError('Username or e-mail already in use', 409);
if (!tokenEntry) {
throw new HttpError('Invalid sign-up link', 401);
}
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`;
const { email } = JSON.parse(tokenEntry);
if (!email) {
throw new HttpError('Invalid sign-up link', 401);
}
const { storedPassword } = await hashPassword(credentials.password);
const [{ id: userId }] = await knex('users')
.insert({
username: curatedUsername,
email: credentials.email,
email,
password: storedPassword,
email_verified: true,
})
.returning('id');
@@ -145,14 +471,138 @@ export async function signup(credentials, userIp) {
primary: true,
});
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
await generateAvatar({
id: userId,
username: curatedUsername,
});
return fetchUser(userId);
const user = await fetchUser(userId, {
includePrivate: true,
});
// no longer needed with token-first signup flow
// await sendEmailVerification(user, null, 'signup');
return user;
}
async function updatePassword(password, reqUser, reqSession) {
const now = new Date();
const { storedPassword } = await hashPassword(password);
await knex('users')
.where('id', reqUser.id)
.update('password', storedPassword);
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
await sendEmails([{
template: 'password',
to: reqUser.email,
subject: 'Your traxxx password has been changed',
props: {
user: reqUser,
ip: reqUser.ip,
country: country?.name,
timestamp: now,
},
}]);
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
}
async function updateEmail(email, reqUser) {
const user = await fetchUser(email, {
email: true,
throwError: false,
});
if (user) {
await sendEmails([{
template: 'unavailable',
to: email,
subject: 'Someone tried to use your e-mail address for traxxx',
props: {
user, // not reqUser, we don't want to leak details about the requester
},
}]);
return;
}
await sendEmailVerification(reqUser, email, 'change');
}
export async function updateUser({ credentials, password }, reqUser, reqSession) {
if (!reqUser) {
throw new HttpError('You are not logged in', 401);
}
if (!credentials?.email && !credentials.password) {
throw new HttpError('No changeable credentials provided', 400);
}
if (credentials.email && !config.email.enabled) {
throw new HttpError('E-mail is currently disabled', 503);
}
if (credentials.password && credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
if (!password) {
throw new HttpError('No current password provided', 401);
}
const { user } = await fetchUser(reqUser.id, {
email: true,
raw: true,
}).catch(() => {
throw new HttpError('Username or password incorrect', 401);
});
await verifyPassword(password, user.password);
if (credentials.password) {
await updatePassword(credentials.password, reqUser, reqSession);
}
if (credentials.email) {
await updateEmail(credentials.email, reqUser);
}
return curateUser(user, reqUser);
}
const validSettingKeys = [
'alertNotificationsEnabled',
'alertEmailsEnabled',
'alertEmailsFrequency',
];
export async function updateSettings(settings, reqUser) {
if (!reqUser) {
throw new HttpError('You need to be authenticated to change your settings', 401);
}
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
if (invalidKeys.length > 0) {
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
}
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
throw new HttpError('Invalid email frequency setting', 400);
}
const [{ settings: updatedSettings }] = await knex('users')
.where('id', reqUser.id)
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
.returning('settings');
return updatedSettings;
}
function curateKey(key) {
@@ -178,6 +628,14 @@ export async function verifyKey(userId, key, req) {
throw new HttpError('The API credentials are not provided.', 401);
}
if (typeof userId !== 'number') {
throw new HttpError('User ID is not a number', 400);
}
if (typeof key !== 'string') {
throw new HttpError('Key is not a string', 400);
}
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
const storedKey = await knex('users_keys')
+15
View File
@@ -0,0 +1,15 @@
import { readdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { config as configureEmailTemplates } from '@vue-email/compiler';
export const emailsDir = path.join(process.cwd(), 'components/emails');
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
export function listEmailTemplates() {
return readdirSync(emailsDir)
.filter((file) => file.endsWith('.vue'))
.map((file) => file.replace(/\.vue$/, ''));
}
+84
View File
@@ -0,0 +1,84 @@
import config from 'config';
import { Resend } from 'resend';
import { emailTemplates } from './email-templates.js';
import initLogger from './logger.js';
const logger = initLogger();
const resend = (() => {
if (config.email.enabled) {
try {
return new Resend(config.email.apiKey);
}
catch (error) {
logger.error(`Failed to initialize Resend: ${error.message}`);
}
}
return null;
})();
export async function renderEmail(template, { to, subject, props }) {
const { html, text } = await emailTemplates.render(`${template}.vue`, {
props: {
...props,
address: config.web.address,
},
});
return {
from: config.email.from,
to,
subject,
html,
text,
};
}
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
export async function sendEmails(items) {
if (!config.email.enabled) {
return null;
}
if (!resend) {
logger.warn('E-mails are enabled, but Resend is not initialized');
return null;
}
const rendered = await Promise.all(items.map(async (item) => {
try {
return await renderEmail(item.template, item);
}
catch (error) {
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`);
return null;
}
}));
const sentItems = items.filter((item, index) => rendered[index]);
const emails = rendered.filter(Boolean);
if (emails.length === 0) {
return null;
}
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
if (error) {
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
return null;
}
data.errors?.forEach(({ index, message }) => {
const item = sentItems[index];
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`);
});
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
return data;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { pageContext } from '../renderer/usePageContext.js';
function getBasePath(media, options) {
if (pageContext.restriction) {
if (pageContext.geo.restriction) {
return pageContext.env.media.assetPath;
}
@@ -17,11 +17,11 @@ function getBasePath(media, options) {
}
function getFilename(media, type, options) {
if (pageContext.restriction && type && !options?.original) {
if (pageContext.geo.restriction && type && !options?.original) {
return media.sfw?.[type];
}
if (pageContext.restriction) {
if (pageContext.geo.restriction) {
return media.sfw?.path;
}
+5 -3
View File
@@ -127,7 +127,7 @@ function curateScene(rawScene, assets, reqUser, context) {
title: serie.title,
poster: context.restriction
? null
: (serie.serie_poster, { type: 'poster' }),
: curateMedia(serie.serie_poster, { type: 'poster' }),
})),
poster: curateMedia(assets.poster, { type: 'poster' }),
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
@@ -214,9 +214,9 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
) affiliates ON TRUE
`),
studios: knex('releases')
.select('studios.*')
.whereIn('releases.id', sceneIds)
.leftJoin('entities as studios', 'studios.id', 'releases.studio_id'),
// .leftJoin('entities as networks', 'networks.id', 'studios.parent_id'),
.innerJoin('entities as studios', 'studios.id', 'releases.studio_id'),
actors: knex('releases_actors')
.select(
'actors.*',
@@ -766,6 +766,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
'scenes_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
knexOwner.raw('count(*) over() as total'),
)
.leftJoin('users', 'users.id', 'scenes_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
@@ -824,6 +825,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
total: Number(revisions[0]?.total) || 0,
actors,
tags,
movies,
+29 -15
View File
@@ -25,10 +25,12 @@ export function curateStash(stash, assets = {}) {
slug: stash.slug,
isPrimary: stash.primary,
isPublic: stash.public,
isArchived: stash.archived,
createdAt: stash.created_at,
stashedScenes: stash.stashed_scenes ?? null,
stashedMovies: stash.stashed_movies ?? null,
stashedActors: stash.stashed_actors ?? null,
lastStashAt: stash.last_stash_at,
user: assets.user
? {
id: assets.user.id,
@@ -64,7 +66,8 @@ function curateStashEntry(stash, user) {
user_id: user?.id || undefined,
name: stash.name || undefined,
slug: slugify(stash.name) || undefined,
public: stash.isPublic ?? false,
public: stash.isPublic ?? undefined,
archived: stash.isArchived ?? undefined,
comment: stash.comment,
meta: stash.meta,
};
@@ -126,6 +129,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
}
export async function fetchUserStashes(usernameOrId, reqUser) {
if (!usernameOrId) {
throw new HttpError(`No username or user ID provided`, 400);
}
const userId = typeof usernameOrId === 'number'
? usernameOrId
: await knex('users')
@@ -134,7 +141,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
.then((user) => user?.id);
if (!userId) {
throw new HttpError(`Could not find user '${usernameOrId}'`);
throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
}
const stashes = await knex('stashes')
@@ -146,7 +153,12 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
if (userId !== reqUser?.id) {
builder.where('public', true);
}
});
})
.orderBy([
{ column: 'stashes.primary', order: 'desc' },
{ column: 'stashes_meta.last_stash_at', order: 'desc' },
{ column: 'stashes.name', order: 'asc' },
]);
return stashes.map((stash) => curateStash(stash, { user: reqUser }));
}
@@ -602,16 +614,18 @@ export async function unstashMovie(movieId, stashId, sessionUser) {
return curateStashed(unstashed);
}
CronJob.from({
cronTime: config.stashes.viewRefreshCrontab,
async onTick() {
logger.verbose('Updating stash views');
export function initStashViewsCron() {
CronJob.from({
cronTime: config.stashes.viewRefreshCrontab,
async onTick() {
logger.verbose('Updating stash views');
await refreshView('scenes');
await refreshView('actors');
await refreshView('movies');
await refreshView('stashes');
},
start: true,
runOnInit: true,
});
await refreshView('scenes');
await refreshView('actors');
await refreshView('movies');
await refreshView('stashes');
},
start: true,
runOnInit: true,
});
}

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