Compare commits

..
97 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
DebaucheryLibrarian e23bc927e2 0.52.4 2026-07-13 21:40:17 +02:00
DebaucheryLibrarian 29a17b2e94 Split footer to prevent overflows. 2026-07-13 21:40:12 +02:00
DebaucheryLibrarian 18693faa5f 0.52.3 2026-07-13 21:25:47 +02:00
DebaucheryLibrarian 378f81efe4 Moed pages/scene to pages/scenes/@sceneId for consistency. 2026-07-13 21:25:45 +02:00
DebaucheryLibrarian 05e3cf811a 0.52.2 2026-07-13 20:45:04 +02:00
DebaucheryLibrarian 5815ac2bf4 Improved scene tile actors height to prevent second line leak. Improved alert action tooltip. Changed popular tag from MFM to threesome due lack of specifically tagged scenes. 2026-07-13 20:45:01 +02:00
DebaucheryLibrarian cafa6476ca 0.52.1 2026-07-13 19:53:15 +02:00
DebaucheryLibrarian 3007150aeb Fixed parameter binding in alert purge, added log. 2026-07-13 19:53:13 +02:00
DebaucheryLibrarian 2199bb3f39 Fixed release date alert query still in debug mode. 2026-07-13 17:31:34 +02:00
128 changed files with 11137 additions and 739 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;
+9
View File
@@ -65,3 +65,12 @@
.capitalize {
text-transform: capitalize;
}
.hidden {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
}
+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"
+5 -1
View File
@@ -114,7 +114,11 @@ const aliasTooltipId = useId();
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="{ content: `Credited as '${actor.alias.name}'`, ariaId: aliasTooltipId }"
v-tooltip="{
content: `Credited as '${actor.alias.name}'`,
triggers: ['hover', 'click'],
ariaId: aliasTooltipId,
}"
icon="at-sign"
class="alias"
/>
+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>
+41 -24
View File
@@ -7,44 +7,61 @@ const { env } = pageContext;
<template>
<footer class="footer">
<span class="footer-segment">© traxxx</span>
<div class="footer-row">
<a
v-if="env.links.matrix"
:href="env.links.matrix"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink matrix"
><Icon icon="matrix-full" /></a>
<a
v-if="env.links.matrix"
:href="env.links.matrix"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink matrix"
><Icon icon="matrix-full" /></a>
<a
v-if="env.links.discord"
:href="env.links.discord"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink discord"
><Icon icon="discord-full" /></a>
<a
v-if="env.links.discord"
:href="env.links.discord"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink discord"
><Icon icon="discord-full" /></a>
<a
v-if="env.links.content"
:href="env.links.content"
target="_blank"
class="footer-segment footer-link"
>Content Removal (DMCA)</a>
</div>
<a
v-if="env.links.content"
:href="env.links.content"
target="_blank"
class="footer-segment footer-link"
>Content Removal / DMCA</a>
<div class="footer-row">
<span class="footer-segment">© traxxx v{{ env.version }}</span>
</div>
</footer>
</template>
<style scoped>
.footer {
display: flex;
justify-content: center;
align-items: stretch;
flex-direction: column;
background: var(--background-base-20);
box-shadow: inset 0 0 3px var(--shadow-weak-30);
font-size: .8rem;
font-size: .75rem;
font-weight: bold;
}
.footer-row {
display: flex;
justify-content: center;
align-items: stretch;
&:first-child .footer-segment {
padding-top: .6rem;
}
&:not(:first-child) {
border-top: solid 1px var(--glass-weak-30);
}
}
.footer-segment {
display: inline-flex;
align-items: center;
+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);
}
+2 -2
View File
@@ -243,8 +243,8 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
}
.actors {
height: 1.15rem;
margin-bottom: .15rem;
height: 1lh;
margin-bottom: .3rem;
overflow: hidden;
white-space: pre-wrap;
}
+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>
+13 -6
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;
@@ -215,26 +218,24 @@ async function reloadStashes(newStash) {
>
<Icon
v-if="itemAlerted"
v-tooltip="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
icon="bell2"
:title="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
class="alert active noselect"
@click="removeAlert"
@contextmenu.prevent="showAlertDialog = true"
/>
<Icon
v-else-if="itemAlerts.multi.length > 0"
v-tooltip="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
icon="bell-plus"
:title="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
class="alert partial noselect"
@click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
/>
<Icon
v-else
v-tooltip="domain === 'scenes' ? `Set alert for the release of '${item.title}'` : `Set alert for '${item.title || item.name}' releases`"
icon="bell-plus"
:title="`Set alert for '${item.title || item.name}' releases`"
class="alert noselect"
@click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
@@ -245,6 +246,8 @@ async function reloadStashes(newStash) {
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false"
>
<Icon
@@ -258,6 +261,7 @@ async function reloadStashes(newStash) {
<StashMenu
:stashes="stashes"
:item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true"
@@ -271,6 +275,8 @@ async function reloadStashes(newStash) {
:triggers="[]"
:shown="showStashes"
:disabled="showSecondary"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false"
>
<template v-if="pageStash?.user.id === user.id">
@@ -313,6 +319,7 @@ async function reloadStashes(newStash) {
<StashMenu
:stashes="stashes"
:item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true"
+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.0",
"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 },
@@ -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;
}
+6 -6
View File
@@ -6,8 +6,7 @@ const tagSlugs = {
'anal',
'lesbian',
// 'interracial',
'mff',
'mfm',
'threesome',
// 'teen',
'milf',
'blowjob',
@@ -48,6 +47,7 @@ const tagSlugs = {
'atm',
],
group: [
'threesome',
'mfm',
'mff',
'orgy',
@@ -73,7 +73,7 @@ const tagSlugs = {
'nurse',
'maid',
],
extreme: [
penetration: [
'dp',
'airtight',
'dap',
@@ -118,7 +118,7 @@ const tagSlugs = {
};
async function searchTags(pageContext) {
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.restriction });
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.geo.restriction });
return {
pageContext: {
@@ -137,13 +137,13 @@ export async function onBeforeRender(pageContext) {
return searchTags(pageContext);
}
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.restriction });
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.geo.restriction });
const filteredTags = tags.filter((tag) => !pageContext.tagFilter.includes(tag.name) && !pageContext.tagFilter.includes(tag.slug));
const tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [
censor(category, pageContext.restriction),
censor(category, pageContext.geo.restriction),
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
]));
+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);

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