Compare commits

...

158 Commits

Author SHA1 Message Date
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
216e7f59ef Added e-mail alert settings, saveable but not integrated yet. 2026-07-18 06:24:07 +02:00
479b044798 0.54.0 2026-07-18 03:21:02 +02:00
7c6bde690f Replaced profile pill bar with portal page. 2026-07-18 03:20:50 +02:00
981205efaa Enabled e-mail action in alert dialog and alerts overview action indicators. 2026-07-18 01:02:03 +02:00
b6408f3244 Implemented e-mail notification trigger. 2026-07-18 00:01:18 +02:00
fab895b85e 0.53.0 2026-07-16 02:21:58 +02:00
9d869c4a37 Integrated notify from legacy triggered by sync. 2026-07-16 02:21:55 +02:00
583b6984d8 0.52.6 2026-07-13 22:05:08 +02:00
ad912e8f03 Integrated scene alerts in alert filters. 2026-07-13 22:05:06 +02:00
fe1170b22e 0.52.5 2026-07-13 21:52:24 +02:00
b69e0a104c Fixed empty studio returned for scenes without studio. 2026-07-13 21:52:17 +02:00
e23bc927e2 0.52.4 2026-07-13 21:40:17 +02:00
29a17b2e94 Split footer to prevent overflows. 2026-07-13 21:40:12 +02:00
18693faa5f 0.52.3 2026-07-13 21:25:47 +02:00
378f81efe4 Moed pages/scene to pages/scenes/@sceneId for consistency. 2026-07-13 21:25:45 +02:00
05e3cf811a 0.52.2 2026-07-13 20:45:04 +02:00
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
cafa6476ca 0.52.1 2026-07-13 19:53:15 +02:00
3007150aeb Fixed parameter binding in alert purge, added log. 2026-07-13 19:53:13 +02:00
2199bb3f39 Fixed release date alert query still in debug mode. 2026-07-13 17:31:34 +02:00
7f52da5f21 0.52.0 2026-07-13 17:29:28 +02:00
4ae88132af 0.51.23 2026-07-13 17:27:52 +02:00
327885dd17 Purging expired notifications and alerts. Fixed manticore stash sync. 2026-07-13 17:27:50 +02:00
cf758c2494 0.51.22 2026-07-13 03:58:32 +02:00
6e82d2af37 Added actor merge comment. 2026-07-13 03:58:29 +02:00
f42a48dbcc 0.51.21 2026-07-13 03:40:12 +02:00
e95145ff1a Adding revisions for actor merges. 2026-07-13 03:40:08 +02:00
4031f35c73 Added actor render timings. 2026-07-13 01:06:38 +02:00
522dc33225 0.51.20 2026-07-13 00:54:54 +02:00
02b7dcb292 Fixed actor scene length check in redirect, removed stray console log. 2026-07-13 00:54:52 +02:00
5dbcf81d84 0.51.19 2026-07-13 00:49:17 +02:00
ff2d4eefb5 Renamed deprecated requireGlobal actor filter property. Fixed actor search wildcard expansion returning no manticore results for name components under 3 characters. 2026-07-13 00:49:15 +02:00
d745b10d24 Upgraded dependencies, bumped to Node 24. 2026-07-12 05:27:14 +02:00
2d4786a4fd 0.51.18 2026-07-08 03:47:08 +02:00
2d0983f4d1 Optionally showing aliases in actors admin. 2026-07-08 03:47:06 +02:00
dd344ecc3f 0.51.17 2026-07-08 03:25:33 +02:00
93c31888b2 Fixed merge ignoring assigned scene tags. 2026-07-08 03:25:31 +02:00
6779caccef 0.51.16 2026-07-08 02:49:11 +02:00
bf69b5acd1 Fixed actors URL not updating on pagination. 2026-07-08 02:49:09 +02:00
0424f27264 0.51.15 2026-07-07 03:10:56 +02:00
5ae102071b Added some missing bio data to actor GraphQL endpoint. Changed enhanced boobs to fake tits on tag overview. 2026-07-07 03:10:54 +02:00
8b716aeb97 0.51.14 2026-07-07 01:05:21 +02:00
1f5bbb6ce5 Redirecting aliased actors and tags. 2026-07-07 01:05:18 +02:00
a94c24581f 0.51.13 2026-07-06 22:08:57 +02:00
5383f9bfc4 Added aliases and stashes to GraphQL actor. 2026-07-06 22:08:56 +02:00
beddc9c7ef 0.51.12 2026-07-06 17:44:38 +02:00
0e81334c5f Fixed actor tags with just one actor showing 'and'. 2026-07-06 17:44:35 +02:00
dbd269c43b 0.51.11 2026-07-06 14:53:04 +02:00
5c3d7d77fc Fixed menu breaking when logged out. 2026-07-06 14:53:02 +02:00
0c48b8f7bf 0.51.10 2026-07-06 01:48:13 +02:00
8c09fb4cda Showing tooltips on filter options. 2026-07-06 01:48:11 +02:00
0a84598fbe 0.51.9 2026-07-06 01:36:43 +02:00
ee1e347405 Fixed search inputs not working in Firefox/Safari. Added Stashes and Alerts to header menu. Indexing compilation tags as assigned tags in Manticore. 2026-07-06 01:36:30 +02:00
6f8d3b1ad2 Hiding merge from actor edit page if not privileged. 2026-07-05 05:20:42 +02:00
a91350bcab 0.51.8 2026-07-05 05:18:41 +02:00
b00425e9c0 Moved actor merge from bio to edit. 2026-07-05 05:18:38 +02:00
c82a3477bd 0.51.7 2026-07-05 04:57:00 +02:00
040dca3829 Added URL as an editable scene field. Fixed plain URL permissions on scene page. 2026-07-05 04:56:57 +02:00
8674c1d361 Tweaked actor tag avatar margins. 2026-07-05 04:37:18 +02:00
6c9cb4201a 0.51.6 2026-07-05 04:33:14 +02:00
7c32a5e824 Improved tag actor avatar size calculation. 2026-07-05 04:33:12 +02:00
6c2f9cb62e Slightly bumped tag actor avatar scale. 2026-07-05 03:57:19 +02:00
3cece877f7 Fixed actor tag star styling. 2026-07-05 03:54:02 +02:00
8dd700f62e 0.51.5 2026-07-05 03:42:45 +02:00
804cdcbf59 Using biometric data for actor tag face positioning if available. 2026-07-05 03:42:40 +02:00
885fe7c9e9 0.51.4 2026-07-03 17:59:47 +02:00
930cc52373 Showing avatars on actor tags instead of sections. Slightly sized down scene page actor tiles, shrinking font size on long names. 2026-07-03 17:59:44 +02:00
4636a213b3 0.51.3 2026-07-03 03:59:16 +02:00
469954f613 Sorting actor tag sections by average tag weight. 2026-07-03 03:59:14 +02:00
5bdcd65d42 0.51.2 2026-07-03 03:18:44 +02:00
cb91cd4cc7 Applying global vs actor tag toggle to scene results. 2026-07-03 03:18:33 +02:00
e04ddaed9b 0.51.1 2026-07-03 02:23:15 +02:00
605da5e46c Allowing actor tag filter on stash page. 2026-07-03 02:23:13 +02:00
360e8ece85 0.51.0 2026-07-03 02:15:08 +02:00
1543bf9d03 Using packed keys instead of actor-tags table. 2026-07-03 02:15:04 +02:00
287932d9d7 0.50.23 2026-07-01 21:12:21 +02:00
7c4de31c12 Mapping all actor aliases to manticore scenes. 2026-07-01 21:11:56 +02:00
497c6150f7 0.50.22 2026-07-01 20:45:59 +02:00
514f51f111 Fixed actor merge failing if multiple source actors have profiles for same entity. 2026-07-01 20:45:57 +02:00
244dc4fff6 0.50.21 2026-06-29 00:37:21 +02:00
4b39f787c9 Including scene actor aliases in manticore. 2026-06-29 00:37:18 +02:00
fb92b9c973 0.50.20 2026-06-28 06:24:21 +02:00
181358db7d Fixed scene actors without alias returning empty actor object instead of null. 2026-06-28 06:24:19 +02:00
3790567d44 0.50.19 2026-06-28 06:18:41 +02:00
b3af993236 Fixed actor edit avatar zoom selecting avatar, showing media date. 2026-06-28 06:18:38 +02:00
7ae2bb7635 0.50.18 2026-06-28 06:05:20 +02:00
a75f0662ad Showing alias on scene actor tile. 2026-06-28 06:05:15 +02:00
ffd68d5037 0.50.17 2026-06-28 05:35:53 +02:00
adf9e2334c Fixed actor merge failing if scene has multiple source actors assigned, merging stashed actors. 2026-06-28 05:35:51 +02:00
4125811017 0.50.16 2026-06-19 02:18:05 +02:00
4e8356b072 Fixed actor merge failing if target actor is already assigned to some scenes. Linked ID in actors admin to actor page. 2026-06-19 02:18:02 +02:00
bad116cdc0 Fixed actor edit negative labia boolean ignored by UI. 2026-06-18 00:33:38 +02:00
9eac6871a4 0.50.15 2026-06-18 00:33:26 +02:00
3b694689f3 Fixed actor edit negative boolean ignored by UI. 2026-06-18 00:33:24 +02:00
1604ddaa78 0.50.14 2026-06-18 00:25:06 +02:00
16181923b6 Fixed actor page breaking if no aliases are found. 2026-06-18 00:25:03 +02:00
62dcaba875 0.50.13 2026-06-18 00:17:23 +02:00
f67c05f034 Showing aliases in bio. 2026-06-18 00:17:21 +02:00
8fd792b85c 0.50.12 2026-06-17 01:54:25 +02:00
2595fcc365 Added new actor dialog to admin panel, removed create mode from actor edit page. 2026-06-17 01:54:23 +02:00
0af5b8d84c 0.50.11 2026-06-17 00:23:05 +02:00
d1e8ff48c5 Verifying abilities for admin panel access instead of relying on user role. 2026-06-17 00:23:03 +02:00
d7964f6fc2 0.50.10 2026-06-17 00:10:28 +02:00
0394093407 Implemented row-level merge in actors admin panel. 2026-06-17 00:10:25 +02:00
adcd6abfc7 0.50.9 2026-06-17 00:05:45 +02:00
721eaa5d07 Added actors admin panel with bulk merge. Fixed merge failing if source and target actor have conflicting network profiles. 2026-06-17 00:05:42 +02:00
994908ef6a 0.50.8 2026-06-10 02:23:39 +02:00
dda76ac7b0 Improved actor merge dialog feedback. 2026-06-10 02:23:36 +02:00
e7eac26e66 0.50.7 2026-06-10 01:43:38 +02:00
15b61ed56f Merging actor avatars. Fixed faulty actor ID search in merge dialog. 2026-06-10 01:43:35 +02:00
f9a51ffdce 0.50.6 2026-06-09 23:53:08 +02:00
a47b9a86dc Fixed alias_for column query breaking actor stashes. 2026-06-09 23:53:00 +02:00
89ca64dd88 0.50.5 2026-06-09 06:18:13 +02:00
e5e1c5f82b Serializing aliased actors as searchable text field in manticore. 2026-06-09 06:18:05 +02:00
ab1e642c36 0.50.4 2026-06-09 04:22:51 +02:00
1cae3ac092 Merge branch 'merge-actors' 2026-06-09 04:22:48 +02:00
afe5876162 Implemented global match actor form edit. 2026-06-09 04:22:41 +02:00
72129f69ee 0.50.3 2026-06-09 03:18:53 +02:00
a2e2704a05 Moved actor merge queries inside try/catch. 2026-06-09 03:18:43 +02:00
65af850d68 Enforcing global target actor in merge. 2026-06-09 01:25:34 +02:00
f7425cdc03 0.50.2 2026-06-09 01:23:32 +02:00
d9f432ef73 Syncing stashes on actor merge, improved merge dialog feedback. 2026-06-09 01:23:23 +02:00
3a86651891 0.50.1 2026-06-08 23:32:44 +02:00
20f304c9cd Processing (Manticore) sync queue. 2026-06-08 23:32:40 +02:00
1f58f989f7 0.50.0 2026-06-08 05:50:55 +02:00
96b1a99e04 Added manticore sync API. 2026-06-08 05:50:29 +02:00
1bc7dd3a43 Integrated Manticore sync, assuming responsibility from traxxx core/legacy. 2026-06-08 05:18:11 +02:00
a048970be6 Merge branch 'main' into merge-actors 2026-05-20 17:53:54 +02:00
e3171e5693 0.49.8 2026-05-20 17:53:40 +02:00
d463b3df5c Added guards for stash operations with missing arguments. 2026-05-20 17:53:38 +02:00
1ae7befa4b Added actor merge dialog UI. 2026-05-20 06:21:56 +02:00
dc80e1e199 Added actor creation page. 2026-05-20 05:27:37 +02:00
35ffc2b0f7 0.49.7 2026-05-06 17:57:36 +02:00
383844dda8 Not showing parent campaigns on independent sites. 2026-05-06 17:57:33 +02:00
77fb6595a2 Added inauthenticated user handling to createSceneRevision. 2026-04-01 00:05:30 +02:00
aa3adbe634 Changed bio location phrasing for deceased actors. 2026-03-30 22:15:40 +02:00
59a700c2f3 0.49.6 2026-03-30 17:00:19 +02:00
18f5a6f476 Fixed age of passing in actor tile. 2026-03-30 17:00:17 +02:00
63a178ca57 0.49.5 2026-03-28 16:29:59 +01:00
0ae949a616 Enabled extreme insertion tag filter by default. 2026-03-28 16:29:57 +01:00
edc9720623 Added RTA logo file to repo. 2026-03-27 05:44:15 +01:00
bbc3fbb0a5 0.49.4 2026-03-27 04:26:19 +01:00
1fc468efac Added RTA Restrict To Adults marker. 2026-03-27 04:26:16 +01:00
143c415797 0.49.3 2026-03-23 17:26:25 +01:00
e79a4d48e1 Removed handle from bio socials to save space. 2026-03-23 17:26:23 +01:00
343325440e 0.49.2 2026-03-22 06:24:07 +01:00
5c018892d3 Fixed revision tag modified highlight for actor association change. 2026-03-22 06:24:05 +01:00
be61293cbe 0.49.1 2026-03-22 06:14:46 +01:00
e493194ce1 Added scene revision tag fix tool. 2026-03-22 06:14:41 +01:00
b61631c33c Fixed scene actor tag revision display. 2026-03-22 05:53:13 +01:00
fa65da75bc Fixed scene tag delta storage format for actor associations. 2026-03-22 05:39:22 +01:00
a4468f18dc 0.49.0 2026-03-22 04:50:51 +01:00
fea28b71ba Restored actor tag filtering with performance fixes. 2026-03-22 04:50:35 +01:00
884ad891f3 0.48.2 2026-03-22 02:09:22 +01:00
058161f798 Patched manticore death queries. 2026-03-22 02:09:20 +01:00
aa68748817 0.48.1 2026-03-20 01:42:02 +01:00
928857596f Fixed fallback watch URL not generated for scenes without URL or network. 2026-03-20 01:42:00 +01:00
234 changed files with 28478 additions and 22440 deletions

View File

@@ -5,7 +5,6 @@
]
],
"plugins": [
"@babel/plugin-transform-optional-chaining",
"@babel/plugin-syntax-import-attributes"
"@babel/plugin-transform-optional-chaining"
],
}

View File

@@ -1,52 +0,0 @@
{
"root": true,
"extends": [
"airbnb-base",
"plugin:import/errors",
"plugin:vue/recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser",
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"default-param-last": 0,
"import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
"import/prefer-default-export": 0,
"no-underscore-dangle": 0,
"import/extensions": [2, "always"],
"indent": ["error", "tab"],
"max-len": 0,
"no-console": 0,
"no-param-reassign": ["error", {
"props": true,
"ignorePropertyModificationsFor": ["state", "acc", "req"]
}],
"vue/multi-word-component-names": 0,
"vue/no-reserved-component-names": 0,
"no-tabs": "off",
"no-unused-vars": ["error", {"argsIgnorePattern": "^_"}],
"prefer-destructuring": 0,
"template-curly-spacing": "off",
"vue/html-indent": ["error", "tab"],
"vue/multiline-html-element-content-newline": 0,
"vue/no-v-html": 0,
"vue/singleline-html-element-content-newline": 0,
"vue/comment-directive": 0,
},
"settings": {
"import/resolver": {
"alias": {
"extensions": [".js"],
"map": [
["#", "."],
["#root", "."]
]
}
}
},
"globals": {
"CONFIG": true
}
}

2
.nvmrc
View File

@@ -1 +1 @@
21.2.0
24.18.0

View File

@@ -19,6 +19,9 @@
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
<!-- RTA restricted to adults label -->
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
<title>traxxx - Consent</title>
<style>
:root {
@@ -156,6 +159,12 @@
text-decoration: underline;
}
.rta {
position: fixed;
bottom: .5rem;
right: .5rem;
}
@media(max-width: 800px) {
.heading {
font-size: 1.25rem;
@@ -219,5 +228,12 @@
</a>
</div>
</div>
<img
src="/img/rta.gif"
alt="RTA Restricted To Adults"
title="RTA Restricted To Adults"
class="rta"
>
</body>
</html>

View File

@@ -25,7 +25,8 @@
.button {
display: inline-flex;
flex-shrink: 0;
align-items: stretch;
/* align-items: stretch; */
align-items: center;
box-sizing: border-box;
padding: .5rem;
border: none;
@@ -42,7 +43,7 @@
fill: var(--glass);
}
&:hover {
&:hover:not(:disabled) {
cursor: pointer;
background: var(--primary);
color: var(--text-light);
@@ -55,6 +56,10 @@
&:focus {
outline: none;
}
&:disabled {
background: var(--glass-weak-30);
}
}
.button-label {
@@ -305,3 +310,23 @@
box-shadow: 0 0 3px var(--shadow-weak-10);
}
}
.search-cancel {
width: 2rem;
height: 2rem;
padding: .5rem;
.icon {
width: 1rem;
height: 1rem;
fill: var(--glass);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--error);
}
}
}

View File

@@ -65,3 +65,12 @@
.capitalize {
text-transform: capitalize;
}
.hidden {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
}

4
assets/img/icons/at-sign.svg Executable file
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.657 2.343c-1.511-1.511-3.52-2.343-5.657-2.343s-4.146 0.832-5.657 2.343c-1.511 1.511-2.343 3.52-2.343 5.657s0.832 4.146 2.343 5.657c1.511 1.511 3.52 2.343 5.657 2.343 1.199 0 2.353-0.259 3.429-0.77 0.499-0.237 0.711-0.834 0.474-1.332s-0.834-0.711-1.332-0.474c-0.806 0.383-1.671 0.577-2.571 0.577-3.308 0-6-2.692-6-6s2.692-6 6-6 6 2.692 6 6v1c0 0.551-0.449 1-1 1s-1-0.449-1-1v-4c0-0.552-0.448-1-1-1-0.406 0-0.755 0.242-0.912 0.59-0.608-0.374-1.323-0.59-2.088-0.59-2.206 0-4 1.794-4 4s1.794 4 4 4c1.045 0 1.998-0.403 2.712-1.062 0.551 0.649 1.372 1.062 2.288 1.062 1.654 0 3-1.346 3-3v-1c0-2.137-0.832-4.146-2.343-5.657zM8 10c-1.103 0-2-0.897-2-2s0.897-2 2-2c1.103 0 2 0.897 2 2s-0.897 2-2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 850 B

4
assets/img/icons/cancel.svg Executable file
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="M11.5 0h-7l-4.5 4.5v7l4.5 4.5h7l4.5-4.5v-7l-4.5-4.5zM12.5 11l-1.5 1.5-3-3-3 3-1.5-1.5 3-3-3-3 1.5-1.5 3 3 3-3 1.5 1.5-3 3 3 3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 283 B

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="M0 0v16h16v-16h-16zM15 15h-14v-14h14v14z"></path>
<path d="M2.5 8l1.5-1.5 2.5 2.5 5.5-5.5 1.5 1.5-7 7z"></path>
</svg>

After

Width:  |  Height:  |  Size: 259 B

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="M14 0h-12c-1.1 0-2 0.9-2 2v12c0 1.1 0.9 2 2 2h12c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2zM7 12.414l-3.707-3.707 1.414-1.414 2.293 2.293 4.793-4.793 1.414 1.414-6.207 6.207z"></path>
</svg>

After

Width:  |  Height:  |  Size: 325 B

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="M0 0v16h16v-16h-16zM15 15h-14v-14h14v14zM4 4h8v8h-8z"></path>
</svg>

After

Width:  |  Height:  |  Size: 209 B

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="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v13c0 0.825 0.675 1.5 1.5 1.5h13c0.825 0 1.5-0.675 1.5-1.5v-13c0-0.825-0.675-1.5-1.5-1.5zM14 14h-12v-12h12v12zM5 12h6c0.55 0 1-0.45 1-1v-6c0-0.55-0.45-1-1-1h-6c-0.55 0-1 0.45-1 1v6c0 0.55 0.45 1 1 1zM5 10.875h6v0.123c-0.001 0.001-0.001 0.001-0.002 0.002h-5.996c-0.001-0.001-0.001-0.001-0.002-0.002v-0.123zM5.002 5h5.996c0.001 0.001 0.001 0.001 0.002 0.002v0.123h-6v-0.123c0.001-0.001 0.001-0.001 0.002-0.002zM11 5.375v0.25h-6v-0.25h6zM11 5.875v0.25h-6v-0.25h6zM11 6.375v0.25h-6v-0.25h6zM11 6.875v0.25h-6v-0.25h6zM11 7.375v0.25h-6v-0.25h6zM11 7.875v0.25h-6v-0.25h6zM11 8.375v0.25h-6v-0.25h6zM11 8.875v0.25h-6v-0.25h6zM11 9.375v0.25h-6v-0.25h6zM11 9.875v0.25h-6v-0.25h6zM11 10.375v0.25h-6v-0.25h6z"></path>
</svg>

After

Width:  |  Height:  |  Size: 890 B

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="M0 0v16h16v-16h-16zM15 15h-14v-14h14v14z"></path>
</svg>

After

Width:  |  Height:  |  Size: 197 B

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="M14 0h-12c-1.1 0-2 0.9-2 2v12c0 1.1 0.9 2 2 2h12c1.1 0 2-0.9 2-2v-12c0-1.1-0.9-2-2-2zM14 14h-12v-12h12v12z"></path>
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -0,0 +1,12 @@
<!-- 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="M4 3.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5z"></path>
<path d="M2.5 5c-1.381 0-2.5 0.448-2.5 1v1h5v-1c0-0.552-1.119-1-2.5-1z"></path>
<path d="M14 3.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5z"></path>
<path d="M12.5 5c-1.381 0-2.5 0.448-2.5 1v1h5v-1c0-0.552-1.119-1-2.5-1z"></path>
<path d="M9 11.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5z"></path>
<path d="M7.5 13c-1.381 0-2.5 0.448-2.5 1v1h5v-1c0-0.552-1.119-1-2.5-1z"></path>
<path d="M9.5 4h-4c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h4c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"></path>
<path d="M10.5 12c-0.095 0-0.192-0.027-0.277-0.084-0.23-0.153-0.292-0.464-0.139-0.693l2-3c0.153-0.23 0.464-0.292 0.693-0.139s0.292 0.464 0.139 0.693l-2 3c-0.096 0.145-0.255 0.223-0.416 0.223z"></path>
<path d="M4.5 12c-0.162 0-0.32-0.078-0.417-0.223l-2-3c-0.153-0.23-0.091-0.54 0.139-0.693s0.54-0.091 0.693 0.139l2 3c0.153 0.23 0.091 0.54-0.139 0.693-0.085 0.057-0.182 0.084-0.277 0.084z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

4
assets/img/icons/exclude.svg Executable file
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="M11 4v-4h-11v11h4v5h12v-12h-5zM14 6.125h-3v-0.125h3v0.125zM6 14v-0.125h8v0.125h-8zM6 13.625v-0.25h8v0.25h-8zM6 13.125v-0.25h8v0.25h-8zM6 12.625v-0.25h8v0.25h-8zM6 12.125v-0.25h8v0.25h-8zM6 11.625v-0.25h8v0.25h-8zM6 11.125v-0.125h5v-0.125h3v0.25h-8zM11 10.625v-0.25h3v0.25h-3zM11 10.125v-0.25h3v0.25h-3zM11 9.625v-0.25h3v0.25h-3zM11 9.125v-0.25h3v0.25h-3zM11 8.625v-0.25h3v0.25h-3zM11 8.125v-0.25h3v0.25h-3zM11 7.625v-0.25h3v0.25h-3zM11 7.125v-0.25h3v0.25h-3zM11 6.625v-0.25h3v0.25h-3zM9 4h-5v0.125h-2v-0.25h7v0.125zM2 9v-0.125h2v0.125h-2zM2 8.625v-0.25h2v0.25h-2zM2 8.125v-0.25h2v0.25h-2zM2 7.625v-0.25h2v0.25h-2zM2 7.125v-0.25h2v0.25h-2zM2 6.625v-0.25h2v0.25h-2zM2 6.125v-0.25h2v0.25h-2zM2 5.625v-0.25h2v0.25h-2zM2 5.125v-0.25h2v0.25h-2zM2 4.625v-0.25h2v0.25h-2zM9 3.625h-7v-0.25h7v0.25zM9 3.125h-7v-0.25h7v0.25zM9 2.625h-7v-0.25h7v0.25zM9 2.125h-7v-0.125h7v0.125zM5 5h5v5h-5v-5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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
assets/img/icons/interset.svg Executable file
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="M11 4v-4h-11v11h4v5h12v-12h-5zM10 6.125h-4v-0.125h4v0.125zM10 10h-4v-0.125h4v0.125zM10 9.625h-4v-0.25h4v0.25zM10 9.125h-4v-0.25h4v0.25zM10 8.625h-4v-0.25h4v0.25zM10 8.125h-4v-0.25h4v0.25zM10 7.625h-4v-0.25h4v0.25zM10 7.125h-4v-0.25h4v0.25zM10 6.625h-4v-0.25h4v0.25zM4 10h-3v-9h9v3h-6v6zM15 15h-10v-3h7v-7h3v10z"></path>
</svg>

After

Width:  |  Height:  |  Size: 467 B

5
assets/img/icons/merge.svg Executable file
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="M9 5h3.5l-4.5-4.5-4.5 4.5h3.5v3.586l-5.957 5.957 1.414 1.414 6.543-6.543z"></path>
<path d="M8.543 11.957l1.414-1.414 4 4-1.414 1.414-4-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 295 B

4
assets/img/icons/popout.svg Executable file
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="M14.5 2h-9c-0.825 0-1.5 0.675-1.5 1.5v1.5h-2.5c-0.825 0-1.5 0.675-1.5 1.5v6c0 0.825 0.675 1.5 1.5 1.5h9c0.825 0 1.5-0.675 1.5-1.5v-1.5h2.5c0.825 0 1.5-0.675 1.5-1.5v-6c0-0.825-0.675-1.5-1.5-1.5zM11 12.5c0 0.271-0.229 0.5-0.5 0.5h-9c-0.271 0-0.5-0.229-0.5-0.5v-6c0-0.271 0.229-0.5 0.5-0.5h2.5v3.5c0 0.825 0.675 1.5 1.5 1.5h5.5v1.5zM15 9.5c0 0.271-0.229 0.5-0.5 0.5h-9c-0.271 0-0.5-0.229-0.5-0.5v-6c0-0.271 0.229-0.5 0.5-0.5h9c0.271 0 0.5 0.229 0.5 0.5v6z"></path>
</svg>

After

Width:  |  Height:  |  Size: 610 B

4
assets/img/icons/profile.svg Executable file
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

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="M5 1v1.155l-2.619 0.368 0.17 1.211-2.551 0.732 3.308 11.535 10.189-2.921 0.558-0.079h1.945v-12h-11zM3.929 14.879l-2.808-9.793 1.558-0.447 1.373 9.766 2.997-0.421-3.119 0.894zM4.822 13.382l-1.418-10.088 1.595-0.224v9.93h2.543l-2.721 0.382zM15 12h-9v-10h9v10zM13 8h-2v2h-1v-2h-2v-1h2v-2h1v2h2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 448 B

4
assets/img/icons/stack.svg Executable file
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.75 4h-0.75v-0.75c0-0.689-0.561-1.25-1.25-1.25h-0.75v-0.75c0-0.689-0.561-1.25-1.25-1.25h-7.5c-0.689 0-1.25 0.561-1.25 1.25v9.5c0 0.689 0.561 1.25 1.25 1.25h0.75v0.75c0 0.689 0.561 1.25 1.25 1.25h0.75v0.75c0 0.689 0.561 1.25 1.25 1.25h7.5c0.689 0 1.25-0.561 1.25-1.25v-9.5c0-0.689-0.561-1.25-1.25-1.25zM2.25 11c-0.138 0-0.25-0.113-0.25-0.25v-9.5c0-0.137 0.112-0.25 0.25-0.25h7.5c0.137 0 0.25 0.113 0.25 0.25v0.75h-5.75c-0.689 0-1.25 0.561-1.25 1.25v7.75h-0.75zM4.25 13c-0.138 0-0.25-0.113-0.25-0.25v-9.5c0-0.138 0.112-0.25 0.25-0.25h7.5c0.137 0 0.25 0.112 0.25 0.25v0.75h-5.75c-0.689 0-1.25 0.561-1.25 1.25v7.75h-0.75zM14 14.75c0 0.137-0.113 0.25-0.25 0.25h-7.5c-0.138 0-0.25-0.113-0.25-0.25v-9.5c0-0.138 0.112-0.25 0.25-0.25h7.5c0.137 0 0.25 0.112 0.25 0.25v9.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 922 B

4
assets/img/icons/stack2.svg Executable file
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="M16 5l-8-4-8 4 8 4 8-4zM8 2.328l5.345 2.672-5.345 2.672-5.345-2.672 5.345-2.672zM14.398 7.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199zM14.398 10.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199z"></path>
</svg>

After

Width:  |  Height:  |  Size: 354 B

4
assets/img/icons/stack3.svg Executable file
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="M8 9l-8-4 8-4 8 4zM14.398 7.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199zM14.398 10.199l1.602 0.801-8 4-8-4 1.602-0.801 6.398 3.199z"></path>
</svg>

After

Width:  |  Height:  |  Size: 292 B

4
assets/img/icons/unite.svg Executable file
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="M11 4v-4h-11v11h4v5h12v-12h-5zM10 5.125h-5v-0.125h5v0.125zM14 6.125h-3v-0.125h3v0.125zM5 9.875h5v0.125h-5v-0.125zM2 8.875h2v0.125h-2v-0.125zM4 5.875v0.25h-2v-0.25h2zM2 5.625v-0.25h2v0.25h-2zM4 6.375v0.25h-2v-0.25h2zM5 8.125v-0.25h5v0.25h-5zM10 8.375v0.25h-5v-0.25h5zM5 7.625v-0.25h5v0.25h-5zM5 7.125v-0.25h5v0.25h-5zM5 6.625v-0.25h5v0.25h-5zM4 6.875v0.25h-2v-0.25h2zM4 7.375v0.25h-2v-0.25h2zM4 7.875v0.25h-2v-0.25h2zM4 8.375v0.25h-2v-0.25h2zM5 8.875h5v0.25h-5v-0.25zM10 9.375v0.25h-5v-0.25h5zM6 14v-0.125h8v0.125h-8zM6 13.625v-0.25h8v0.25h-8zM6 13.125v-0.25h8v0.25h-8zM6 12.625v-0.25h8v0.25h-8zM6 12.125v-0.25h8v0.25h-8zM6 11.625v-0.25h8v0.25h-8zM6 11.125v-0.125h5v-0.125h3v0.25h-8zM11 10.625v-0.25h3v0.25h-3zM11 10.125v-0.25h3v0.25h-3zM11 9.625v-0.25h3v0.25h-3zM11 9.125v-0.25h3v0.25h-3zM11 8.625v-0.25h3v0.25h-3zM11 8.125v-0.25h3v0.25h-3zM11 7.625v-0.25h3v0.25h-3zM11 7.125v-0.25h3v0.25h-3zM11 6.625v-0.25h3v0.25h-3zM10 6.125h-5v-0.25h5v0.25zM5 5.625v-0.25h5v0.25h-5zM4 5.125h-2v-0.25h2v0.25zM9 2v0.125h-7v-0.125h7zM9 2.375v0.25h-7v-0.25h7zM9 2.875v0.25h-7v-0.25h7zM9 3.375v0.25h-7v-0.25h7zM9 3.875v0.125h-5v0.125h-2v-0.25h7zM4 4.375v0.25h-2v-0.25h2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

11
assets/img/icons/unlink5.svg Executable file
View File

@@ -0,0 +1,11 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16">
<path d="M7 12h-3c-2.206 0-4-1.794-4-4s1.794-4 4-4h3c0.552 0 1 0.448 1 1s-0.448 1-1 1h-3c-1.103 0-2 0.897-2 2s0.897 2 2 2h3c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
<path d="M13 12h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h3c1.103 0 2-0.897 2-2s-0.897-2-2-2h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h3c2.206 0 4 1.794 4 4s-1.794 4-4 4z"></path>
<path d="M8.5 3c-0.276 0-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5z"></path>
<path d="M11.5 3c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l2-2c0.195-0.195 0.512-0.195 0.707 0s0.195 0.512 0 0.707l-2 2c-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M5.5 3c-0.128 0-0.256-0.049-0.354-0.146l-2-2c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l2 2c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M8.5 13c0.276 0 0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5z"></path>
<path d="M5.5 13c0.128 0 0.256 0.049 0.354 0.146 0.195 0.195 0.195 0.512 0 0.707l-2 2c-0.195 0.195-0.512 0.195-0.707 0s-0.195-0.512 0-0.707l2-2c0.098-0.098 0.226-0.146 0.354-0.146z"></path>
<path d="M11.5 13c0.128 0 0.256 0.049 0.354 0.146l2 2c0.195 0.195 0.195 0.512 0 0.707s-0.512 0.195-0.707 0l-2-2c-0.195-0.195-0.195-0.512 0-0.707 0.098-0.098 0.226-0.146 0.354-0.146z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

2
common

Submodule common updated: ec4b15ce33...ec0812ad9d

View File

@@ -1,3 +1,132 @@
<script setup>
import { format, subYears } from 'date-fns';
import { parse } from 'path-to-regexp';
import { inject, ref } from 'vue';
import ActorTile from '#/components/actors/tile.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import CountryFilter from '#/components/filters/country.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Pagination from '#/components/pagination/pagination.vue';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const pageStash = pageProps.stash;
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.actorTotal || pageProps.total));
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
});
function getPath(preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'page') {
return `${segment.prefix}${currentPage.value || 1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (q.value) {
order.value = 'results.desc';
}
if (!q.value && order.value.includes('results')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
stashId: pageStash?.id,
};
navigate(getPath(false), query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script>
<template>
<div class="page">
<Filters :results="total">
@@ -5,12 +134,19 @@
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="q"
type="search"
type="input"
placeholder="Search actors"
class="input search"
@search="search"
@keydown.enter="search"
>
<Icon
v-if="q"
icon="cross2"
class="search-button"
@click="q = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@@ -104,135 +240,6 @@
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { format, subYears } from 'date-fns';
import { parse } from 'path-to-regexp';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import ActorTile from '#/components/actors/tile.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Filters from '#/components/filters/filters.vue';
import GenderFilter from '#/components/filters/gender.vue';
import BirthdateFilter from '#/components/filters/birthdate.vue';
import BoobsFilter from '#/components/filters/boobs.vue';
import PhysiqueFilter from '#/components/filters/physique.vue';
import CountryFilter from '#/components/filters/country.vue';
const pageContext = inject('pageContext');
const { pageProps, urlParsed, routeParams } = pageContext;
const q = ref(urlParsed.search.q);
const actors = ref([]);
const pageStash = pageProps.stash;
const countries = ref(pageProps.countries);
const cupRange = ref(pageProps.cupRange);
actors.value = pageProps.actors;
const currentPage = ref(Number(routeParams.page));
const total = ref(Number(pageProps.actorTotal || pageProps.total));
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
const filters = ref({
gender: urlParsed.search.gender,
ageRequired: !!urlParsed.search.age,
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
dobRequired: !!urlParsed.search.dob,
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
country: urlParsed.search.c,
braSizeRequired: !!urlParsed.search.cup,
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
heightRequired: !!urlParsed.search.height,
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
weightRequired: !!urlParsed.search.weight,
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
avatarRequired: !!urlParsed.search.avatar,
});
function getPath(preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (q.value) {
order.value = 'results.desc';
}
if (!q.value && order.value.includes('results')) {
order.value = 'likes.desc';
}
}
const query = {
q: q.value || undefined,
order: order.value,
gender: filters.value.gender || undefined,
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
dob: filters.value.dobRequired ? filters.value.dob : undefined,
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
c: filters.value.country || undefined,
nb: filters.value.naturalBoobs,
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
avatar: filters.value.avatarRequired || undefined,
stashId: pageStash?.id,
};
navigate(getPath(false), query, { redirect: false });
const res = await get('/actors', {
...query,
page: currentPage.value, // client uses param rather than query pagination
});
actors.value = res.actors;
total.value = res.total;
countries.value = res.countries;
events.emit('scrollUp');
}
function paginate({ page }) {
currentPage.value = page;
search({ resetPage: false });
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
</script>
<style>
.gender-button {
&.selected .gender .icon {

176
components/actors/add.vue Normal file
View File

@@ -0,0 +1,176 @@
<script setup>
import { onMounted, ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { post } from '#/src/api.js';
import events from '#/src/events.js';
const emit = defineEmits(['close']);
const nameInput = ref(null);
const isSubmitting = ref(false);
const isSubmitted = ref(false);
const name = ref(null);
const gender = ref('female');
const allowGlobalMatch = ref(true);
const newActor = ref(null);
onMounted(() => {
nameInput.value.focus();
});
async function addActor() {
isSubmitting.value = true;
try {
const { actor } = await post('/actors', {
actor: {
name: name.value,
gender: gender.value,
allowGlobalMatch: allowGlobalMatch.value,
},
}, {
successFeedback: 'Actor has been added.',
appendErrorMessage: true,
});
name.value = null;
gender.value = null;
allowGlobalMatch.value = true;
newActor.value = actor;
}
catch (error) {
events.emit('feedback', {
type: 'error',
message: error.message,
});
}
isSubmitting.value = false;
isSubmitted.value = true;
}
</script>
<template>
<Dialog
title="Add actor"
@close="emit('close')"
>
<div
v-if="isSubmitted && newActor"
class="dialog-body"
>
<strong>Added '{{ newActor.name }}' (#{{ newActor.id }})</strong>
<ul
v-if="isSubmitted && newActor"
class="dialog-body options"
>
<li class="option">
<a
:href="`/actor/edit/${newActor.id}/${newActor.slug}`"
target="_blank"
class="link"
>Edit bio</a>
</li>
<li class="option">
<a
:href="`/actor/${newActor.id}/${newActor.slug}`"
target="_blank"
class="link"
>Go to actor</a>
</li>
<li class="option">
<span
class="link"
@click="newActor = null; isSubmitted = false;"
>Add another actor</span>
</li>
</ul>
</div>
<form
v-else
class="dialog-body"
@submit.prevent="addActor"
>
<input
ref="nameInput"
v-model="name"
class="input"
placeholder="Name"
required
>
<select
v-model="gender"
placeholder="Gender"
class="input"
>
<option value="female">Female</option>
<option value="male">Male</option>
<option value="transsexual">Transsexual</option>
<option value="other">Other</option>
</select>
<Checkbox
label="Allow global match"
:checked="allowGlobalMatch"
@change="(isChecked) => allowGlobalMatch = isChecked"
/>
<div class="dialog-actions">
<Ellipsis v-if="isSubmitting" />
<button
v-else
class="button button-primary"
>Add actor</button>
</div>
</form>
</Dialog>
</template>
<style scoped>
.dialog-body {
width: 20rem;
max-width: 100%;
box-sizing: border-box;
padding: 1rem;
gap: 1.25rem;
}
.load-container {
width: 100%;
display: inline-flex;
justify-content: center;
align-items: center;
padding: 0;
height: 2rem;
}
.options {
padding: 0 1.5rem;
margin: 0;
}
.option {
cursor: pointer;
}
.dialog-actions {
margin-top: .5rem;
}
.button-primary {
width: 100%;
}
</style>

View File

@@ -1,3 +1,99 @@
<script setup>
import formatTemplate from 'template-format';
import { inject, ref, useId } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const expanded = ref(false);
const enhancedTooltipId = useId();
const pageContext = inject('pageContext');
const { user, env } = pageContext;
const iconMap = {
twitter: 'twitter-x',
};
// if the profile is empty, the expand button overlaps the header
const showExpand = [
'age',
'bust',
'cup',
'eyes',
'ethnicity',
'hairColor',
'hasPiercings',
'hasTattoos',
'height',
'hip',
'naturalBoobs',
'origin',
'residence',
'waist',
'weight',
].some((attribute) => !!props.actor[attribute]);
const augmentationMap = {
bbl: 'BBL',
fat: 'fat transfer',
lift: 'direct lift',
lipo: 'lipo without BBL',
filler: 'filler',
mms: 'MMS',
over: 'over-muscle',
under: 'under-muscle',
mammary: 'under breast',
areolar: 'areolar',
crescent: 'crescent areolar',
lollipop: 'lollipop',
axillary: 'armpit',
umbilical: 'navel',
};
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
.filter((profile) => !!profile.description)
.map((profile) => [profile.descriptionHash, {
text: profile.description,
entity: profile.entity,
}])));
function getSocialUrl(social) {
if (social.url) {
return social.url;
}
if (pageContext.env.socials.urls[social.platform]) {
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
const socials = props.actor.socials.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) => {
acc[alias.name] = (props.actor[alias.name] || 0) + 1;
return acc;
}, {}))
.toSorted(([, countA], [, countB]) => countB - countA)
.map(([alias]) => alias)
.filter((alias) => alias !== props.actor.name);
</script>
<template>
<div
class="profile"
@@ -20,6 +116,15 @@
</div>
<ul class="bio nolist">
<li
v-if="aliases.length > 0"
class="bio-item"
>
<dfn class="bio-label"><Icon icon="at-sign" />Aliases</dfn>
<span class="orientation">{{ aliases.join(', ') }}</span>
</li>
<li
v-if="actor.dateOfBirth"
class="bio-item"
@@ -44,7 +149,7 @@
<dfn class="bio-label"><Icon icon="cake" />Age</dfn>
<span
:title="'Exact date of birth or age unknown'"
title="Exact date of birth or age unknown"
class="birthdate"
>&gt; {{ actor.age }}</span>
</li>
@@ -80,10 +185,17 @@
<span
v-if="actor.origin.city"
class="city"
>{{ actor.origin.city }}</span><span
>{{ actor.origin.city }}</span>
<span
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
class="state"
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span>
>
{{ actor.origin.city
? [',', actor.origin.state].join(' ')
: actor.origin.state
}}
</span>
<span
v-if="actor.origin.country"
@@ -105,7 +217,7 @@
class="bio-item residence"
:class="{ hideable: !!actor.origin }"
>
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
<dfn class="bio-label"><Icon icon="location" />{{ actor.dateOfDeath ? 'Lived' : 'Lives' }} in</dfn>
<span>
<span
@@ -146,7 +258,7 @@
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<Icon
v-if="actor.naturalBoobs === false || actor.naturalButt === false"
v-tooltip="[actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', ')"
v-tooltip="{ content: [actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', '), ariaId: enhancedTooltipId }"
icon="magic-wand2"
class="enhanced"
/>
@@ -168,7 +280,7 @@
actor.boobsVolume && `${actor.boobsVolume}cc`,
augmentationMap[actor.boobsPlacement] || actor.boobsPlacement,
augmentationMap[actor.boobsIncision] || actor.boobsIncision,
augmentationMap[actor.boobsImplant] || actor.boobsImplant
augmentationMap[actor.boobsImplant] || actor.boobsImplant,
].filter(Boolean).join(' ')"
class="augmentations-section"
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template>
@@ -208,7 +320,7 @@
<span>
<Icon
v-if="actor.isCircumcised"
:title="'Circumcised'"
title="Circumcised"
icon="pagebreak"
class="circumcised"
/>
@@ -312,10 +424,10 @@
<a
v-for="social in socials"
:key="`social-${social.id}`"
v-tooltip="{ content: social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url, ariaId: `social-${social.id}` }"
:href="getSocialUrl(social)"
target="_blank"
rel="noopener"
:title="social.platform || social.url"
class="social ellipsis"
>
<Icon
@@ -339,7 +451,9 @@
:class="`icon-social icon-${social.platform} icon-generic`"
/>
<!--
<template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
-->
</a>
</ul>
</div>
@@ -356,7 +470,7 @@
:href="`/actor/edit/${actor.id}/${actor.slug}`"
target="_blank"
class="link"
>Edit bio</a>
>Edit</a>
<a
:href="`/actor/revs/${actor.id}/${actor.slug}`"
@@ -420,92 +534,6 @@
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import formatTemplate from 'template-format';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
const expanded = ref(false);
const pageContext = inject('pageContext');
const { user, env } = pageContext;
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const iconMap = {
twitter: 'twitter-x',
};
// if the profile is empty, the expand button overlaps the header
const showExpand = [
'age',
'bust',
'cup',
'eyes',
'ethnicity',
'hairColor',
'hasPiercings',
'hasTattoos',
'height',
'hip',
'naturalBoobs',
'origin',
'residence',
'waist',
'weight',
].some((attribute) => !!props.actor[attribute]);
const augmentationMap = {
bbl: 'BBL',
fat: 'fat transfer',
lift: 'direct lift',
lipo: 'lipo without BBL',
filler: 'filler',
mms: 'MMS',
over: 'over-muscle',
under: 'under-muscle',
mammary: 'under breast',
areolar: 'areolar',
crescent: 'crescent areolar',
lollipop: 'lollipop',
axillary: 'armpit',
umbilical: 'navel',
};
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
.filter((profile) => !!profile.description)
.map((profile) => [profile.descriptionHash, {
text: profile.description,
entity: profile.entity,
}])));
function getSocialUrl(social) {
if (social.url) {
return social.url;
}
if (pageContext.env.socials.urls[social.platform]) {
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
const socials = props.actor.socials.map((social) => ({
...social,
handle: social.url
? new URL(social.url).hostname
: social.handle,
}));
</script>
<style>
.header-gender {
display: inline-flex;
@@ -735,19 +763,23 @@ const socials = props.actor.socials.map((social) => ({
}
.socials {
display: flex;
flex-wrap: wrap;
/*
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0 0;
overflow: hidden;
*/
gap: .25rem;
padding: 0;
}
.social {
display: flex;
height: 2rem;
display: inline-flex;
align-items: center;
padding: .1rem .5rem;
justify-content: center;
padding: .75rem .75rem;
border-radius: .25rem;
color: inherit;
text-decoration: none;
@@ -755,10 +787,6 @@ const socials = props.actor.socials.map((social) => ({
font-weight: normal;
background: var(--highlight-weak-40);
.icon {
margin-right: .5rem;
}
.icon-generic {
fill: var(--highlight);
}
@@ -783,6 +811,7 @@ const socials = props.actor.socials.map((social) => ({
.link {
color: inherit;
flex-shrink: 0;
cursor: pointer;
}
}
@@ -854,7 +883,7 @@ const socials = props.actor.socials.map((social) => ({
display: none;
justify-content: center;
position: absolute;
z-index: 10;
z-index: 1;
bottom: -.25rem;
}

View File

@@ -1,3 +1,12 @@
<script setup>
defineProps({
gender: {
type: String,
default: null,
},
});
</script>
<template>
<span
v-if="gender"
@@ -8,15 +17,6 @@
</span>
</template>
<script setup>
defineProps({
gender: {
type: String,
default: null,
},
});
</script>
<style scoped>
.gender {
&.female .icon {

302
components/actors/merge.vue Normal file
View File

@@ -0,0 +1,302 @@
<script setup>
import { computed, onMounted, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, post } from '#/src/api.js';
const props = defineProps({
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'merged']);
const targetActor = ref(null);
const actorInput = ref(null);
const comment = ref(null);
const actorQuery = ref('');
const actorResults = ref([]);
const submitted = ref(false);
const merged = ref(false);
const searchDropdownId = useId();
const conflictTooltipId = useId();
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
async function searchActors() {
const res = await get('/actors', {
q: actorQuery.value, // return partial matches
limit: 10,
global: true,
});
actorResults.value = res.actors;
}
function getActorNames() {
if (props.actors.length > 1) {
return `${props.actors.length} actors`;
}
if (props.actors[0].entity) {
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
}
return props.actors[0].name;
}
async function merge() {
submitted.value = true;
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, {
comment: comment.value || null,
}, {
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
appendErrorMessage: true,
});
submitted.value = false;
merged.value = true;
emit('merged');
// emit('close');
}
function selectActor(actor) {
targetActor.value = actor;
actorQuery.value = '';
actorResults.value = [];
}
onMounted(() => {
actorInput.value.focus();
});
</script>
<template>
<Dialog
:title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`"
@close="emit('close')"
>
<div
v-if="merged"
class="dialog-body"
>
Merge successful
<ul class="options">
<li class="option">
<a
href=""
class="link"
>Reload page</a>
</li>
<li class="option">
<a
:href="`/actor/${targetActor?.id}/${targetActor?.slug}`"
class="link"
>Go to #{{ targetActor?.id }} {{ targetActor?.name }}</a>
</li>
</ul>
</div>
<div
v-else
class="dialog-body"
>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
class="actor"
>
<span class="source">
<strong class="source-name">{{ actor.name }}<template v-if="actor.entity"> ({{ actor.entity.name }})</template></strong>
<span class="source-id">#{{ actor.id }}</span>
</span>
</li>
</ul>
<span class="path">merging into</span>
<div
v-if="targetActor"
class="target"
>
<strong class="target-name">
<span class="target-id">#{{ targetActor.id }}</span>
{{ targetActor.name }}
</strong>
<Icon
icon="cross2"
@click="targetActor = null"
/>
</div>
<template v-else>
<VDropdown
:aria-id="searchDropdownId"
:triggers="[]"
:shown="actorResults.length > 0"
:auto-hide="false"
>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
placeholder="Search target actor"
@input="searchActors"
>
<template #popper>
<ul
class="results nolist"
>
<li
v-for="actorResult in actorResults"
:key="`actor-result-${actorResult.id}`"
v-close-popper
class="result-item"
@click="selectActor(actorResult)"
>
<div class="result-label">
<span class="result-id">#{{ actorResult.id }}</span> {{ actorResult.name }}
</div>
</li>
</ul>
</template>
</VDropdown>
</template>
<textarea
v-model="comment"
placeholder="Optional comment"
rows="2"
class="input comment"
/>
<div class="dialog-actions">
<Ellipsis v-if="submitted" />
<button
v-else
v-tooltip="sourceTargetConflict && { content: 'Cannot merge actor profile into itself', ariaId: conflictTooltipId }"
type="submit"
class="button button-primary"
:disabled="!targetActor || sourceTargetConflict"
@click="merge"
>Merge</button>
</div>
</div>
</Dialog>
</template>
<style scoped>
.dialog-body {
width: 20rem;
max-width: 100%;
box-sizing: border-box;
padding: 1rem;
gap: 1rem;
overflow: hidden;
}
.dialog-actions {
.button {
width: 100%;
}
}
.actors {
max-height: 15rem;
overflow-y: auto;
}
.actor {
display: block;
}
.input {
width: 100%;
&.comment {
flex-basis: auto;
}
}
.path {
color: var(--glass-strong-20);
}
.target {
display: flex;
justify-content: space-between;
align-items: center;
.icon {
padding: .25rem .75rem;
fill: var(--glass);
&:hover {
fill: var(--error);
cursor: pointer;
}
}
}
.source-id,
.target-id {
font-family: monospace;
font-size: 1rem;
}
.source {
display: flex;
}
.source-name {
flex-grow: 1;
}
.results {
padding: .25rem 0;
}
.result-item {
display: flex;
padding: .25rem .5rem;
&:hover {
cursor: pointer;
color: var(--primary);
}
}
.result-id {
font-family: monospace;
font-size: 1rem;
}
.load-container {
height: 2rem;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
padding: 0 1rem;
}
.options {
padding: 0 1.5rem;
margin: 0;
line-height: 1.75;
}
</style>

View File

@@ -1,5 +1,37 @@
<script setup>
import { ref, useId } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['actor']);
const dropdownId = useId();
const actors = ref([]);
const query = ref(null);
const queryInput = ref(null);
async function search() {
const data = await get('/actors', { q: query.value });
actors.value = data.actors;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template>
<VDropdown
:aria-id="dropdownId"
:disabled="disabled"
class="trigger"
@show="focus"
@@ -36,38 +68,6 @@
</VDropdown>
</template>
<script setup>
import { ref } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const actors = ref([]);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['actor']);
async function search() {
const data = await get('/actors', { q: query.value });
actors.value = data.actors;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped>
.trigger {
height: 100%;

View File

@@ -1,3 +1,29 @@
<script setup>
import { inject, ref, useId } from 'vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Gender from './gender.vue';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user, restriction } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
const entityTooltipId = useId();
const aliasTooltipId = useId();
</script>
<template>
<div
class="tile"
@@ -41,7 +67,7 @@
<span
v-if="actor.ageAtDeath"
:title="`Passed ${formatDate(actor.ageAtDeath, 'MMMM d, yyyy')}`"
:title="`Passed ${formatDate(actor.dateOfDeath, 'MMMM d, yyyy')}`"
class="age age-death"
>{{ actor.ageAtDeath }}</span>
@@ -74,42 +100,32 @@
</div>
<span class="label">
<span class="name ellipsis">{{ actor.name }}</span>
<span
class="name ellipsis"
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
>{{ actor.name }}</span>
<img
v-if="actor.entity"
v-tooltip="actor.entity.name"
v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
class="favicon"
>
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="{
content: `Credited as '${actor.alias.name}'`,
triggers: ['hover', 'click'],
ariaId: aliasTooltipId,
}"
icon="at-sign"
class="alias"
/>
</span>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Heart from '#/components/stashes/heart.vue';
import Gender from './gender.vue';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user, restriction } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
</script>
<style scoped>
.tile {
display: flex;
@@ -128,6 +144,10 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
:deep(.bookmarks) .icon:not(.favorited):not(:hover) {
fill: var(--text-light);
}
.menu {
fill: var(--text-light);
}
}
&.unstashed {
@@ -135,7 +155,16 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
}
}
.menu {
padding: .5rem .75rem;
position: absolute;
top: 0;
left: 0;
fill: var(--highlight-strong-20);
}
.label {
height: 1.75rem;
display: flex;
justify-content: space-between;
align-items: center;
@@ -149,7 +178,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
}
.name {
padding: .35rem .25rem .35rem .5rem;
padding: 0 .25rem 0 .5rem;
}
.favicon {
@@ -246,4 +275,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem;
margin-left: .25rem;
}
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</style>

View File

@@ -1,7 +1,31 @@
<script setup>
import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
async function initCaches() {
await post('/caches', null, {
successFeedback: 'Reloaded caches',
errorFeedback: 'Failed to reload caches',
appendErrorMessage: true,
});
}
</script>
<template>
<div class="page">
<nav class="nav">
<ul class="nav-items nolist">
<li class="nav-item">
<a
href="/admin/actors"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'actors' }"
>Actors</a>
</li>
<li class="nav-item">
<a
href="/admin/revisions/scenes"
@@ -25,6 +49,17 @@
: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 +74,6 @@
</div>
</template>
<script setup>
import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
async function initCaches() {
await post('/caches', null, {
successFeedback: 'Reloaded caches',
errorFeedback: 'Failed to reload caches',
appendErrorMessage: true,
});
}
</script>
<style scoped>
.page {
display: flex;

View File

@@ -1,8 +1,133 @@
<script setup>
import { format } from 'date-fns';
import { computed, inject, ref } from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import { del, get } from '#/src/api.js';
const pageContext = inject('pageContext');
const alerts = ref(pageContext.pageProps.alerts);
const showAlertDialog = ref(false);
const done = ref(true);
const query = ref('');
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(() => {
const queryRegex = new RegExp(query.value, 'i');
return alerts.value.filter((alert) => {
if (filterActors.value && alert.actors.length === 0) {
return false;
}
if (filterEntities.value && alert.entities.length === 0) {
return false;
}
if (filterTags.value && alert.tags.length === 0) {
return false;
}
if (filterExpressions.value && alert.matches.length === 0) {
return false;
}
if (filterScenes.value && alert.scenes.length === 0) {
return false;
}
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;
}
if (queryRegex.test(alert.id)) {
return true;
}
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
return true;
}
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
return true;
}
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
return true;
}
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
return true;
}
if (alert.scenes.some((scene) => queryRegex.test(scene.title))) {
return true;
}
return false;
});
});
async function reloadAlerts() {
alerts.value = await get('/alerts');
}
async function removeAlert(alert) {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-alert
return;
}
done.value = false;
const alertLabel = [
...alert.actors.map((actor) => actor.name),
...alert.tags.map((tag) => tag.name),
...alert.entities.map((entity) => entity.name),
...alert.matches.map((match) => match.expression),
].filter(Boolean).join(', ');
try {
await del(`/alerts/${alert.id}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
await reloadAlerts();
}
finally {
done.value = true;
}
}
const filterCombinedStates = [null, true, false];
function toggleFilterCombined() {
const index = filterCombinedStates.indexOf(filterCombined.value);
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
}
</script>
<template>
<section class="profile-section">
<div class="section-header">
<h3 class="heading">Alerts</h3>
<button
class="button"
@click="showAlertDialog = true"
@@ -22,37 +147,45 @@
<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"
@@ -163,50 +296,64 @@
</span>
</span>
</span>
<span
v-for="scene in alert.scenes"
:key="`alert-${alert.id}-${scene.id}`"
class="alert-scene"
>
<time
:datetime="scene.date.toISOString()"
class="alert-date"
>{{ format(scene.date, 'yyyy-MM-dd') }}</time>
<a
:href="`/scene/${scene.id}/${scene.slug}`"
class="alert-value alert-title ellipsis link"
>{{ scene.title }}</a>
</span>
</div>
<div class="alert-meta">
<div class="alert-triggers">
<Icon
v-if="alert.notify && alert.isFromPreset"
v-tooltip="'Notify in traxxx, added as quick alert'"
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="'Notify in traxxx'"
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
v-if="alert.stashes.some((stash) => !stash.isPrimary)"
v-tooltip="`Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`"
v-tooltip="{ content: `Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`, ariaId: `alert-stashes-${alert.id}` }"
icon="folder-heart"
class="trigger"
/>
<Icon
v-else-if="alert.stashes.length > 0"
v-tooltip="alert.stashes.length > 0 ? 'Add to Favorites' : undefined"
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">
<span
v-tooltip="format(alert.createdAt, 'yyyy-MM-dd hh:mm')"
v-tooltip="{ content: format(alert.createdAt, 'yyyy-MM-dd hh:mm'), ariaId: `alert-created-${alert.id}` }"
class="alert-id"
title="Alert ID"
>#{{ alert.id }}</span>
@@ -227,124 +374,11 @@
</section>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns';
import AlertDialog from '#/components/alerts/create.vue';
import { get, del } from '#/src/api.js';
const pageContext = inject('pageContext');
const alerts = ref(pageContext.pageProps.alerts);
const showAlertDialog = ref(false);
const done = ref(true);
const query = ref('');
const filterActors = ref(false);
const filterEntities = ref(false);
const filterTags = ref(false);
const filterExpressions = ref(false);
const filterCombined = ref(null);
const filteredAlerts = computed(() => {
const queryRegex = new RegExp(query.value, 'i');
return alerts.value.filter((alert) => {
if (filterActors.value && alert.actors.length === 0) {
return false;
}
if (filterEntities.value && alert.entities.length === 0) {
return false;
}
if (filterTags.value && alert.tags.length === 0) {
return false;
}
if (filterExpressions.value && alert.matches.length === 0) {
return false;
}
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length > 1) {
return false;
}
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length === 1) {
return false;
}
if (queryRegex.test(alert.id)) {
return true;
}
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
return true;
}
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
return true;
}
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
return true;
}
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
return true;
}
return false;
});
});
async function reloadAlerts() {
alerts.value = await get('/alerts');
}
async function removeAlert(alert) {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-restricted-globals, no-alert
return;
}
done.value = false;
const alertLabel = [
...alert.actors.map((actor) => actor.name),
...alert.tags.map((tag) => tag.name),
...alert.entities.map((entity) => entity.name),
...alert.matches.map((match) => match.expression),
].filter(Boolean).join(', ');
try {
await del(`/alerts/${alert.id}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
await reloadAlerts();
} finally {
done.value = true;
}
}
const filterCombinedStates = [null, true, false];
function toggleFilterCombined() {
const index = filterCombinedStates.indexOf(filterCombined.value);
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
}
</script>
<style scoped>
.profile-section .section-header {
justify-content: center;
}
.alerts {
width: 100%;
margin-bottom: 1rem;
@@ -475,6 +509,16 @@ function toggleFilterCombined() {
display: flex;
}
.alert-scene {
display: flex;
gap: .5rem;
padding: 0 .5rem;
}
.alert-date {
flex-shrink: 0;
}
.alert-actions {
display: flex;
align-items: center;

View File

@@ -1,390 +1,11 @@
<template>
<Dialog
title="New alert"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="createAlert"
>
<div class="dialog-section">
<div class="section-header">
<h3 class="heading">IF</h3>
</div>
<div class="field actors">
<span
v-tooltip="fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.'"
class="field-logic fields-logic noselect"
@click="fieldsAnd = !fieldsAnd"
>
<Icon
v-show="fieldsAnd"
icon="link3"
/>
<Icon
v-show="!fieldsAnd"
icon="unlink3"
/>
</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(actor, index) in actors"
:key="`actor-${actor.id}`"
class="field-item actor"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="actorAnd = !actorAnd"
>{{ actorAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile">
<div class="field-label">{{ actor.name }}</div>
</div>
<Icon
icon="cross2"
class="field-remove"
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
/>
</li>
<li class="field-add">
<VDropdown @show="focusActorInput">
<button
class="button"
type="button"
><Icon icon="plus3" />Add actor</button>
<template #popper>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
@input="searchActors"
>
<ul class="nolist">
<li
v-for="actor in actorResults"
:key="`actor-result-${actor.id}`"
v-close-popper
class="result-item"
@click="selectActor(actor)"
>
<img
v-if="actor.avatar"
class="field-avatar"
:src="getPath(actor.avatar, 'lazy')"
>
<span
v-else
class="field-avatar"
/>
<div class="result-label">
{{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
</div>
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field tags">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(tag, index) in tags"
:key="`tag-${tag.id}`"
class="field-item tag"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="tagAnd = !tagAnd"
>{{ tagAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">{{ tag.name }}</div>
<Icon
icon="cross2"
class="field-remove"
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add tag</button>
<template #popper>
<input
ref="tagInput"
v-model="tagQuery"
class="input"
@input="searchTags"
>
<ul class="nolist">
<li
v-for="tag in tagResults"
:key="`tag-result-${tag.id}`"
v-close-popper
class="result-item result-label"
@click="selectTag(tag)"
>{{ tag.name }}</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field entities">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
class="field-item entity"
>
<div
v-if="index > 0"
v-tooltip.click="{
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
triggers: ['click'],
autoHide: true,
}"
class="field-logic"
>OR</div>
<div class="field-tile field-label">
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add channel</button>
<template #popper>
<input
ref="entityInput"
v-model="entityQuery"
class="input"
@input="searchEntities"
>
<ul class="nolist">
<li
v-for="entity in entityResults"
:key="`entity-result-${entity.id}`"
v-close-popper
class="result-item result-label"
@click="selectEntity(entity)"
>
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field matches">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(match, index) in matches"
:key="`match-${match.property}-${match.expression}`"
class="field-item match"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="matchAnd = !matchAnd"
>{{ matchAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">
<strong>{{ match.property }}:</strong>&nbsp;{{ match.expression }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add expression</button>
<template #popper>
<form @submit.prevent="addMatch">
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
v-model="matchExpression"
placeholder="Expression, // for regex"
class="input"
>
</form>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section then">
<h3 class="heading">THEN</h3>
<label class="field notify">
<span>Notify me in traxxx</span>
<Checkbox
:checked="notify"
@change="(checked) => notify = checked"
/>
</label>
<!--
<label class="field email">
<span>E-mail me</span>
<Checkbox
:checked="email"
@change="(checked) => email = checked"
/>
</label>
-->
<div class="stash">
<ul class="field-items nolist noselect">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="field-item tag"
>
<div class="field-tile field-label stash">
<Icon
v-if="stash.isPrimary"
class="favorites"
icon="heart7"
/>{{ stash.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
/>
</li>
<template v-if="stashes.length < assets.stashes.length">
<li class="field-add">
<button
v-if="stashes.length === 0"
type="button"
class="button favorites"
@click="selectStash(assets.primaryStash)"
><Icon icon="heart7" />Add to favorites</button>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button field-add"
><Icon icon="folder-heart" />Add to stash</button>
<template #popper>
<ul class="nolist">
<li
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
:key="`stash-result-${stash.id}`"
v-close-popper
class="result-item result-stash result-label"
@click="selectStash(stash)"
>
{{ stash.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</template>
</ul>
</div>
</div>
<div class="dialog-section dialog-actions">
<button
class="button button-submit"
>Set alert</button>
</div>
</form>
</Dialog>
</template>
<script setup>
import { ref, inject } from 'vue';
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
import { inject, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
const { assets } = inject('pageContext');
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const props = defineProps({
presetActors: {
@@ -403,6 +24,12 @@ const props = defineProps({
const emit = defineEmits(['alert', 'close']);
const {
assets,
user,
env,
} = inject('pageContext');
const actors = ref(props.presetActors);
const actorResults = ref([]);
const actorInput = ref(null);
@@ -427,6 +54,13 @@ const actorAnd = ref(true);
const tagAnd = ref(true);
const matchAnd = ref(true);
const fieldsLogicTooltipId = useId();
const addActorDropdownId = useId();
const addTagDropdownId = useId();
const addEntityDropdownId = useId();
const addMatchDropdownId = useId();
const addStashDropdownId = useId();
const notify = ref(true);
const email = ref(false);
@@ -530,6 +164,388 @@ function selectStash(selectedStash) {
}
</script>
<template>
<Dialog
title="New alert"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="createAlert"
>
<div class="dialog-section">
<div class="section-header">
<h3 class="heading">IF</h3>
</div>
<div class="field actors">
<span
v-tooltip="{ content: fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.', ariaId: fieldsLogicTooltipId }"
class="field-logic fields-logic noselect"
@click="fieldsAnd = !fieldsAnd"
>
<Icon
v-show="fieldsAnd"
icon="link3"
/>
<Icon
v-show="!fieldsAnd"
icon="unlink3"
/>
</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(actor, index) in actors"
:key="`actor-${actor.id}`"
class="field-item actor"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="actorAnd = !actorAnd"
>{{ actorAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile">
<div class="field-label">{{ actor.name }}</div>
</div>
<Icon
icon="cross2"
class="field-remove"
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
/>
</li>
<li class="field-add">
<VDropdown
:aria-id="addActorDropdownId"
@show="focusActorInput"
>
<button
class="button"
type="button"
><Icon icon="plus3" />Add actor</button>
<template #popper>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
@input="searchActors"
>
<ul class="nolist">
<li
v-for="actor in actorResults"
:key="`actor-result-${actor.id}`"
v-close-popper
class="result-item"
@click="selectActor(actor)"
>
<img
v-if="actor.avatar"
class="field-avatar"
:src="getPath(actor.avatar, 'lazy')"
>
<span
v-else
class="field-avatar"
/>
<div class="result-label">
{{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
</div>
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field tags">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(tag, index) in tags"
:key="`tag-${tag.id}`"
class="field-item tag"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="tagAnd = !tagAnd"
>{{ tagAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">{{ tag.name }}</div>
<Icon
icon="cross2"
class="field-remove"
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addTagDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add tag</button>
<template #popper>
<input
ref="tagInput"
v-model="tagQuery"
class="input"
@input="searchTags"
>
<ul class="nolist">
<li
v-for="tag in tagResults"
:key="`tag-result-${tag.id}`"
v-close-popper
class="result-item result-label"
@click="selectTag(tag)"
>{{ tag.name }}</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field entities">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
class="field-item entity"
>
<div
v-if="index > 0"
v-tooltip.click="{
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
triggers: ['click'],
autoHide: true,
ariaId: `entity-and-tooltip-${entity.id}`,
}"
class="field-logic"
>OR</div>
<div class="field-tile field-label">
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addEntityDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add channel</button>
<template #popper>
<input
ref="entityInput"
v-model="entityQuery"
class="input"
@input="searchEntities"
>
<ul class="nolist">
<li
v-for="entity in entityResults"
:key="`entity-result-${entity.id}`"
v-close-popper
class="result-item result-label"
@click="selectEntity(entity)"
>
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field matches">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(match, index) in matches"
:key="`match-${match.property}-${match.expression}`"
class="field-item match"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="matchAnd = !matchAnd"
>{{ matchAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">
<strong>{{ match.property }}:</strong>&nbsp;{{ match.expression }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
/>
</li>
<li class="field-add">
<VDropdown :aria-id="addMatchDropdownId">
<button
type="button"
class="button"
><Icon icon="plus3" />Add expression</button>
<template #popper>
<form @submit.prevent="addMatch">
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
v-model="matchExpression"
placeholder="Expression, // for regex"
class="input"
>
</form>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section then">
<h3 class="heading">THEN</h3>
<label class="field notify">
<span>Notify me in traxxx</span>
<Checkbox
:checked="notify"
@change="(checked) => notify = checked"
/>
</label>
<label
v-if="env.emailEnabled && user.email"
class="field email"
>
<span>E-mail me</span>
<Checkbox
:checked="email"
@change="(checked) => email = checked"
/>
</label>
<div class="stash">
<ul class="field-items nolist noselect">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="field-item tag"
>
<div class="field-tile field-label stash">
<Icon
v-if="stash.isPrimary"
class="favorites"
icon="heart7"
/>{{ stash.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
/>
</li>
<template v-if="stashes.length < assets.stashes.length">
<li class="field-add">
<button
v-if="stashes.length === 0"
type="button"
class="button favorites"
@click="selectStash(assets.primaryStash)"
><Icon icon="heart7" />Add to favorites</button>
</li>
<li class="field-add">
<VDropdown :aria-id="addStashDropdownId">
<button
type="button"
class="button field-add"
><Icon icon="folder-heart" />Add to stash</button>
<template #popper>
<ul class="nolist">
<li
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
:key="`stash-result-${stash.id}`"
v-close-popper
class="result-item result-stash result-label"
@click="selectStash(stash)"
>
{{ stash.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</template>
</ul>
</div>
</div>
<div class="dialog-section dialog-actions">
<button
class="button button-submit"
>Set alert</button>
</div>
</form>
</Dialog>
</template>
<style scoped>
.dialog-body {
width: 30rem;

View File

@@ -1,3 +1,37 @@
<script setup>
import { inject } from 'vue';
const props = defineProps({
campaign: {
type: Object,
default: null,
},
backdrop: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { restriction } = pageContext;
// console.log(props.campaign);
const bannerSrc = (() => {
if (props.campaign.banner) {
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
}
return null;
})();
</script>
<template>
<div
v-if="restriction"
@@ -12,7 +46,6 @@
<iframe
v-else-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width"
:height="campaign.banner.height"
:src="bannerSrc"
@@ -45,41 +78,6 @@
</a>
</template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { restriction } = pageContext;
const props = defineProps({
campaign: {
type: Object,
default: null,
},
backdrop: {
type: Boolean,
default: false,
},
});
// console.log(props.campaign);
const bannerSrc = (() => {
if (props.campaign.banner) {
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
}
}
return null;
})();
</script>
<style scoped>
.campaign {
height: 100%;

View File

@@ -1,3 +1,30 @@
<script setup>
import { onMounted } from 'vue';
defineOptions({ inheritAttrs: false });
const props = defineProps({
title: {
type: String,
default: null,
},
confirmClose: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['open', 'close', 'event']);
function close() {
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-alert
emit('close');
}
}
onMounted(() => emit('open'));
</script>
<template>
<Teleport to="#container">
<div
@@ -6,6 +33,7 @@
>
<div
class="dialog"
v-bind="$attrs"
@click.stop
>
<div class="dialog-header">
@@ -24,31 +52,6 @@
</Teleport>
</template>
<script setup>
import { onMounted } from 'vue';
const props = defineProps({
title: {
type: String,
default: null,
},
confirmClose: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['open', 'close', 'event']);
function close() {
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-restricted-globals, no-alert
emit('close');
}
}
onMounted(() => emit('open'));
</script>
<style>
.dialog-body {
display: flex;

View File

@@ -1,3 +1,24 @@
<script setup>
defineProps({
stash: {
type: Object,
default: null,
},
path: {
type: String,
default: '',
},
domains: {
type: Array,
default: () => ['scenes', 'actors', 'movies'],
},
domain: {
type: String,
default: 'scenes',
},
});
</script>
<template>
<nav class="domains">
<Link
@@ -32,27 +53,6 @@
</nav>
</template>
<script setup>
defineProps({
stash: {
type: Object,
default: null,
},
path: {
type: String,
default: '',
},
domains: {
type: Array,
default: () => ['scenes', 'actors', 'movies'],
},
domain: {
type: String,
default: 'scenes',
},
});
</script>
<style scoped>
.domains {
display: flex;

View File

@@ -1,3 +1,42 @@
<script setup>
import { ref, watch } from 'vue';
import ActorSearch from '#/components/actors/search.vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['actors']);
const newActors = ref([]);
function addActor(actor) {
newActors.value = newActors.value.concat(actor);
emit('actors', props.edits.actors.concat(actor.id));
}
watch(() => props.scene, () => {
newActors.value = [];
});
</script>
<template>
<ul
class="actors nolist"
@@ -40,43 +79,6 @@
</ul>
</template>
<script setup>
import { ref, watch } from 'vue';
import ActorSearch from '#/components/actors/search.vue';
const newActors = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['actors']);
function addActor(actor) {
newActors.value = newActors.value.concat(actor);
emit('actors', props.edits.actors.concat(actor.id));
}
watch(() => props.scene, () => { newActors.value = []; });
</script>
<style scoped>
.actors {
display: flex;

View File

@@ -1,3 +1,23 @@
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['augmentation']);
const augmentation = reactive(props.edits.augmentation);
watch(augmentation, () => emit('augmentation', augmentation));
</script>
<template>
<div
class="figure augmentation"
@@ -208,23 +228,3 @@
</div>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['augmentation']);
const augmentation = reactive(props.edits.augmentation);
watch(augmentation, () => emit('augmentation', augmentation));
</script>

View File

@@ -1,3 +1,15 @@
<script setup>
import { format } from 'date-fns';
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<template>
<div class="avatar noshrink">
<img
@@ -19,10 +31,16 @@
>{{ avatar.sharpness.toFixed(2) }}</span>
</span>
<span
:title="`Added ${format(avatar.createdAt, 'yyyy-MM-dd')}, may not reflect photo age`"
class="avatar-date"
>{{ format(avatar.createdAt, '\'\'yy-MM') }}</span>
<a
:href="getPath(avatar)"
target="_blank"
class="avatar-zoom"
@click.stop
>
<Icon
icon="search"
@@ -31,17 +49,6 @@
</div>
</template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.avatar {
display: inline-block;
@@ -89,7 +96,8 @@ defineProps({
}
.avatar-meta,
.avatar-credit {
.avatar-credit,
.avatar-date {
position: absolute;
z-index: 10;
box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem;
left: 0;
}
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -1,104 +1,3 @@
<template>
<div class="figure">
<div class="value-section">
<span class="value-label">Units</span>
<select
:value="units"
class="input"
:disabled="!editing.has('figure')"
@change="emit('units', $event.target.value)"
>
<option value="us">USA</option>
<option value="uk">UK</option>
<option value="eu">Europe/Asia</option>
<option value="jp">Japan</option>
<option value="au">Australia</option>
<option value="it">Italy</option>
<option value="fr">France</option>
</select>
</div>
<span class="figure-bust">
<div class="value-section">
<span class="value-label">Bust</span>
<select
v-model="figure.bust"
class="select input"
placeholder="Bust"
:disabled="!editing.has('figure')"
>
<option
:key="`figure-bust-unknown`"
:value="null"
/>
<option
v-for="bust in bustSizes[units]"
:key="`figure-bust-${bust}`"
:value="Array.isArray(bust) ? bust[0] : bust"
>{{ Array.isArray(bust) ? bust.join('/') : bust }}</option>
</select>
</div>
<div class="value-section">
<span class="value-label">Cup</span>
<select
v-model="figure.cup"
class="select input"
placeholder="Cup"
:disabled="!editing.has('figure')"
>
<option
:key="`figure-cup-unknown`"
:value="null"
/>
<option
v-for="cup in cupSizes[units]"
:key="`figure-cup-${cup}`"
:value="Array.isArray(cup) ? cup[0] : cup"
>{{ Array.isArray(cup) ? cup.join('/') : cup }}</option>
</select>
</div>
</span>
<div class="value-section">
<span class="value-label">Waist</span>
<span>
<input
v-model="figure.waist"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
<div class="value-section">
<span class="value-label">Hip</span>
<span>
<input
v-model="figure.hip"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue';
@@ -145,3 +44,104 @@ const bustSizes = {
bustSizes.uk = bustSizes.us;
bustSizes.jp = bustSizes.eu;
</script>
<template>
<div class="figure">
<div class="value-section">
<span class="value-label">Units</span>
<select
:value="units"
class="input"
:disabled="!editing.has('figure')"
@change="emit('units', $event.target.value)"
>
<option value="us">USA</option>
<option value="uk">UK</option>
<option value="eu">Europe/Asia</option>
<option value="jp">Japan</option>
<option value="au">Australia</option>
<option value="it">Italy</option>
<option value="fr">France</option>
</select>
</div>
<span class="figure-bust">
<div class="value-section">
<span class="value-label">Bust</span>
<select
v-model="figure.bust"
class="select input"
placeholder="Bust"
:disabled="!editing.has('figure')"
>
<option
key="figure-bust-unknown"
:value="null"
/>
<option
v-for="bust in bustSizes[units]"
:key="`figure-bust-${bust}`"
:value="Array.isArray(bust) ? bust[0] : bust"
>{{ Array.isArray(bust) ? bust.join('/') : bust }}</option>
</select>
</div>
<div class="value-section">
<span class="value-label">Cup</span>
<select
v-model="figure.cup"
class="select input"
placeholder="Cup"
:disabled="!editing.has('figure')"
>
<option
key="figure-cup-unknown"
:value="null"
/>
<option
v-for="cup in cupSizes[units]"
:key="`figure-cup-${cup}`"
:value="Array.isArray(cup) ? cup[0] : cup"
>{{ Array.isArray(cup) ? cup.join('/') : cup }}</option>
</select>
</div>
</span>
<div class="value-section">
<span class="value-label">Waist</span>
<span>
<input
v-model="figure.waist"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
<div class="value-section">
<span class="value-label">Hip</span>
<span>
<input
v-model="figure.hip"
type="number"
class="input"
:disabled="!editing.has('figure')"
>
<template v-if="['us', 'uk'].includes(units)">&nbsp;inch</template>
<template v-else>&nbsp;cm</template>
</span>
</div>
</div>
</template>

View File

@@ -1,3 +1,46 @@
<script setup>
import { ref, watch } from 'vue';
import MovieSearch from '#/components/movies/search.vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['movies']);
const newMovies = ref([]);
function addMovie(movie) {
newMovies.value = newMovies.value.concat(movie);
console.log(movie);
emit('movies', props.edits.movies.concat(movie.id));
console.log(props.edits);
}
watch(() => props.scene, () => {
newMovies.value = [];
});
</script>
<template>
<ul
class="movies nolist"
@@ -40,47 +83,6 @@
</ul>
</template>
<script setup>
import { ref, watch } from 'vue';
import MovieSearch from '#/components/movies/search.vue';
const newMovies = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['movies']);
function addMovie(movie) {
newMovies.value = newMovies.value.concat(movie);
console.log(movie);
emit('movies', props.edits.movies.concat(movie.id));
console.log(props.edits);
}
watch(() => props.scene, () => { newMovies.value = []; });
</script>
<style scoped>
.movies {
display: flex;

View File

@@ -1,3 +1,27 @@
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
units: {
type: String,
default: null,
},
});
const emit = defineEmits(['penis', 'units']);
const penis = reactive(props.edits.penis);
watch(penis, () => emit('penis', penis));
</script>
<template>
<div
class="figure penis"
@@ -83,27 +107,3 @@
</div>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
units: {
type: String,
default: null,
},
});
const emit = defineEmits(['penis', 'units']);
const penis = reactive(props.edits.penis);
watch(penis, () => emit('penis', penis));
</script>

View File

@@ -1,3 +1,43 @@
<script setup>
import { inject, reactive, watch } from 'vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['place']);
const pageContext = inject('pageContext');
const countries = pageContext.pageProps.countries;
const place = reactive(props.edits[props.item.key]);
watch(place, () => emit('place', place));
const topCountries = [
'AU',
'BR',
'CZ',
'DE',
'JP',
'RU',
'GB',
'US',
];
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
</script>
<template>
<div
class="place"
@@ -35,43 +75,3 @@
</div>
</div>
</template>
<script setup>
import { reactive, watch, inject } from 'vue';
const props = defineProps({
item: {
type: Object,
default: null,
},
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const pageContext = inject('pageContext');
const countries = pageContext.pageProps.countries;
const emit = defineEmits(['place']);
const place = reactive(props.edits[props.item.key]);
watch(place, () => emit('place', place));
const topCountries = [
'AU',
'BR',
'CZ',
'DE',
'JP',
'RU',
'GB',
'US',
];
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
</script>

View File

@@ -1,3 +1,42 @@
<script setup>
import formatTemplate from 'template-format';
import { inject } from 'vue';
defineProps({
rev: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
socials: {
type: Array,
default: () => [],
},
});
const pageContext = inject('pageContext');
const { env } = pageContext;
const iconMap = {
twitter: 'twitter-x',
};
function getUrl(social) {
if (social.url) {
return social.url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<template>
<ul class="socials nolist">
<li
@@ -38,46 +77,6 @@
</ul>
</template>
<script setup>
import { inject } from 'vue';
import formatTemplate from 'template-format';
const pageContext = inject('pageContext');
const { env } = pageContext;
defineProps({
rev: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
socials: {
type: Array,
default: () => [],
},
});
const iconMap = {
twitter: 'twitter-x',
};
function getUrl(social) {
if (social.url) {
return social.url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<style scoped>
.socials {
display: flex;

View File

@@ -1,3 +1,210 @@
<script setup>
import { format } from 'date-fns';
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 { get, post } from '#/src/api.js';
import { formatDuration } from '#/utils/format.js';
defineProps({
context: {
type: String,
default: 'admin',
},
});
const pageContext = inject('pageContext');
const revisions = ref(pageContext.pageProps.revisions);
const domain = pageContext.routeParams.domain;
const actors = ref(pageContext.pageProps.actors);
const tags = ref(pageContext.pageProps.tags);
const movies = ref(pageContext.pageProps.movies);
const avatars = ref(pageContext.pageProps.avatars);
const actorsById = computed(() => Object.fromEntries(actors.value.map((actor) => [actor.id, actor])));
const tagsById = computed(() => Object.fromEntries(tags.value.map((tag) => [tag.id, tag])));
const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) => [movie.id, movie])));
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
const feedbacks = ref({});
const showReviewed = ref(false);
const reviewedRevisions = ref(new Set());
const expanded = ref(new Set());
const mappedKeys = {
actors: actorsById,
// tags: tagsById,
movies: moviesById,
};
const dateKeys = [
'date',
'dateOfBirth',
'dateOfDeath',
'productionDate',
'createdAt',
];
const curatedKeys = {
duration: (duration) => formatDuration(duration),
};
const curatedRevisions = computed(() => revisions.value.map((revision) => {
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
if (Array.isArray(value) && mappedKeys[key]) {
return [key, value.map((itemId) => ({
id: itemId,
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
}))];
}
if (key === 'tags') {
return [key, value.map((tag) => ({
id: tag.id,
name: tag.actorId
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
: tagsById.value[tag.id]?.name,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaTag) => deltaTag.id === tag.id && (!Object.hasOwn(tag, 'actorId') || deltaTag.actorId === tag.actorId))),
}))];
}
if (key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return [key, value.map((item) => ({
...item,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)),
}))];
}
if (dateKeys.includes(key) && value) {
return [key, new Date(value)];
}
if (curatedKeys[key]) {
return [key, curatedKeys[key](value)];
}
return [key, value];
}));
const curatedDeltas = revision.deltas.map((delta) => {
if (Array.isArray(delta.value) && mappedKeys[delta.key]) {
return {
...delta,
value: delta.value.map((itemId) => ({
id: itemId,
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
modified: !revision.base[delta.key].includes(itemId),
})),
};
}
if (delta.key === 'tags') {
return {
...delta,
value: delta.value.map((tag) => ({
id: tag.id,
name: tag.actorId
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
: tagsById.value[tag.id]?.name,
modified: !revision.base[delta.key].some((baseTag) => baseTag.id === tag.id && (!Object.hasOwn(baseTag, 'actorId') || baseTag.actorId === tag.actorId)),
})),
};
}
if (delta.key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return {
...delta,
value: delta.value.map((social) => ({
...social,
modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`),
})),
};
}
if (delta.key === 'mergeTarget' || delta.key === 'mergeSource') {
// new socials don't have IDs yet, so we need to compare the values
return {
...delta,
value: `#${delta.actorId} ${delta.actorName} (${delta.profileIds.length} profiles${delta.profileIds.length > 0 ? `: ${delta.profileIds.join()}` : ''})`,
};
}
if (dateKeys.includes(delta.key) && delta.value) {
return {
...delta,
value: new Date(delta.value),
};
}
if (curatedKeys[delta.key]) {
return {
...delta,
value: curatedKeys[delta.key](delta.value),
};
}
return delta;
});
return {
...revision,
base: curatedBase,
deltas: curatedDeltas,
};
}));
async function reloadRevisions() {
const updatedRevisions = await get(`/revisions/${domain}`, {
isFinalized: showReviewed.value ? undefined : false,
limit: 50,
});
actors.value = updatedRevisions.actors;
tags.value = updatedRevisions.tags;
movies.value = updatedRevisions.movies;
avatars.value = updatedRevisions.avatars;
revisions.value = updatedRevisions.revisions;
}
async function reviewRevision(revision, isApproved) {
reviewedRevisions.value.add(revision.id);
try {
await post(`/revisions/${domain}/${revision.id}/reviews`, {
isApproved,
feedback: feedbacks.value[revision.id],
}, {
appendErrorMessage: true,
});
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
revisionId: revision.id,
});
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
}
catch (error) {
reviewedRevisions.value.delete(revision.id);
}
}
async function banEditor(revision) {
await post('/bans', {
userId: revision.user.id,
banIp: true,
});
await reviewRevision(revision, false);
}
</script>
<template>
<div class="page">
<div
@@ -21,7 +228,7 @@
class="rev"
:class="{
reviewed: reviewedRevisions.has(rev.id),
expanded: context === 'admin' || expanded.has(rev.id)
expanded: context === 'admin' || expanded.has(rev.id),
}"
>
<template v-if="context === 'admin' || expanded.has(rev.id)">
@@ -65,14 +272,14 @@
<template v-else-if="context === 'admin'">
<Icon
v-tooltip="`Ban user from submitting revisions`"
v-tooltip="{ content: 'Ban user from submitting revisions', ariaId: `rev-ban-${rev.id}` }"
icon="user-block"
class="review-reject review-ban"
@click="banEditor(rev)"
/>
<Icon
v-tooltip="`Reject revision`"
v-tooltip="{ content: 'Reject revision', ariaId: `rev-reject-${rev.id}` }"
icon="blocked"
class="review-reject"
@click="reviewRevision(rev, false)"
@@ -85,7 +292,7 @@
>
<Icon
v-tooltip="`Approve and apply revision`"
v-tooltip="{ content: 'Approve and apply revision', ariaId: `rev-approve-${rev.id}` }"
icon="checkmark"
class="review-approve"
@click="reviewRevision(rev, true)"
@@ -209,7 +416,7 @@
<a
v-if="rev.user && context !== 'user'"
v-tooltip="rev.user.username"
v-tooltip="{ content: rev.user.username, ariaId: `rev-user-${rev.id}` }"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-avatar nolink ellipsis"
@@ -227,181 +434,6 @@
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { format } from 'date-fns';
import Avatar from '#/components/edit/avatar.vue';
import Socials from '#/components/edit/revision-socials.vue';
import Checkbox from '#/components/form/checkbox.vue';
import { get, post } from '#/src/api.js';
import { formatDuration } from '#/utils/format.js';
defineProps({
context: {
type: String,
default: 'admin',
},
});
const pageContext = inject('pageContext');
const revisions = ref(pageContext.pageProps.revisions);
const domain = pageContext.routeParams.domain;
const actors = ref(pageContext.pageProps.actors);
const tags = ref(pageContext.pageProps.tags);
const movies = ref(pageContext.pageProps.movies);
const avatars = ref(pageContext.pageProps.avatars);
const actorsById = computed(() => Object.fromEntries(actors.value.map((actor) => [actor.id, actor])));
const tagsById = computed(() => Object.fromEntries(tags.value.map((tag) => [tag.id, tag])));
const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) => [movie.id, movie])));
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
const feedbacks = ref({});
const showReviewed = ref(false);
const reviewedRevisions = ref(new Set());
const expanded = ref(new Set());
const mappedKeys = {
actors: actorsById,
tags: tagsById,
movies: moviesById,
};
const dateKeys = [
'date',
'dateOfBirth',
'dateOfDeath',
'productionDate',
'createdAt',
];
const curatedKeys = {
duration: (duration) => formatDuration(duration),
};
const curatedRevisions = computed(() => revisions.value.map((revision) => {
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
if (Array.isArray(value) && mappedKeys[key]) {
return [key, value.map((itemId) => ({
id: itemId,
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
}))];
}
if (key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return [key, value.map((item) => ({
...item,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)),
}))];
}
if (dateKeys.includes(key) && value) {
return [key, new Date(value)];
}
if (curatedKeys[key]) {
return [key, curatedKeys[key](value)];
}
return [key, value];
}));
const curatedDeltas = revision.deltas.map((delta) => {
if (Array.isArray(delta.value) && mappedKeys[delta.key]) {
return {
...delta,
value: delta.value.map((itemId) => ({
id: itemId,
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
modified: !revision.base[delta.key].includes(itemId),
})),
};
}
if (delta.key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return {
...delta,
value: delta.value.map((social) => ({
...social,
modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`),
})),
};
}
if (dateKeys.includes(delta.key) && delta.value) {
return {
...delta,
value: new Date(delta.value),
};
}
if (curatedKeys[delta.key]) {
return {
...delta,
value: curatedKeys[delta.key](delta.value),
};
}
return delta;
});
return {
...revision,
base: curatedBase,
deltas: curatedDeltas,
};
}));
async function reloadRevisions() {
const updatedRevisions = await get(`/revisions/${domain}`, {
isFinalized: showReviewed.value ? undefined : false,
limit: 50,
});
actors.value = updatedRevisions.actors;
tags.value = updatedRevisions.tags;
movies.value = updatedRevisions.movies;
avatars.value = updatedRevisions.avatars;
revisions.value = updatedRevisions.revisions;
}
async function reviewRevision(revision, isApproved) {
reviewedRevisions.value.add(revision.id);
try {
await post(`/revisions/${domain}/${revision.id}/reviews`, {
isApproved,
feedback: feedbacks.value[revision.id],
}, {
appendErrorMessage: true,
});
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
revisionId: revision.id,
});
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
} catch (error) {
reviewedRevisions.value.delete(revision.id);
}
}
async function banEditor(revision) {
await post('/bans', {
userId: revision.user.id,
banIp: true,
});
await reviewRevision(revision, false);
}
</script>
<style scoped>
.page {
display: flex;

View File

@@ -1,3 +1,75 @@
<script setup>
import formatTemplate from 'template-format';
import {
inject,
ref,
useId,
watch,
} from 'vue';
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['socials']);
const pageContext = inject('pageContext');
const { env } = pageContext;
const socials = ref(props.edits.socials);
const platform = ref('');
const handle = ref('');
const url = ref('');
const addSocialDropdownId = useId();
watch(socials, () => emit('socials', socials.value));
const iconMap = {
twitter: 'twitter-x',
};
function addSocial() {
if (!handle.value && !url.value) {
return;
}
if (handle.value && !platform.value) {
return;
}
socials.value = socials.value.concat({
platform: platform.value || null,
handle: handle.value || null,
url: url.value || null,
});
emit('socials', socials.value);
platform.value = '';
handle.value = '';
url.value = '';
}
function getUrl(social) {
if (social.url) {
return url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<template>
<ul
class="list nolist"
@@ -54,7 +126,7 @@
</li>
<li class="list-new">
<VDropdown>
<VDropdown :aria-id="addSocialDropdownId">
<Icon
icon="plus2"
class="add noselect"
@@ -110,77 +182,6 @@
</ul>
</template>
<script setup>
import {
ref,
watch,
inject,
} from 'vue';
import formatTemplate from 'template-format';
const pageContext = inject('pageContext');
const { env } = pageContext;
const props = defineProps({
edits: {
type: Object,
default: null,
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['socials']);
const socials = ref(props.edits.socials);
const platform = ref('');
const handle = ref('');
const url = ref('');
watch(socials, () => emit('socials', socials));
const iconMap = {
twitter: 'twitter-x',
};
function addSocial() {
if (!handle.value && !url.value) {
return;
}
if (handle.value && !platform.value) {
return;
}
socials.value = socials.value.concat({
platform: platform.value || null,
handle: handle.value || null,
url: url.value || null,
});
emit('socials', socials.value);
platform.value = '';
handle.value = '';
url.value = '';
}
function getUrl(social) {
if (social.url) {
return url;
}
if (env.socials.urls[social.platform]) {
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
}
return null;
}
</script>
<style scoped>
.list {
display: flex;

View File

@@ -1,3 +1,72 @@
<script setup>
import { ref, watch } from 'vue';
import TagSearch from '#/components/tags/search.vue';
import events from '#/src/events.js';
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['tags']);
const newTags = ref([]);
const tags = [
{
tags: props.item.value.filter((tag) => tag.actorId === null),
actor: null,
actorId: null,
},
...props.scene.actors.map((actor) => ({
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
actor,
actorId: actor?.id || null,
})),
];
function addTag(newTag, actor) {
const actorId = actor?.id || null;
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
events.emit('feedback', {
type: 'error',
message: 'Tag already added',
});
return;
}
const newTagWithActorId = {
...newTag,
actorId,
};
newTags.value = newTags.value.concat(newTagWithActorId);
emit('tags', props.edits.tags.concat(newTagWithActorId));
}
watch(() => props.scene, () => {
newTags.value = [];
});
</script>
<template>
<div class="tags-sections">
<div
@@ -53,73 +122,6 @@
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import events from '#/src/events.js';
import TagSearch from '#/components/tags/search.vue';
const newTags = ref([]);
const props = defineProps({
item: {
type: Object,
default: null,
},
scene: {
type: Object,
default: null,
},
edits: {
type: Object,
default: () => {},
},
editing: {
type: Set,
default: null,
},
});
const emit = defineEmits(['tags']);
const tags = [
{
tags: props.item.value.filter((tag) => tag.actorId === null),
actor: null,
actorId: null,
},
...props.scene.actors.map((actor) => ({
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
actor,
actorId: actor?.id || null,
})),
];
function addTag(newTag, actor) {
const actorId = actor?.id || null;
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
events.emit('feedback', {
type: 'error',
message: 'Tag already added',
});
return;
}
const newTagWithActorId = {
...newTag,
actorId,
};
newTags.value = newTags.value.concat(newTagWithActorId);
emit('tags', props.edits.tags.concat(newTagWithActorId));
}
watch(() => props.scene, () => { newTags.value = []; });
</script>
<style scoped>
.tags {
display: flex;

307
components/emails/alert.vue Normal file
View File

@@ -0,0 +1,307 @@
<script setup>
import {
Body,
Column,
Container,
Heading,
Html,
Img,
Link,
Preview,
Row,
Text,
} from '@vue-email/components';
import { format } from 'date-fns';
defineProps({
trigger: {
type: Object,
default: () => mockTrigger,
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockTrigger = {
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
alerts: [
{
sceneId: 684061,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684061,
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
date: '2026-02-17T00:00:00.000Z',
description: 'Vanessa Blakes 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 whats 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, Vanessas thick body bouncing with every thrust. When shes fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
actors: [
{
id: 919,
name: 'Vanessa Blake',
},
{
id: 1374,
name: 'Lexington Steele',
},
],
actorIds: [
919,
1374,
],
tags: [
{
name: 'MILF',
slug: 'milf',
},
{
name: 'facial',
slug: 'facial',
},
],
tagIds: [
4,
12,
19,
30,
29,
37,
41,
68,
82,
100,
112,
119,
null,
130,
],
entityId: 522,
parentEntityId: 47,
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
},
},
{
sceneId: 684060,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684060,
title: 'Elizabeth Skylars 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 theres only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
actors: [
{
id: 44001,
name: 'Elizabeth Skylar',
},
{
id: 512,
name: 'Manuel Ferrara',
},
],
actorIds: [
512,
44001,
],
tags: [
{
name: 'anal',
slug: 'anal',
},
{
name: 'ass to mouth',
tag: 'atm',
},
],
tagIds: [
4,
30,
29,
31,
37,
41,
68,
82,
119,
125,
],
entityId: 522,
parentEntityId: 47,
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
},
},
],
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ trigger.username }}</Heading>
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
<div
v-for="(alert, index) in trigger.alerts"
:key="alert.alert.id"
style="margin-bottom: 15px; font-size: 14px;"
:style="{
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0,
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none',
}"
>
<Row style="margin-bottom: 8px;">
<Column valign="top">
{{ format(alert.scene.date, 'yyyy-MM-dd') }} |
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span>
<span v-else>
New
<template
v-if="alert.alert.tags.length > 0"
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template>
scene
<template
v-if="alert.alert.actors.length > 0"
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }}&nbsp;</template>
<template
v-if="alert.alert.entities.length > 0"
>for {{ alert.alert.entities[0].name }}&nbsp;</template>
<template
v-if="alert.alert.matches.length > 0"
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template>
</span>
</Column>
<Column
width="50"
valign="top"
style="text-align: right;"
>
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span>
</Column>
</Row>
<div>
<Row>
<Column
width="175"
valign="top"
>
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`">
<Img
v-if="alert.scene.posterUrl"
:src="alert.scene.posterUrl"
width="160"
:alt="alert.scene.title"
style="margin-top: 2px; border-radius: 8px;"
/>
<div
v-else
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
>No thumb</div>
</Link>
</Column>
<Column valign="top">
<div style="margin-bottom: 5px;">
<Link
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"
style="font-weight: bold; line-height: 1.5; color: inherit;"
>{{ alert.scene.title }}</Link>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
</div>
</Column>
</Row>
</div>
</div>
<Heading
as="h3"
style="margin-top: 30px;"
>Enjoy!</Heading>
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
</Container>
</Body>
</Html>
</template>

View File

@@ -1,3 +1,16 @@
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<template>
<a
:href="`/${entity.type}/${entity.slug}`"
@@ -22,19 +35,6 @@
</a>
</template>
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<style scoped>
.entity {
width: 15rem;

View File

@@ -1,3 +1,34 @@
<script setup>
import Gender from '#/components/actors/gender.vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
defineProps({
actor: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
filters: {
type: Object,
default: null,
},
type: {
type: String,
default: 'available',
},
toggleActor: {
type: Function,
default: null,
},
});
const emit = defineEmits(['actor']);
</script>
<template>
<li
:key="`filter-actor-${actor.id}`"
@@ -44,37 +75,6 @@
</li>
</template>
<script setup>
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
import Gender from '#/components/actors/gender.vue';
defineProps({
actor: {
type: Object,
default: null,
},
index: {
type: Number,
default: null,
},
filters: {
type: Object,
default: null,
},
type: {
type: String,
default: 'available',
},
toggleActor: {
type: Function,
default: null,
},
});
const emit = defineEmits(['actor']);
</script>
<style scoped>
.filter-name {
align-items: stretch;

View File

@@ -1,3 +1,79 @@
<script setup>
import { UseVirtualList } from '@vueuse/components';
import { computed, inject, ref, useId } from 'vue';
import Gender from '#/components/actors/gender.vue';
import Actor from '#/components/filters/actor.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const selectedGender = ref(null);
const order = ref('count');
const genderTooltipId = useId();
const sortAlphaTooltipId = useId();
const sortCountTooltipId = useId();
const pageContext = inject('pageContext');
const pageProps = pageContext.pageProps;
const { actor: pageActor } = pageProps;
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
const availableActors = computed(() => props.actors
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
&& actor.id !== pageActor?.id
&& searchRegexp.value.test(actor.name)
&& (!selectedGender.value || actor.gender === selectedGender.value))
.sort((actorA, actorB) => {
if (order.value === 'count') {
return actorB.count - actorA.count;
}
return actorA.name.localeCompare(actorB.name);
}));
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
function toggleActor(actor, combine) {
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
return;
}
if (combine) {
emit('update', 'actors', props.filters.actors.concat(actor));
}
else {
emit('update', 'actors', [actor]);
}
}
function selectGender() {
const genderIndex = genders.value.indexOf(selectedGender.value);
if (genderIndex >= genders.value.length - 1) {
selectedGender.value = genders.value[0];
return;
}
selectedGender.value = genders.value[genderIndex + 1];
}
</script>
<template>
<div class="filter actors-container">
<div
@@ -18,6 +94,7 @@
</label>
<div
v-tooltip="{ content: selectedGender ? `${selectedGender.slice(0, 1).toUpperCase()}${selectedGender.slice(1)} performers` : `All genders`, ariaId: genderTooltipId }"
class="filter-sort noselect"
@click="selectGender"
>
@@ -35,6 +112,7 @@
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
@@ -45,6 +123,7 @@
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
@@ -96,78 +175,6 @@
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { UseVirtualList } from '@vueuse/components';
import Actor from '#/components/filters/actor.vue';
import Gender from '#/components/actors/gender.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
actors: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const selectedGender = ref(null);
const order = ref('count');
const pageContext = inject('pageContext');
const pageProps = pageContext.pageProps;
const { actor: pageActor } = pageProps;
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
const availableActors = computed(() => props.actors
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
&& actor.id !== pageActor?.id
&& searchRegexp.value.test(actor.name)
&& (!selectedGender.value || actor.gender === selectedGender.value))
.sort((actorA, actorB) => {
if (order.value === 'count') {
return actorB.count - actorA.count;
}
return actorA.name.localeCompare(actorB.name);
}));
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
function toggleActor(actor, combine) {
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
return;
}
if (combine) {
emit('update', 'actors', props.filters.actors.concat(actor));
} else {
emit('update', 'actors', [actor]);
}
}
function selectGender() {
const genderIndex = genders.value.indexOf(selectedGender.value);
if (genderIndex >= genders.value.length - 1) {
selectedGender.value = genders.value[0];
return;
}
selectedGender.value = genders.value[genderIndex + 1];
}
</script>
<style scoped>
.filter {
padding: 0;

View File

@@ -1,3 +1,25 @@
<script setup>
import { format, subYears } from 'date-fns';
import { ref } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
import Checkbox from '#/components/form/checkbox.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
});
// <select> doesn't work well with :value for some reason
const emit = defineEmits(['update']);
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
const dobType = ref(props.filters.dobType);
</script>
<template>
<div class="filter">
<div class="filter-section">
@@ -53,26 +75,6 @@
</div>
</template>
<script setup>
import { ref } from 'vue';
import { format, subYears } from 'date-fns';
import Checkbox from '#/components/form/checkbox.vue';
import RangeFilter from '#/components/filters/range.vue';
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
const props = defineProps({
filters: {
type: Object,
default: null,
},
});
const dobType = ref(props.filters.dobType); // <select> doesn't work well with :value for some reason
const emit = defineEmits(['update']);
</script>
<style scoped>
.input {
width: 100%;

View File

@@ -1,3 +1,33 @@
<script setup>
import { computed } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
cupRange: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
function cupRangeToCups(cupA, cupB) {
const smallestCode = cupA.charCodeAt(0);
const biggestCode = cupB.charCodeAt(0);
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
}
const naturalBoobsValues = [true, undefined, false];
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
</script>
<template>
<div class="filter">
<RangeFilter
@@ -42,33 +72,3 @@
</span>
</div>
</template>
<script setup>
import { computed } from 'vue';
import RangeFilter from '#/components/filters/range.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
cupRange: {
type: Array,
default: null,
},
});
function cupRangeToCups(cupA, cupB) {
const smallestCode = cupA.charCodeAt(0);
const biggestCode = cupB.charCodeAt(0);
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
}
const emit = defineEmits(['update']);
const naturalBoobsValues = [true, undefined, false];
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
</script>

View File

@@ -1,92 +1,5 @@
<template>
<div class="filter channels-container">
<div class="filters-sort">
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'name'"
class="filter-sort order noselect"
@click="order = 'count'"
>
<Icon icon="sort-alpha-asc" />
</div>
<div
v-show="order === 'count'"
class="filter-sort order noselect"
@click="order = 'name'"
>
<Icon icon="sort-numeric-desc" />
</div>
</div>
<div
v-if="entities.length === 0 && !filters.entity"
class="filter-empty"
>No channels</div>
<template v-else>
<ul
class="filter-items nolist"
>
<li
v-for="entity in entities"
:key="`filter-channel-${entity.id}`"
class="filter-item"
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
@click="emit('update', 'entity', entity)"
>
<span class="filter-name">
<span
class="filter-text"
:title="entity.name"
>
<img
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
:src="`/logos/${entity.slug}/favicon_dark.png`"
class="favicon"
>
<Icon
v-else
icon="arrow-up4"
/>
{{ entity.name }}
</span>
<span class="filter-details">
<span
v-if="entity.count"
:title="entity.count"
class="filter-count"
>{{ abbreviateNumber(entity.count) }}</span>
<Icon
v-if="filters.entity?.id === entity.id"
icon="cross2"
class="filter-remove"
@click.native.stop="emit('update', 'entity', null)"
/>
</span>
</span>
</li>
</ul>
</template>
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { computed, inject, ref, useId } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
@@ -106,6 +19,8 @@ const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('name');
const sortAlphaTooltipId = useId();
const sortCountTooltipId = useId();
const { pageProps } = inject('pageContext');
const { channel: pageChannel } = pageProps;
@@ -160,6 +75,95 @@ const entities = computed(() => {
});
</script>
<template>
<div class="filter channels-container">
<div class="filters-sort">
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
<Icon icon="sort-alpha-asc" />
</div>
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
<Icon icon="sort-numeric-desc" />
</div>
</div>
<div
v-if="entities.length === 0 && !filters.entity"
class="filter-empty"
>No channels</div>
<template v-else>
<ul
class="filter-items nolist"
>
<li
v-for="entity in entities"
:key="`filter-channel-${entity.id}`"
class="filter-item"
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
@click="emit('update', 'entity', entity)"
>
<span class="filter-name">
<span
class="filter-text"
:title="entity.name"
>
<img
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
:src="`/logos/${entity.slug}/favicon_dark.png`"
class="favicon"
>
<Icon
v-else
icon="arrow-up4"
/>
{{ entity.name }}
</span>
<span class="filter-details">
<span
v-if="entity.count"
:title="entity.count"
class="filter-count"
>{{ abbreviateNumber(entity.count) }}</span>
<Icon
v-if="filters.entity?.id === entity.id"
icon="cross2"
class="filter-remove"
@click.stop="emit('update', 'entity', null)"
/>
</span>
</span>
</li>
</ul>
</template>
</div>
</template>
<style scoped>
.filter-items {
max-height: 15rem;

View File

@@ -1,3 +1,18 @@
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<template>
<ul class="countries nolist">
<li
@@ -19,27 +34,12 @@
v-if="selectedCountry === country.alpha2"
icon="cross2"
class="filter-remove"
@click.native.stop="emit('country', null)"
@click.stop="emit('country', null)"
/>
</li>
</ul>
</template>
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<style scoped>
.countries:not(:last-child) {
border-bottom: solid 1px var(--glass-weak-30);

View File

@@ -1,3 +1,32 @@
<script setup>
import { computed, inject, ref } from 'vue';
import Countries from './countries.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
countries: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
const pageContext = inject('pageContext');
const { pageProps } = pageContext;
const allCountries = computed(() => props.countries || pageProps.countries);
const countryQuery = ref('');
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
</script>
<template>
<div
class="countries-container"
@@ -43,35 +72,6 @@
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import Countries from './countries.vue';
const props = defineProps({
filters: {
type: Object,
default: null,
},
countries: {
type: Array,
default: null,
},
});
const emit = defineEmits(['update']);
const pageContext = inject('pageContext');
const { pageProps } = pageContext;
const allCountries = computed(() => props.countries || pageProps.countries);
const countryQuery = ref('');
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
</script>
<style scoped>
.countries-container {
border-bottom: solid 1px var(--shadow-weak-30);

View File

@@ -1,3 +1,28 @@
<script setup>
import { ref } from 'vue';
defineProps({
results: {
type: Number,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
// desktop defaults to open, compact defaults to closed
// we can't measure viewframe in SSR, so use separate toggles
const showFilters = ref(true);
const showFiltersCompact = ref(false);
function toggleFilters(state) {
showFilters.value = state ?? !showFilters.value;
showFiltersCompact.value = state ?? !showFiltersCompact.value;
}
</script>
<template>
<div class="filters-frame">
<div
@@ -47,31 +72,6 @@
</div>
</template>
<script setup>
import { ref } from 'vue';
defineProps({
results: {
type: Number,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
// desktop defaults to open, compact defaults to closed
// we can't measure viewframe in SSR, so use separate toggles
const showFilters = ref(true);
const showFiltersCompact = ref(false);
function toggleFilters(state) {
showFilters.value = state ?? !showFilters.value;
showFiltersCompact.value = state ?? !showFiltersCompact.value;
}
</script>
<style>
.filter {
padding: .5rem;

View File

@@ -1,3 +1,16 @@
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<template>
<ul class="filter genders nolist">
<li>
@@ -47,19 +60,6 @@
</ul>
</template>
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<style scoped>
.genders {
display: flex;

View File

@@ -1,3 +1,16 @@
<script setup>
import RangeFilter from '#/components/filters/range.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<template>
<div class="filter">
<RangeFilter
@@ -31,16 +44,3 @@
</RangeFilter>
</div>
</template>
<script setup>
import RangeFilter from '#/components/filters/range.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>

View File

@@ -1,39 +1,3 @@
<template>
<div class="filter-section">
<label class="filter-label noselect">
<span class="label">
<Checkbox
:checked="!disabled"
class="checkbox"
@change="(checked) => $emit('enable', checked)"
/>{{ label }}
</span>
<span
v-if="!disabled"
class="label-values"
>{{ value[0] }} - {{ value[1] }}<template v-if="unit">&nbsp;{{ unit }}</template></span>
</label>
<span class="filter-split">
<Range
:min="min"
:max="max"
:value="value"
:values="values"
:disabled="disabled"
:allow-enable="allowEnable"
@enable="emit('enable', true)"
@input="(range) => emit('input', range)"
@change="(range) => emit('change', range)"
>
<template #start><slot name="start" /></template>
<template #end><slot name="end" /></template>
</Range>
</span>
</div>
</template>
<script setup>
import Checkbox from '../form/checkbox.vue';
import Range from '../form/range.vue';
@@ -75,3 +39,39 @@ defineProps({
const emit = defineEmits(['change', 'input', 'enable']);
</script>
<template>
<div class="filter-section">
<label class="filter-label noselect">
<span class="label">
<Checkbox
:checked="!disabled"
class="checkbox"
@change="(checked) => $emit('enable', checked)"
/>{{ label }}
</span>
<span
v-if="!disabled"
class="label-values"
>{{ value[0] }} - {{ value[1] }}<template v-if="unit">&nbsp;{{ unit }}</template></span>
</label>
<span class="filter-split">
<Range
:min="min"
:max="max"
:value="value"
:values="values"
:disabled="disabled"
:allow-enable="allowEnable"
@enable="emit('enable', true)"
@input="(range) => emit('input', range)"
@change="(range) => emit('change', range)"
>
<template #start><slot name="start" /></template>
<template #end><slot name="end" /></template>
</Range>
</span>
</div>
</template>

View File

@@ -1,3 +1,129 @@
<script setup>
import { computed, inject, ref, useId } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
filters: {
type: Object,
default: null,
},
tags: {
type: Array,
default: () => [],
},
actorTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
const {
tag: pageTag,
actor: pageActor,
} = pageProps;
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority');
const showActorTags = ref(!!pageActor);
const actorTagsTooltipId = useId();
const allTagsTooltipId = useId();
const sortPriorityTooltipId = useId();
const sortCountTooltipId = useId();
const sortAlphaTooltipId = useId();
const priorityTags = [
'anal',
'dp',
'threesome',
'gangbang',
'blowbang',
'orgy',
'airtight',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
'transsexual',
'spitroast',
'mfm',
'fmf',
'bdsm',
'deepthroat',
'blowjob',
'lesbian',
];
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor));
const groupedTags = computed(() => {
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one
const tags = showActorTags.value && isActorTagsAvailable.value
? props.actorTags
: props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name));
if (order.value === 'count') {
const available = filtered.sort((tagA, tagB) => {
if (order.value === 'count') {
return tagB.count - tagA.count;
}
return 0;
});
return {
// page: [pageTag],
selected,
available,
};
}
if (order.value === 'priority') {
return {
// page: [pageTag],
selected,
available: [
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
],
};
}
return {
// page: [pageTag],
selected,
available: filtered,
};
});
function toggleTag(tag, combine) {
emit('update', 'onlyActorTags', showActorTags.value, false);
if (props.filters.tags.includes(tag.slug)) {
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
return;
}
if (combine) {
emit('update', 'tags', props.filters.tags.concat(tag.slug));
}
else {
emit('update', 'tags', [tag.slug]);
}
}
</script>
<template>
<div class="filter tags-container">
<div class="filters-sort">
@@ -12,10 +138,10 @@
<Icon icon="search" />
</label>
<!--
<template v-if="isActorTagsAvailable">
<div
v-show="showActorTags"
v-tooltip="'Tags relevant to the selected actors'"
v-tooltip="{ content: 'Tags relevant to the selected actors', ariaId: actorTagsTooltipId }"
class="filter-sort order noselect"
@click="showActorTags = false"
>
@@ -24,16 +150,17 @@
<div
v-show="!showActorTags"
v-tooltip="'All tags'"
v-tooltip="{ content: 'All tags', ariaId: allTagsTooltipId }"
class="filter-sort order noselect"
@click="showActorTags = true"
>
<Icon icon="price-tags" />
</div>
-->
</template>
<div
v-show="order === 'priority'"
v-tooltip="{ content: 'Sorted by tag priority', ariaId: sortPriorityTooltipId }"
class="filter-sort order noselect"
@click="order = 'count'"
>
@@ -42,6 +169,7 @@
<div
v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect"
@click="order = 'name'"
>
@@ -52,6 +180,7 @@
<div
v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect"
@click="order = 'priority'"
>
@@ -119,121 +248,6 @@
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
filters: {
type: Object,
default: null,
},
tags: {
type: Array,
default: () => [],
},
actorTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
// const { tag: pageTag, actor: pageActor } = pageProps;
const { tag: pageTag } = pageProps;
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority');
// const showActorTags = ref(true);
const priorityTags = [
'anal',
'dp',
'threesome',
'gangbang',
'blowbang',
'orgy',
'airtight',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
'transsexual',
'spitroast',
'mfm',
'fmf',
'bdsm',
'deepthroat',
'blowjob',
'lesbian',
];
const groupedTags = computed(() => {
/*
const tags = showActorTags.value && props.actorTags && (props.filters.actors.length > 0 || pageActor)
? props.actorTags
: props.tags;
*/
const tags = props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name));
if (order.value === 'count') {
const available = filtered.sort((tagA, tagB) => {
if (order.value === 'count') {
return tagB.count - tagA.count;
}
return 0;
});
return {
// page: [pageTag],
selected,
available,
};
}
if (order.value === 'priority') {
return {
// page: [pageTag],
selected,
available: [
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
],
};
}
return {
// page: [pageTag],
selected,
available: filtered,
};
});
function toggleTag(tag, combine) {
if (props.filters.tags.includes(tag.slug)) {
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
return;
}
if (combine) {
emit('update', 'tags', props.filters.tags.concat(tag.slug));
} else {
emit('update', 'tags', [tag.slug]);
}
}
</script>
<style scoped>
.filter-items {
max-height: 15rem;

View File

@@ -1,3 +1,7 @@
<script setup>
const emit = defineEmits(['toggle']);
</script>
<template>
<div
class="filters-toggle open"
@@ -5,10 +9,6 @@
><Icon icon="filter" /></div>
</template>
<script setup>
const emit = defineEmits(['toggle']);
</script>
<style scoped>
.filters-toggle {
min-width: 2.75rem;

View File

@@ -1,28 +1,3 @@
<template>
<div class="years-container">
<select
v-model="selected"
class="years nobar"
multiple
@change="updateYears"
>
<option
v-for="year in years"
:key="`year-${year.year}`"
class="year"
:value="year.year"
>{{ year.year }}</option>
</select>
<Icon
v-show="selected.length > 0"
icon="cross2"
class="clear"
@click="clearYears"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
@@ -50,6 +25,31 @@ function clearYears() {
}
</script>
<template>
<div class="years-container">
<select
v-model="selected"
class="years nobar"
multiple
@change="updateYears"
>
<option
v-for="year in years"
:key="`year-${year.year}`"
class="year"
:value="year.year"
>{{ year.year }}</option>
</select>
<Icon
v-show="selected.length > 0"
icon="cross2"
class="clear"
@click="clearYears"
/>
</div>
</template>
<style scoped>
.years-container {
display: flex;

View File

@@ -1,7 +1,13 @@
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<template>
<footer class="footer">
<span class="footer-segment">© traxxx</span>
<div class="footer-row">
<a
v-if="env.links.matrix"
:href="env.links.matrix"
@@ -23,28 +29,39 @@
:href="env.links.content"
target="_blank"
class="footer-segment footer-link"
>Content Removal / DMCA</a>
>Content Removal (DMCA)</a>
</div>
<div class="footer-row">
<span class="footer-segment">© traxxx v{{ env.version }}</span>
</div>
</footer>
</template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<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;

View File

@@ -1,3 +1,12 @@
<script setup>
import { computed, inject } from 'vue';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<template>
<nav class="nav-list noselect">
<a
@@ -37,15 +46,6 @@
</nav>
</template>
<script setup>
import { computed, inject } from 'vue';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<style scoped>
.nav-list {
display: flex;

View File

@@ -1,3 +1,26 @@
<script setup>
import { useId } from 'vue';
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
const uid = useId();
</script>
<template>
<label class="check-container noselect">
<span
@@ -22,27 +45,6 @@
</label>
</template>
<script setup>
const uid = String(Math.random()).slice(2);
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
</script>
<style scoped>
.check-container {
display: flex;

View File

@@ -1,61 +1,8 @@
<template>
<div class="range-container">
<div
class="label label-start"
:class="{ disabled }"
@click="setRange(range.a === minValue ? 'a' : 'b', min)"
>
<slot name="start" />
</div>
<div
class="range"
:class="{ disabled }"
:style="{ background: `linear-gradient(90deg, var(--slider-track) ${minPercentage}%, var(--slider-range) ${minPercentage}%, var(--slider-range) ${maxPercentage}%, var(--slider-track) ${maxPercentage}%)` }"
@click="setNearest"
>
<input
v-model.number="range.a"
:min="min"
:max="max"
:data-value="range.a"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
<input
v-model.number="range.b"
:min="min"
:max="max"
:data-value="range.b"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
</div>
<div
class="label label-end"
:class="{ disabled }"
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
>
<slot name="end" />
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
nextTick,
ref,
} from 'vue';
const props = defineProps({
@@ -138,6 +85,59 @@ async function setRange(prop, value) {
}
</script>
<template>
<div class="range-container">
<div
class="label label-start"
:class="{ disabled }"
@click="setRange(range.a === minValue ? 'a' : 'b', min)"
>
<slot name="start" />
</div>
<div
class="range"
:class="{ disabled }"
:style="{ background: `linear-gradient(90deg, var(--slider-track) ${minPercentage}%, var(--slider-range) ${minPercentage}%, var(--slider-range) ${maxPercentage}%, var(--slider-track) ${maxPercentage}%)` }"
@click="setNearest"
>
<input
v-model.number="range.a"
:min="min"
:max="max"
:data-value="range.a"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
<input
v-model.number="range.b"
:min="min"
:max="max"
:data-value="range.b"
:disabled="disabled"
type="range"
class="slider"
@input="set('input')"
@change="set('change')"
@click.stop
>
</div>
<div
class="label label-end"
:class="{ disabled }"
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
>
<slot name="end" />
</div>
</div>
</template>
<style>
.dark .range-container .range {
--slider-range: var(--lighten-weak-10);

View File

@@ -1,26 +1,3 @@
<template>
<label
class="toggle-container noselect"
:class="{ light: $store.state.ui.theme === 'dark' }"
>
<input
:id="`toggle-${id}`"
:checked="checked"
:true-value="trueValue"
:false-value="falseValue"
:disabled="disabled"
type="checkbox"
class="toggle-input"
@change="$emit('change', $event.target.checked)"
>
<label
:for="`toggle-${id}`"
class="toggle"
/>
</label>
</template>
<script>
export default {
props: {
@@ -44,12 +21,35 @@ export default {
emits: ['change'],
data() {
return {
id: Math.floor(new Date().getTime() * Math.random()),
id: Math.floor(Date.now() * Math.random()),
};
},
};
</script>
<template>
<label
class="toggle-container noselect"
:class="{ light: $store.state.ui.theme === 'dark' }"
>
<input
:id="`toggle-${id}`"
:checked="checked"
:true-value="trueValue"
:false-value="falseValue"
:disabled="disabled"
type="checkbox"
class="toggle-input"
@change="$emit('change', $event.target.checked)"
>
<label
:for="`toggle-${id}`"
class="toggle"
/>
</label>
</template>
<style lang="scss" scoped>
@import 'breakpoints';

View File

@@ -1,3 +1,64 @@
<script setup>
import Cookies from 'js-cookie';
import {
computed,
inject,
ref,
useId,
} from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import Notifications from '#/components/header/notifications.vue';
import Settings from '#/components/settings/settings.vue';
// import getPath from '#/src/get-path.js';
import { del } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
import logo from '../../assets/img/logo.svg?raw';
const pageContext = inject('pageContext');
const theme = ref(pageContext.env.theme);
const user = pageContext.user;
const primaryStash = pageContext.assets?.primaryStash;
const unseen = ref(pageContext.assets?.unseenNotifications);
const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false);
const showSettings = ref(false);
const showAlertDialog = ref(false);
const notifsDropdownId = useId();
const menuDropdownId = useId();
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
function search() {
navigate('/search', { q: query.value }, { redirect: true });
}
function setTheme(newTheme) {
theme.value = newTheme;
Cookies.set('theme', newTheme);
events.emit('theme', newTheme);
}
async function logout() {
await del('/session');
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
}
function blurSearch(event) {
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
searchFocused.value = false;
}
}
</script>
<template>
<header class="header">
<nav class="nav">
@@ -92,6 +153,7 @@
>
<VDropdown
v-if="user"
:aria-id="notifsDropdownId"
:triggers="['click']"
:prevent-overflow="true"
class="notifs-trigger"
@@ -121,6 +183,7 @@
</VDropdown>
<VDropdown
:aria-id="menuDropdownId"
:triggers="['click']"
:prevent-overflow="true"
class="menu-trigger"
@@ -187,14 +250,59 @@
</a>
</li>
<li
v-if="user"
class="menu-item"
>
<a
:href="`/user/${user.username}/stashes`"
class="menu-button nolink stashes"
>
<Icon icon="folder-heart" />
Stashes
</a>
</li>
<li
v-if="user"
class="menu-item menu-div"
>
<a
:href="`/user/${user.username}/alerts`"
class="menu-button nolink alerts"
>
<Icon icon="alarm" />
Alerts
</a>
</li>
<li
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
@@ -211,28 +319,6 @@
</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"
@@ -257,64 +343,6 @@
</header>
</template>
<script setup>
import {
ref,
computed,
inject,
} from 'vue';
import Cookies from 'js-cookie';
import navigate from '#/src/navigate.js';
import { del } from '#/src/api.js';
import events from '#/src/events.js';
// import getPath from '#/src/get-path.js';
import Notifications from '#/components/header/notifications.vue';
import Settings from '#/components/settings/settings.vue';
import AlertDialog from '#/components/alerts/create.vue';
import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext');
const theme = ref(pageContext.env.theme);
const user = pageContext.user;
const primaryStash = pageContext.assets?.primaryStash;
const unseen = ref(pageContext.assets?.unseenNotifications);
const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false);
const showSettings = ref(false);
const showAlertDialog = ref(false);
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
function search() {
navigate('/search', { q: query.value }, { redirect: true });
}
function setTheme(newTheme) {
theme.value = newTheme;
Cookies.set('theme', newTheme);
events.emit('theme', newTheme);
}
async function logout() {
await del('/session');
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
}
function blurSearch(event) {
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
searchFocused.value = false;
}
}
</script>
<style scoped>
.header {
display: flex;
@@ -540,6 +568,7 @@ function blurSearch(event) {
align-items: center;
padding: .75rem .5rem .75rem .75rem;
font-size: 1.1rem;
cursor: pointer;
.icon {
fill: var(--glass);
@@ -549,7 +578,6 @@ function blurSearch(event) {
&:hover {
color: var(--primary);
cursor: pointer;
&:not(.logout) .icon {
fill: var(--primary);
@@ -561,6 +589,10 @@ function blurSearch(event) {
}
}
.menu-div {
border-bottom: solid 1px var(--glass-weak-30);
}
/*
.favorites .icon {
fill: var(--primary);

View File

@@ -1,3 +1,67 @@
<script setup>
import {
defineEmits,
inject,
onMounted,
ref,
} from 'vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, patch } from '#/src/api.js';
import { formatDate } from '#/utils/format.js';
const emit = defineEmits(['unseen', 'addAlert']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const notifications = ref([]);
const done = ref(true);
async function fetchNotifications() {
done.value = false;
const res = await get(`/users/${user?.id}/notifications`);
notifications.value = res.notifications;
done.value = true;
emit('unseen', res.unseen);
}
async function markSeen(notif) {
if (notif.isSeen || !done.value) {
return;
}
done.value = false;
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function markAllSeen() {
done.value = false;
await patch(`/users/${user?.id}/notifications`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
onMounted(async () => {
await fetchNotifications();
});
</script>
<template>
<div class="menu">
<div class="notifs-header">
@@ -44,7 +108,15 @@
class="notif-body notif-link nolink"
>
<span class="notif-trigger">
<span class="notif-details">
<span
v-if="notif.matchedSceneIds.length > 0"
class="notif-details"
>Scene is released</span>
<span
v-else
class="notif-details"
>
New
<template
@@ -88,71 +160,6 @@
</div>
</template>
<script setup>
import {
ref,
defineEmits,
onMounted,
inject,
} from 'vue';
import { get, patch } from '#/src/api.js';
import { formatDate } from '#/utils/format.js';
import Ellipsis from '#/components/loading/ellipsis.vue';
const pageContext = inject('pageContext');
const user = pageContext.user;
const notifications = ref([]);
const done = ref(true);
const emit = defineEmits(['unseen', 'addAlert']);
async function fetchNotifications() {
done.value = false;
const res = await get(`/users/${user?.id}/notifications`);
notifications.value = res.notifications;
done.value = true;
emit('unseen', res.unseen);
}
async function markSeen(notif) {
if (notif.isSeen || !done.value) {
return;
}
done.value = false;
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function markAllSeen() {
done.value = false;
await patch(`/users/${user?.id}/notifications`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
onMounted(async () => {
await fetchNotifications();
});
</script>
<style scoped>
.notifs {
width: 30rem;

View File

@@ -1,17 +1,6 @@
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<script setup>
import { ref } from 'vue';
const svg = ref(null);
const props = defineProps({
icon: {
type: String,
@@ -27,11 +16,22 @@ const props = defineProps({
},
});
const svg = ref(null);
import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => {
svg.value = raw.default;
});
</script>
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<style>
.icon {
fill: var(--text);

View File

@@ -1,12 +1,3 @@
<template>
<a
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
rel="noopener"
>
<slot />
</a>
</template>
<script setup>
import { inject } from 'vue';
@@ -19,3 +10,12 @@ defineProps({
const pageContext = inject('pageContext');
</script>
<template>
<a
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
rel="noopener"
>
<slot />
</a>
</template>

View File

@@ -1,3 +1,40 @@
<script setup>
import { computed, ref, useId } from 'vue';
import Player from '#/components/video/player.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const playing = ref(false);
const paused = ref(false);
const poster = computed(() => {
if (props.release.poster) {
return getPath(props.release.poster, 'thumbnail');
}
if (props.release.covers?.length > 0) {
return getPath(props.release.covers[0], 'thumbnail');
}
if (props.release.photos?.length > 0) {
return getPath(props.release.photos[0], 'thumbnail');
}
return null;
});
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
const restrictedTooltipId = useId();
</script>
<template>
<div
class="banner-container"
@@ -49,7 +86,7 @@
<Icon
v-if="(release.trailer || release.teaser).isRestricted"
v-tooltip="'Restricted video'"
v-tooltip="{ content: 'Restricted video', ariaId: restrictedTooltipId }"
icon="blocked"
class="restricted"
/>
@@ -102,42 +139,6 @@
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import getPath from '#/src/get-path.js';
import Player from '#/components/video/player.vue';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const playing = ref(false);
const paused = ref(false);
const poster = computed(() => {
if (props.release.poster) {
return getPath(props.release.poster, 'thumbnail');
}
if (props.release.covers?.length > 0) {
return getPath(props.release.covers[0], 'thumbnail');
}
if (props.release.photos?.length > 0) {
return getPath(props.release.photos[0], 'thumbnail');
}
return null;
});
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
</script>
<style scoped>
.banner-container {
background-position: center;

View File

@@ -1,115 +1,20 @@
<template>
<div class="movies-page">
<Filters
v-if="showFilters"
:class="{ loading }"
>
<div class="filter">
<input
v-model="filters.search"
type="search"
placeholder="Search movies"
class="search input"
@search="search"
>
</div>
<YearsFilter
:filters="filters"
:years="aggYears"
@update="updateFilter"
/>
<TagsFilter
:filters="filters"
:tags="aggTags"
@update="updateFilter"
/>
<ChannelsFilter
:filters="filters"
:channels="aggChannels"
@update="updateFilter"
/>
<ActorsFilter
:filters="filters"
:actors="aggActors"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.requireCover"
label="Require box cover"
@change="(checked) => updateFilter('requireCover', checked, true)"
/>
</div>
</Filters>
<div class="movies-container">
<div class="movies-header">
<div class="meta">{{ total }} results</div>
<select
v-model="scope"
class="input"
@change="search({ autoScope: false })"
>
<!-- not selected in SSR without prop -->
<option
v-if="pageStash"
:selected="scope === 'stashed'"
value="stashed"
>Added</option>
<option
v-if="filters.search"
:selected="scope === 'results'"
value="results"
>Relevance</option>
<option value="likes">Popular</option>
<option value="latest">Latest</option>
<option value="upcoming">Upcoming</option>
<option value="new">New</option>
</select>
</div>
<ul class="movies nolist">
<li
v-for="movie in movies"
:key="`movie-${movie.id}`"
>
<MovieTile :movie="movie" />
</li>
</ul>
<Pagination
:total="total"
:page="currentPage"
/>
</div>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { parse } from 'path-to-regexp';
import { inject, ref } from 'vue';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
import events from '#/src/events.js';
import MovieTile from '#/components/movies/tile.vue';
import Filters from '#/components/filters/filters.vue';
import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import Filters from '#/components/filters/filters.vue';
import TagsFilter from '#/components/filters/tags.vue';
import YearsFilter from '#/components/filters/years.vue';
import Checkbox from '#/components/form/checkbox.vue';
import MovieTile from '#/components/movies/tile.vue';
import Pagination from '#/components/pagination/pagination.vue';
import { get } from '#/src/api.js';
import events from '#/src/events.js';
import navigate from '#/src/navigate.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
const pageContext = inject('pageContext');
const { pageProps, routeParams, urlParsed } = pageContext;
@@ -236,6 +141,115 @@ function updateFilter(prop, value, reload = true) {
}
</script>
<template>
<div class="movies-page">
<Filters
v-if="showFilters"
:class="{ loading }"
>
<div class="filter search-container">
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="filters.search"
type="input"
placeholder="Search movies"
class="search input"
@keydown.enter="search"
>
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@click="search"
/>
</div>
<YearsFilter
:filters="filters"
:years="aggYears"
@update="updateFilter"
/>
<TagsFilter
:filters="filters"
:tags="aggTags"
@update="updateFilter"
/>
<ChannelsFilter
:filters="filters"
:channels="aggChannels"
@update="updateFilter"
/>
<ActorsFilter
:filters="filters"
:actors="aggActors"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.requireCover"
label="Require box cover"
@change="(checked) => updateFilter('requireCover', checked, true)"
/>
</div>
</Filters>
<div class="movies-container">
<div class="movies-header">
<div class="meta">{{ total }} results</div>
<select
v-model="scope"
class="input"
@change="search({ autoScope: false })"
>
<!-- not selected in SSR without prop -->
<option
v-if="pageStash"
:selected="scope === 'stashed'"
value="stashed"
>Added</option>
<option
v-if="filters.search"
:selected="scope === 'results'"
value="results"
>Relevance</option>
<option value="likes">Popular</option>
<option value="latest">Latest</option>
<option value="upcoming">Upcoming</option>
<option value="new">New</option>
</select>
</div>
<ul class="movies nolist">
<li
v-for="movie in movies"
:key="`movie-${movie.id}`"
>
<MovieTile :movie="movie" />
</li>
</ul>
<Pagination
:total="total"
:page="currentPage"
/>
</div>
</div>
</template>
<style scoped>
.movies-page {
display: flex;

View File

@@ -1,5 +1,38 @@
<script setup>
import { format } from 'date-fns';
import { ref, useId } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['movie']);
const dropdownId = useId();
const movies = ref([]);
const query = ref(null);
const queryInput = ref(null);
async function search() {
const data = await get('/movies', { q: query.value });
movies.value = data.movies;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template>
<VDropdown
:aria-id="dropdownId"
:disabled="disabled"
class="trigger"
@show="focus"
@@ -36,39 +69,6 @@
</VDropdown>
</template>
<script setup>
import { ref } from 'vue';
import { format } from 'date-fns';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const movies = ref([]);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['movie']);
async function search() {
const data = await get('/movies', { q: query.value });
movies.value = data.movies;
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped>
.trigger {
height: 100%;

View File

@@ -1,3 +1,32 @@
<script setup>
import { format } from 'date-fns';
import { inject, ref } from 'vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
movie: {
type: Object,
default: null,
},
showDetails: {
type: Boolean,
default: true,
},
});
const pageContext = inject('pageContext');
const user = pageContext.user;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const currentYear = new Date().getFullYear();
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
</script>
<template>
<div
class="movie-tile"
@@ -118,35 +147,6 @@
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { format } from 'date-fns';
import getPath from '#/src/get-path.js';
import Heart from '#/components/stashes/heart.vue';
const props = defineProps({
movie: {
type: Object,
default: null,
},
showDetails: {
type: Boolean,
default: true,
},
});
const pageContext = inject('pageContext');
const user = pageContext.user;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const currentYear = new Date().getFullYear();
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
</script>
<style scoped>
.movie-tile {
display: flex;

View File

@@ -1,3 +1,119 @@
<script setup>
import { parse } from 'path-to-regexp';
import { computed, inject } from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
const props = defineProps({
page: {
type: Number,
default: null,
},
total: {
type: Number,
default: null,
},
redirect: {
type: Boolean,
default: true,
},
query: {
type: String,
default: null,
},
includeQuery: {
type: Boolean,
default: true,
},
useMaxMatches: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['navigation']);
const pageContext = inject('pageContext');
const {
routeParams,
urlParsed,
pageProps,
env,
campaigns,
} = pageContext;
const currentPage = computed(() => props.page || Number(routeParams?.page));
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
const total = computed(() => props.total || Number(pageProps.total));
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value - index - 1;
if (page < 1) {
return null;
}
return page;
}).filter(Boolean));
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value + index + 1;
if (page > pageTotal.value) {
return null;
}
return page;
}).filter(Boolean));
function go(page, event) {
if (!props.redirect) {
event.preventDefault();
history.pushState({}, '', event.target.href);
}
emit('navigation', {
href: event.target.href,
page,
});
}
function getPath(page) {
const query = typeof window === 'undefined'
? urlParsed.searchOriginal
: window.location.search;
if (!routeParams.path && props.includeQuery && query) {
return `${pageContext.urlParsed.pathname}${page}${query}`;
}
if (!routeParams.path) {
return `${pageContext.urlParsed.pathname}${page}`;
}
const path = parse(routeParams.path)
.map((segment) => {
if (segment.name === 'page') {
return `/${page}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
})
.join('');
if (props.includeQuery && query) {
return `${path}${query}`;
}
return path;
}
</script>
<template>
<div class="pagination-container">
<Campaign
@@ -91,122 +207,6 @@
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
import { parse } from 'path-to-regexp';
import Campaign from '#/components/campaigns/campaign.vue';
const props = defineProps({
page: {
type: Number,
default: null,
},
total: {
type: Number,
default: null,
},
redirect: {
type: Boolean,
default: true,
},
query: {
type: String,
default: null,
},
includeQuery: {
type: Boolean,
default: true,
},
useMaxMatches: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['navigation']);
const pageContext = inject('pageContext');
const {
routeParams,
urlParsed,
pageProps,
env,
campaigns,
} = pageContext;
const currentPage = computed(() => props.page || Number(routeParams?.page));
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
const total = computed(() => props.total || Number(pageProps.total));
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value - index - 1;
if (page < 1) {
return null;
}
return page;
}).filter(Boolean));
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
const page = currentPage.value + index + 1;
if (page > pageTotal.value) {
return null;
}
return page;
}).filter(Boolean));
function go(page, event) {
if (!props.redirect) {
event.preventDefault();
history.pushState({}, '', event.target.href); // eslint-disable-line no-restricted-globals
}
emit('navigation', {
href: event.target.href,
page,
});
}
function getPath(page) {
const query = typeof window === 'undefined'
? urlParsed.searchOriginal
: window.location.search;
if (!routeParams.path && props.includeQuery && query) {
return `${pageContext.urlParsed.pathname}${page}${query}`;
}
if (!routeParams.path) {
return `${pageContext.urlParsed.pathname}${page}`;
}
const path = parse(routeParams.path)
.map((segment) => {
if (segment.name === 'page') {
return `/${page}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
})
.join('');
if (props.includeQuery && query) {
return `${path}${query}`;
}
return path;
}
</script>
<style scoped>
.pagination-container {
display: flex;

View File

@@ -1,3 +1,28 @@
<script setup>
import { computed } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate, formatDuration } from '#/utils/format.js';
const props = defineProps({
chapters: {
type: Array,
default: () => [],
},
});
const lastChapter = props.chapters.at(-1);
const duration = lastChapter.time + lastChapter.duration;
const timeline = computed(() => {
if (props.chapters.every((chapter) => chapter.time)) {
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
}
return null;
});
</script>
<template>
<div
v-if="timeline"
@@ -43,19 +68,19 @@
<span class="chapter-details">
<span
v-if="typeof chapter.time === 'number'"
v-tooltip="'Time in video'"
v-tooltip="{ content: 'Time in video', ariaId: `chapter-time-${chapter.id}` }"
class="chapter-time"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span
v-if="chapter.duration"
v-tooltip="'Duration'"
v-tooltip="{ content: 'Duration', ariaId: `chapter-duration-${chapter.id}` }"
class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time
v-if="chapter.date"
v-tooltip="formatDate(chapter.date, 'yyyy-MM-dd hh:mm')"
v-tooltip="{ content: formatDate(chapter.date, 'yyyy-MM-dd hh:mm'), ariaId: `chapter-date-${chapter.id}` }"
:datetime="chapter.date"
class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time>
@@ -90,31 +115,6 @@
</ul>
</template>
<script setup>
import { computed } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDuration, formatDate } from '#/utils/format.js';
const props = defineProps({
chapters: {
type: Array,
default: () => [],
},
});
const lastChapter = props.chapters.at(-1);
const duration = lastChapter.time + lastChapter.duration;
const timeline = computed(() => {
if (props.chapters.every((chapter) => chapter.time)) {
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
}
return null;
});
</script>
<style scoped>
.chapters {
display: grid;

View File

@@ -1,3 +1,18 @@
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<template>
<div class="meta">
<div class="channel">
@@ -44,21 +59,6 @@
</div>
</template>
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.meta {
display: flex;

View File

@@ -1,3 +1,195 @@
<script setup>
import Cookies from 'js-cookie';
import { parse } from 'path-to-regexp';
import {
computed,
inject,
ref,
} from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import Filters from '#/components/filters/filters.vue';
import TagsFilter from '#/components/filters/tags.vue';
import YearsFilter from '#/components/filters/years.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import Pagination from '#/components/pagination/pagination.vue';
import SceneTile from '#/components/scenes/tile.vue';
import { get } from '#/src/api.js';
// import events from '#/src/events.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import navigate from '#/src/navigate.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
const props = defineProps({
showFilters: {
type: Boolean,
default: true,
},
showMeta: {
type: Boolean,
default: true,
},
showScopeTabs: {
type: Boolean,
default: false,
},
defaultScope: {
type: String,
default: 'latest',
},
});
const {
pageProps,
routeParams,
urlParsed,
campaigns,
env,
} = inject('pageContext');
const {
actor: pageActor,
tag: pageTag,
entity: pageEntity,
stash: pageStash,
} = pageProps;
const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page));
const scope = ref(routeParams.scope || props.defaultScope);
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
const loading = ref(false);
const scenesContainer = ref(null);
const view = ref(env.scenesView || 'grid');
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
const filters = ref({
search: urlParsed.search.q,
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
entity: queryEntity,
actors: queryActors,
});
const sceneCampaign = campaigns?.scenes;
const campaignIndex = campaigns?.index;
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
function getPath(targetScope, preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'scope') {
return `${segment.prefix}${targetScope}`;
}
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (filters.value.search) {
scope.value = 'results';
}
if (!filters.value.search && scope.value === 'results') {
scope.value = 'latest';
}
}
const query = {
q: filters.value.search || undefined,
};
const entity = filters.value.entity || pageEntity;
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
loading.value = true;
navigate(getPath(scope.value, false), {
...query,
years: filters.value.years.join(',') || undefined,
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
tags: filters.value.tags.join(',') || undefined,
at: (filters.value.tags.length > 0 && filters.value.onlyActorTags) || undefined,
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
}, { redirect: false });
const res = await get('/scenes', {
...query,
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
at: !!filters.value.onlyActorTags,
stashId: pageStash?.id,
e: entitySlug,
scope: scope.value,
page: currentPage.value, // client uses param rather than query pagination
});
scenes.value = res.scenes;
aggYears.value = res.aggYears;
aggActors.value = res.aggActors;
aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels;
total.value = res.total;
loading.value = false;
// events.emit('scrollUp');
scenesContainer.value?.scrollIntoView(true);
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
function setView(newView) {
view.value = newView;
Cookies.set('scenesView', newView);
}
</script>
<template>
<div
class="scenes-page"
@@ -12,12 +204,19 @@
<!-- onsearch not compatible with FF and Safari -->
<input
v-model="filters.search"
type="search"
type="input"
placeholder="Search scenes"
class="search input"
@search="search"
@keydown.enter="search"
>
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon
icon="search"
class="search-button"
@@ -200,195 +399,6 @@
</div>
</template>
<script setup>
import {
ref,
computed,
inject,
} from 'vue';
import Cookies from 'js-cookie';
import { parse } from 'path-to-regexp';
import navigate from '#/src/navigate.js';
import { get } from '#/src/api.js';
// import events from '#/src/events.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
import Filters from '#/components/filters/filters.vue';
import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import SceneTile from '#/components/scenes/tile.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import Pagination from '#/components/pagination/pagination.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
const props = defineProps({
showFilters: {
type: Boolean,
default: true,
},
showMeta: {
type: Boolean,
default: true,
},
showScopeTabs: {
type: Boolean,
default: false,
},
defaultScope: {
type: String,
default: 'latest',
},
});
const {
pageProps,
routeParams,
urlParsed,
campaigns,
env,
} = inject('pageContext');
const {
actor: pageActor,
tag: pageTag,
entity: pageEntity,
stash: pageStash,
} = pageProps;
const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page));
const scope = ref(routeParams.scope || props.defaultScope);
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
const loading = ref(false);
const scenesContainer = ref(null);
const view = ref(env.scenesView || 'grid');
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
const filters = ref({
search: urlParsed.search.q,
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
entity: queryEntity,
actors: queryActors,
});
const sceneCampaign = campaigns?.scenes;
const campaignIndex = campaigns?.index;
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
function getPath(targetScope, preserveQuery) {
const path = parse(routeParams.path).map((segment) => {
if (segment.name === 'scope') {
return `${segment.prefix}${targetScope}`;
}
if (segment.name === 'page') {
return `${segment.prefix}${1}`;
}
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
}).join('');
if (preserveQuery && urlParsed.searchOriginal) {
return `${path}${urlParsed.searchOriginal}`;
}
return path;
}
async function search(options = {}) {
if (options.resetPage !== false) {
currentPage.value = 1;
}
if (options.autoScope !== false) {
if (filters.value.search) {
scope.value = 'results';
}
if (!filters.value.search && scope.value === 'results') {
scope.value = 'latest';
}
}
const query = {
q: filters.value.search || undefined,
};
const entity = filters.value.entity || pageEntity;
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
loading.value = true;
navigate(getPath(scope.value, false), {
...query,
years: filters.value.years.join(',') || undefined,
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
tags: filters.value.tags.join(',') || undefined,
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
}, { redirect: false });
const res = await get('/scenes', {
...query,
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
stashId: pageStash?.id,
e: entitySlug,
scope: scope.value,
page: currentPage.value, // client uses param rather than query pagination
});
scenes.value = res.scenes;
aggYears.value = res.aggYears;
aggActors.value = res.aggActors;
aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels;
total.value = res.total;
loading.value = false;
// events.emit('scrollUp');
scenesContainer.value?.scrollIntoView(true);
}
function updateFilter(prop, value, reload = true) {
filters.value[prop] = value;
if (reload) {
search();
}
}
function setView(newView) {
view.value = newView;
Cookies.set('scenesView', newView);
}
</script>
<style scoped>
.scenes-page {
display: flex;

View File

@@ -1,3 +1,158 @@
<script setup>
import Cookies from 'js-cookie';
import { inject, onMounted, ref } from 'vue';
import { parse } from 'yaml';
import defaultTemplate from '#/assets/summary.yaml?raw';
import { del, get, post } from '#/src/api.js';
// import slugify from '#/utils/slugify.js';
import events from '#/src/events.js';
import processSummaryTemplate from '#/utils/process-summary-template.js';
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const cookies = Cookies.withConverter({
write: (value) => value,
});
const templates = ref(pageContext.assets.templates);
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
const template = ref(initialTemplate?.template || defaultTemplate);
const hasError = ref(false);
const input = ref(null);
const changed = ref(false);
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
function getSummary() {
return processSummaryTemplate(template.value, props.release, pageContext.env);
}
const summary = ref(getSummary());
function selectTemplate(templateId) {
selectedTemplate.value = templateId;
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
template.value = nextTemplate.template;
templateName.value = nextTemplate.name;
summary.value = getSummary();
cookies.set('selectedTemplate', String(templateId));
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
}
function update() {
hasError.value = false;
changed.value = true;
try {
summary.value = getSummary();
}
catch (error) {
hasError.value = true;
}
}
async function save(explicit) {
if (!explicit && !changed.value) {
return;
}
try {
parse(template.value);
const createdTemplate = await post('/templates', {
name: templateName.value,
template: template.value,
}, {
successFeedback: `Saved summary template '${templateName.value}'`,
errorFeedback: `Failed to save summary template '${templateName.value}'`,
});
changed.value = false;
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(createdTemplate.id);
}
catch (error) {
events.emit('feedback', {
type: 'error',
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
});
}
}
function add() {
selectedTemplate.value = null;
template.value = '';
templateName.value = `custom_${Date.now()}`;
summary.value = '';
input.value.focus();
}
async function remove() {
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-alert
await del(`/templates/${selectedTemplate.value}`, {
undoFeedback: `Deleted summary template '${templateName.value}'`,
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
});
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(templates.value.at(-1)?.id);
}
}
function copy() {
navigator.clipboard.writeText(summary.value);
events.emit('feedback', {
type: 'success',
message: 'Summary copied to clipboard',
});
}
function reset() {
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-alert
template.value = defaultTemplate;
update();
events.emit('feedback', {
type: 'undo',
message: 'Reset summary template',
});
}
}
function indent() {
// YAML does not support tabs
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
}
onMounted(() => {
window.addEventListener('beforeunload', (event) => {
if (changed.value) {
event.preventDefault();
}
});
});
</script>
<template>
<div class="editor">
<div class="editor-header">
@@ -89,159 +244,6 @@
</div>
</template>
<script setup>
import { ref, inject, onMounted } from 'vue';
import { parse } from 'yaml';
import Cookies from 'js-cookie';
// import slugify from '#/utils/slugify.js';
import events from '#/src/events.js';
import { get, post, del } from '#/src/api.js';
import processSummaryTemplate from '#/utils/process-summary-template.js';
import defaultTemplate from '#/assets/summary.yaml?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext');
const cookies = Cookies.withConverter({
write: (value) => value,
});
const props = defineProps({
release: {
type: Object,
default: null,
},
});
const templates = ref(pageContext.assets.templates);
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
const template = ref(initialTemplate?.template || defaultTemplate);
const hasError = ref(false);
const input = ref(null);
const changed = ref(false);
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
function getSummary() {
return processSummaryTemplate(template.value, props.release, pageContext.env);
}
const summary = ref(getSummary());
function selectTemplate(templateId) {
selectedTemplate.value = templateId;
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
template.value = nextTemplate.template;
templateName.value = nextTemplate.name;
summary.value = getSummary();
cookies.set('selectedTemplate', String(templateId));
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
}
function update() {
hasError.value = false;
changed.value = true;
try {
summary.value = getSummary();
} catch (error) {
hasError.value = true;
}
}
async function save(explicit) {
if (!explicit && !changed.value) {
return;
}
try {
parse(template.value);
const createdTemplate = await post('/templates', {
name: templateName.value,
template: template.value,
}, {
successFeedback: `Saved summary template '${templateName.value}'`,
errorFeedback: `Failed to save summary template '${templateName.value}'`,
});
changed.value = false;
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(createdTemplate.id);
} catch (error) {
events.emit('feedback', {
type: 'error',
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
});
}
}
function add() {
selectedTemplate.value = null;
template.value = '';
templateName.value = `custom_${Date.now()}`;
summary.value = '';
input.value.focus();
}
async function remove() {
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-restricted-globals, no-alert
await del(`/templates/${selectedTemplate.value}`, {
undoFeedback: `Deleted summary template '${templateName.value}'`,
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
});
templates.value = await get(`/users/${pageContext.user.id}/templates`);
selectTemplate(templates.value.at(-1)?.id);
}
}
function copy() {
navigator.clipboard.writeText(summary.value);
events.emit('feedback', {
type: 'success',
message: 'Summary copied to clipboard',
});
}
function reset() {
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-restricted-globals, no-alert
template.value = defaultTemplate;
update();
events.emit('feedback', {
type: 'undo',
message: 'Reset summary template',
});
}
}
function indent() {
// YAML does not support tabs
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
}
onMounted(() => {
window.addEventListener('beforeunload', (event) => {
if (changed.value) {
event.preventDefault();
}
});
});
</script>
<style scoped>
.editor {
display: flex;

View File

@@ -1,3 +1,30 @@
<script setup>
import { inject, ref } from 'vue';
import Meta from '#/components/scenes/meta.vue';
import Heart from '#/components/stashes/heart.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
scene: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
</script>
<template>
<div
class="tile"
@@ -115,33 +142,6 @@
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import Meta from '#/components/scenes/meta.vue';
import Heart from '#/components/stashes/heart.vue';
const props = defineProps({
scene: {
type: Object,
default: null,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
</script>
<style scoped>
.tile {
display: flex;
@@ -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;
}

View File

@@ -1,3 +1,14 @@
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
serie: {
type: Object,
default: null,
},
});
</script>
<template>
<div class="serie-tile">
<a
@@ -22,17 +33,6 @@
</div>
</template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
serie: {
type: Object,
default: null,
},
});
</script>
<style scoped>
.serie-tile {
display: flex;

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>

View File

@@ -0,0 +1,29 @@
<script setup>
import { ref } from 'vue';
// import Checkbox from '#/components/form/checkbox.vue';
</script>
<template>
<div class="dialog-section">
<div class="setting">
Combine e-mail alerts
<select
class="input"
>
<option>Never</option>
<option>Daily</option>
<option>Weekly</option>
<option>Monthly</option>
</select>
</div>
</div>
</template>
<style scoped>
.setting {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -1,90 +1,141 @@
<script setup>
// 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 Filters from '#/components/settings/filters.vue';
import Notifications from '#/components/settings/notifications.vue';
defineProps({
showNavigation: {
type: Boolean,
default: false,
},
});
const pageContext = inject('pageContext');
const { user } = pageContext;
const activeSection = ref('filters');
</script>
<template>
<Dialog title="Settings">
<div class="dialog-body">
<div class="dialog-section">
<h3 class="dialog-heading">Filter</h3>
<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"
<Dialog
title="Filters"
class="settings-dialog"
>
<label class="tag noselect">
<Checkbox
:value="slug"
:checked="checkedTags.has(slug)"
class="minus"
@change="(checked) => toggleTag(slug, checked)"
/>{{ label }}
</label>
<div class="dialog-body">
<ul
v-if="!activeSection"
class="sections"
>
<li
class="section"
@click="activeSection = 'filters'"
>
<Icon icon="filter" />
Filters
</li>
<li
v-if="user"
class="section"
@click="activeSection = 'notifications'"
>
<Icon icon="bell2" />
Notifications
</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>
<script setup>
import { ref } from 'vue';
import Cookies from 'js-cookie';
import Dialog from '#/components/dialog/dialog.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>
<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 {
.sections {
padding: 0;
margin: 0;
}
.section {
display: flex;
padding: .5rem 0;
text-transform: capitalize;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: .25rem;
font-size: 1.1rem;
.check-container {
margin-right: 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>

View File

@@ -1,3 +1,14 @@
<script setup>
import { computed, inject } from 'vue';
import logo from '#/assets/img/logo.svg?raw';
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<template>
<div
class="sidebar-container"
@@ -85,17 +96,6 @@
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
import logo from '#/assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const emit = defineEmits(['sidebar']);
const pageContext = inject('pageContext');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
</script>
<style scoped>
.sidebar-container {
width: 100%;

View File

@@ -1,3 +1,30 @@
<script setup>
import { ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import { post } from '#/src/api.js';
const emit = defineEmits(['created', 'close']);
const stashName = ref(null);
const stashNameInput = ref(null);
async function createStash() {
const newStash = await post('/stashes', {
name: stashName.value,
public: false,
}, {
successFeedback: `Stash '${stashName.value}' created`,
errorFeedback: `Failed to create stash '${stashName.value}'`,
appendErrorMessage: true,
});
emit('created', newStash);
stashName.value = null;
}
</script>
<template>
<Dialog
title="New stash"
@@ -23,33 +50,6 @@
</Dialog>
</template>
<script setup>
import { ref } from 'vue';
import { post } from '#/src/api.js';
import Dialog from '#/components/dialog/dialog.vue';
const emit = defineEmits(['created', 'close']);
const stashName = ref(null);
const stashNameInput = ref(null);
async function createStash() {
const newStash = await post('/stashes', {
name: stashName.value,
public: false,
}, {
successFeedback: `Stash '${stashName.value}' created`,
errorFeedback: `Failed to create stash '${stashName.value}'`,
appendErrorMessage: true,
});
emit('created', newStash);
stashName.value = null;
}
</script>
<style scoped>
.dialog-body {
padding: 1rem;

View File

@@ -1,16 +1,222 @@
<script setup>
import { computed, inject, ref, useId } from 'vue';
import AlertDialog from '#/components/alerts/create.vue';
import Icon from '#/components/icon/icon.vue';
import StashDialog from '#/components/stashes/create.vue';
import StashMenu from '#/components/stashes/menu.vue';
import { del, get, post } from '#/src/api.js';
import ellipsis from '#/utils/ellipsis.js';
const props = defineProps({
domain: {
type: String,
default: 'scenes',
},
item: {
type: Object,
default: null,
},
showSecondary: {
type: Boolean,
default: true,
},
showAlert: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['stashed', 'unstashed']);
const pageContext = inject('pageContext');
const pageStash = pageContext.pageProps.stash;
const user = pageContext.user;
const stashes = ref(pageContext.assets?.stashes);
const primaryStash = pageContext.assets?.primaryStash;
const itemStashes = ref(props.item.stashes);
const itemAlerts = ref(props.item.alerts);
const itemAlerted = ref(props.item.alerts?.only.length > 0);
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
const done = ref(true);
const stashMenuDropdownId = useId();
const stashTogglesDropdownId = useId();
const showStashes = ref(false);
const showStashDialog = ref(false);
const showAlertDialog = ref(false);
const feedbackCutoff = 20;
const itemKeys = {
scenes: 'sceneId',
actors: 'actorId',
movies: 'movieId',
};
async function stashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.concat(stash);
try {
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
});
emit('stashed', stash);
}
catch (error) {
itemStashes.value = stashState;
}
finally {
done.value = true;
}
}
async function unstashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
try {
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
});
emit('unstashed', stash);
}
catch (error) {
itemStashes.value = stashState;
}
finally {
done.value = true;
}
}
async function alertItem() {
if (!done.value) {
return;
}
const alertLabel = props.item.title || props.item.name;
done.value = false;
itemAlerted.value = true;
try {
const newAlert = await post('/alerts', {
...(props.domain === 'actors' && { actors: [props.item.id] }),
...(props.domain === 'tags' && { tags: [props.item.id] }),
...(props.domain === 'entities' && { entities: [props.item.id] }),
...(props.domain === 'scenes' && { scenes: [props.item.id] }),
notify: true,
email: false,
preset: true,
}, {
successFeedback: `Alert set for '${alertLabel}'`,
errorFeedback: `Failed to set alert for '${alertLabel}'`,
appendErrorMessage: true,
});
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
}
catch (error) {
itemAlerted.value = false;
}
finally {
done.value = true;
}
}
async function removeAlert() {
if (done.value === false) {
return;
}
const alertLabel = props.item.title || props.item.name;
const alertIds = itemAlerts.value.only;
done.value = false;
itemAlerted.value = false;
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
try {
await del(`/alerts/${alertIds.join(',')}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
}
catch (error) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
}
finally {
done.value = true;
}
}
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];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
}
else {
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
}
}
function toggleShowStashes(state) {
if (props.showSecondary) {
return;
}
if (state) {
showStashes.value = state;
return;
}
showStashes.value = !showStashes.value;
}
async function reloadStashes(newStash) {
stashes.value = await get(`/users/${user.id}/stashes`);
await stashItem(newStash);
}
</script>
<template>
<div
v-if="user"
class="bookmarks"
>
<div
v-if="showSecondary && !!itemAlerts"
v-if="showSecondary && showAlert && !!itemAlerts"
class="alert-trigger"
>
<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"
@@ -18,8 +224,8 @@
<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"
@@ -27,8 +233,8 @@
<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"
@@ -37,6 +243,7 @@
<VDropdown
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes"
@hide="showStashes = false"
>
@@ -60,6 +267,7 @@
<VDropdown
v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)"
:aria-id="stashTogglesDropdownId"
:triggers="[]"
:shown="showStashes"
:disabled="showSecondary"
@@ -70,7 +278,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)"
:icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'"
class="heart favorited noselect"
@click.native.stop="unstashItem(pageStash)"
@click.stop="unstashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
@@ -78,7 +286,7 @@
v-else
:icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'"
class="heart noselect"
@click.native.stop="stashItem(pageStash)"
@click.stop="stashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
</template>
@@ -88,7 +296,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)"
icon="heart7"
class="heart favorited noselect"
@click.native.stop="unstashItem(primaryStash)"
@click.stop="unstashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
@@ -96,7 +304,7 @@
v-else
icon="heart8"
class="heart noselect"
@click.native.stop="stashItem(primaryStash)"
@click.stop="stashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)"
/>
</template>
@@ -127,196 +335,6 @@
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { get, post, del } from '#/src/api.js';
import ellipsis from '#/utils/ellipsis.js';
import Icon from '#/components/icon/icon.vue';
import StashMenu from '#/components/stashes/menu.vue';
import StashDialog from '#/components/stashes/create.vue';
import AlertDialog from '#/components/alerts/create.vue';
const props = defineProps({
domain: {
type: String,
default: 'scenes',
},
item: {
type: Object,
default: null,
},
showSecondary: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['stashed', 'unstashed']);
const pageContext = inject('pageContext');
const pageStash = pageContext.pageProps.stash;
const user = pageContext.user;
const stashes = ref(pageContext.assets?.stashes);
const primaryStash = pageContext.assets?.primaryStash;
const itemStashes = ref(props.item.stashes);
const itemAlerts = ref(props.item.alerts);
const itemAlerted = ref(props.item.alerts?.only.length > 0);
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
const done = ref(true);
const showStashes = ref(false);
const showStashDialog = ref(false);
const showAlertDialog = ref(false);
const feedbackCutoff = 20;
const itemKeys = {
scenes: 'sceneId',
actors: 'actorId',
movies: 'movieId',
};
async function stashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.concat(stash);
try {
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
});
emit('stashed', stash);
} catch (error) {
itemStashes.value = stashState;
} finally {
done.value = true;
}
}
async function unstashItem(stash) {
if (!done.value) {
return;
}
const stashState = itemStashes.value;
done.value = false;
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
try {
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
});
emit('unstashed', stash);
} catch (error) {
itemStashes.value = stashState;
} finally {
done.value = true;
}
}
async function alertItem() {
if (!done.value) {
return;
}
const alertLabel = props.item.title || props.item.name;
done.value = false;
itemAlerted.value = true;
try {
const newAlert = await post('/alerts', {
...(props.domain === 'actors' && { actors: [props.item.id] }),
...(props.domain === 'tags' && { tags: [props.item.id] }),
...(props.domain === 'entities' && { entities: [props.item.id] }),
notify: true,
email: false,
preset: true,
}, {
successFeedback: `Alert set for '${alertLabel}'`,
errorFeedback: `Failed to set alert for '${alertLabel}'`,
appendErrorMessage: true,
});
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
} catch (error) {
itemAlerted.value = false;
} finally {
done.value = true;
}
}
async function removeAlert() {
if (done.value === false) {
return;
}
const alertLabel = props.item.title || props.item.name;
const alertIds = itemAlerts.value.only;
done.value = false;
itemAlerted.value = false;
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
try {
await del(`/alerts/${alertIds.join(',')}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
} catch (error) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
} finally {
done.value = true;
}
}
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];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true;
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
} else {
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
}
}
function toggleShowStashes(state) {
if (props.showSecondary) {
return;
}
if (state) {
showStashes.value = state;
return;
}
showStashes.value = !showStashes.value;
}
async function reloadStashes(newStash) {
stashes.value = await get(`/users/${user.id}/stashes`);
await stashItem(newStash);
}
</script>
<style scoped>
.bookmarks {
display: flex;

View File

@@ -1,3 +1,20 @@
<script setup>
import Checkbox from '#/components/form/checkbox.vue';
defineProps({
stashes: {
type: Array,
default: () => [],
},
itemStashes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['stash', 'unstash', 'create']);
</script>
<template>
<ul class="stash-menu nolist noselect">
<li
@@ -26,23 +43,6 @@
</ul>
</template>
<script setup>
import Checkbox from '#/components/form/checkbox.vue';
defineProps({
stashes: {
type: Array,
default: () => [],
},
itemStashes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['stash', 'unstash', 'create']);
</script>
<style scoped>
.menu-item {
display: flex;

View File

@@ -1,3 +1,15 @@
<script setup>
import { inject } from 'vue';
import Domains from '#/components/domains/domains.vue';
const pageContext = inject('pageContext');
const { routeParams } = pageContext;
const domain = routeParams.domain;
const stash = pageContext.pageProps.stash;
</script>
<template>
<div class="stash">
<div class="header">
@@ -44,18 +56,6 @@
</div>
</template>
<script setup>
import { inject } from 'vue';
import Domains from '#/components/domains/domains.vue';
const pageContext = inject('pageContext');
const { routeParams } = pageContext;
const domain = routeParams.domain;
const stash = pageContext.pageProps.stash;
</script>
<style scoped>
.stash {
display: flex;

View File

@@ -1,8 +1,28 @@
<script setup>
import { inject, ref } from 'vue';
import StashDialog from '#/components/stashes/create.vue';
import StashTile from '#/components/stashes/tile.vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashes = ref(pageContext.pageProps.stashes);
const profile = ref(pageContext.pageProps.profile);
const showStashDialog = ref(false);
async function reloadStashes() {
// profile.value = await get(`/users/${profile.value.id}`);
stashes.value = await get(`/users/${profile.value.id}/stashes`);
}
</script>
<template>
<section class="profile-section">
<div class="section-header">
<h3 class="heading">Stashes</h3>
<button
v-if="profile.id === user?.id"
class="button"
@@ -34,29 +54,13 @@
</section>
</template>
<script setup>
import { ref, inject } from 'vue';
import StashTile from '#/components/stashes/tile.vue';
import StashDialog from '#/components/stashes/create.vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashes = ref(pageContext.pageProps.stashes);
const profile = ref(pageContext.pageProps.profile);
const showStashDialog = ref(false);
async function reloadStashes() {
// profile.value = await get(`/users/${profile.value.id}`);
stashes.value = await get(`/users/${profile.value.id}/stashes`);
}
</script>
<style scoped>
.profile-section .section-header {
display: flex;
justify-content: center;
margin-bottom: .5rem;
}
.stashes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));

View File

@@ -1,3 +1,98 @@
<script setup>
import { inject, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import { del, patch } from '#/src/api.js';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
const props = defineProps({
stash: {
type: Object,
default: null,
},
profile: {
type: Object,
default: null,
},
});
const emit = defineEmits(['reload']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashName = ref(props.stash.name);
const stashNameInput = ref(null);
const showRenameDialog = ref(false);
const done = ref(true);
const privateTooltipId = useId();
const menuDropdownId = useId();
const domainCounts = {
scenes: props.stash.stashedScenes,
actors: props.stash.stashedActors,
movies: props.stash.stashedMovies,
};
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
async function setPublic(isPublic) {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
errorFeedback: 'Failed to update stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function remove() {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-alert
return;
}
done.value = false;
await del(`/stashes/${props.stash.id}`, {
undoFeedback: `Stash '${props.stash.name}' removed`,
errorFeedback: 'Failed to remove stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function rename() {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
successFeedback: `Stash renamed to ${stashName.value}`,
errorFeedback: 'Failed to rename stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
showRenameDialog.value = false;
}
</script>
<template>
<div class="stash">
<div class="stash-header">
@@ -16,12 +111,12 @@
<Icon
v-if="!stash.isPublic"
v-tooltip="'This stash is private'"
v-tooltip="{ content: 'This stash is private', ariaId: privateTooltipId }"
icon="eye-blocked"
class="private noselect"
/>
<VDropdown>
<VDropdown :aria-id="menuDropdownId">
<Icon
v-if="profile.id === user?.id"
icon="more2"
@@ -122,99 +217,6 @@
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { del, patch } from '#/src/api.js';
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
import Dialog from '#/components/dialog/dialog.vue';
const props = defineProps({
stash: {
type: Object,
default: null,
},
profile: {
type: Object,
default: null,
},
});
const emit = defineEmits(['reload']);
const pageContext = inject('pageContext');
const user = pageContext.user;
const stashName = ref(props.stash.name);
const stashNameInput = ref(null);
const showRenameDialog = ref(false);
const done = ref(true);
const domainCounts = {
scenes: props.stash.stashedScenes,
actors: props.stash.stashedActors,
movies: props.stash.stashedMovies,
};
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
async function setPublic(isPublic) {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
errorFeedback: 'Failed to update stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function remove() {
if (done.value === false) {
return;
}
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-restricted-globals, no-alert
return;
}
done.value = false;
await del(`/stashes/${props.stash.id}`, {
undoFeedback: `Stash '${props.stash.name}' removed`,
errorFeedback: 'Failed to remove stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
}
async function rename() {
if (done.value === false) {
return;
}
done.value = false;
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
successFeedback: `Stash renamed to ${stashName.value}`,
errorFeedback: 'Failed to rename stash',
appendErrorMessage: true,
}).finally(() => { done.value = true; });
emit('reload');
showRenameDialog.value = false;
}
</script>
<style scoped>
.stash {
width: 100%;

View File

@@ -1,7 +1,24 @@
<script setup>
import { useId } from 'vue';
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
const entityTooltipId = useId();
</script>
<template>
<a
v-if="photo?.entity"
v-tooltip="photo.entity.name"
v-tooltip="{ content: photo.entity.name, ariaId: entityTooltipId }"
:to="`/${photo.entity.type}/${photo.entity.slug}`"
>
<img
@@ -30,19 +47,6 @@
</a>
</template>
<script setup>
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.album-logo {
max-height: .75rem;

View File

@@ -1,3 +1,41 @@
<script setup>
import { computed, inject } from 'vue';
import Campaign from '#/components/campaigns/campaign.vue';
import Logo from '#/components/tags/logo.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
tag: {
type: Object,
default: null,
},
});
const emit = defineEmits(['load', 'campaign']);
const { campaigns } = inject('pageContext');
const photos = computed(() => {
/* sfw not currently implemented
if (props.tag.poster && this.$store.state.ui.sfw) {
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return props.tag.photos.map((photo) => photo.sfw);
}
*/
if (props.tag.poster) {
return [props.tag.poster].concat(props.tag.photos);
}
return props.tag.photos;
});
</script>
<template>
<div class="photos nobar">
<Campaign
@@ -40,44 +78,6 @@
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
import Logo from '#/components/tags/logo.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
tag: {
type: Object,
default: null,
},
});
const emit = defineEmits(['load', 'campaign']);
const { campaigns } = inject('pageContext');
const photos = computed(() => {
/* sfw not currently implemented
if (props.tag.poster && this.$store.state.ui.sfw) {
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return props.tag.photos.map((photo) => photo.sfw);
}
*/
if (props.tag.poster) {
return [props.tag.poster].concat(props.tag.photos);
}
return props.tag.photos;
});
</script>
<style scoped>
.photos {
display: flex;

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