Compare commits

...

123 Commits

Author SHA1 Message Date
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
215 changed files with 20922 additions and 22489 deletions

View File

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

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" /> <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> <title>traxxx - Consent</title>
<style> <style>
:root { :root {
@@ -156,6 +159,12 @@
text-decoration: underline; text-decoration: underline;
} }
.rta {
position: fixed;
bottom: .5rem;
right: .5rem;
}
@media(max-width: 800px) { @media(max-width: 800px) {
.heading { .heading {
font-size: 1.25rem; font-size: 1.25rem;
@@ -219,5 +228,12 @@
</a> </a>
</div> </div>
</div> </div>
<img
src="/img/rta.gif"
alt="RTA Restricted To Adults"
title="RTA Restricted To Adults"
class="rta"
>
</body> </body>
</html> </html>

View File

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

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

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

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> <template>
<div class="page"> <div class="page">
<Filters :results="total"> <Filters :results="total">
@@ -5,12 +134,19 @@
<!-- onsearch not compatible with FF and Safari --> <!-- onsearch not compatible with FF and Safari -->
<input <input
v-model="q" v-model="q"
type="search" type="input"
placeholder="Search actors" placeholder="Search actors"
class="input search" class="input search"
@search="search" @keydown.enter="search"
> >
<Icon
v-if="q"
icon="cross2"
class="search-button"
@click="q = ''; search()"
/>
<Icon <Icon
icon="search" icon="search"
class="search-button" class="search-button"
@@ -104,135 +240,6 @@
</div> </div>
</template> </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> <style>
.gender-button { .gender-button {
&.selected .gender .icon { &.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> <template>
<div <div
class="profile" class="profile"
@@ -20,6 +116,15 @@
</div> </div>
<ul class="bio nolist"> <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 <li
v-if="actor.dateOfBirth" v-if="actor.dateOfBirth"
class="bio-item" class="bio-item"
@@ -44,7 +149,7 @@
<dfn class="bio-label"><Icon icon="cake" />Age</dfn> <dfn class="bio-label"><Icon icon="cake" />Age</dfn>
<span <span
:title="'Exact date of birth or age unknown'" title="Exact date of birth or age unknown"
class="birthdate" class="birthdate"
>&gt; {{ actor.age }}</span> >&gt; {{ actor.age }}</span>
</li> </li>
@@ -80,10 +185,17 @@
<span <span
v-if="actor.origin.city" v-if="actor.origin.city"
class="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'))" v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
class="state" class="state"
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span> >
{{ actor.origin.city
? [',', actor.origin.state].join(' ')
: actor.origin.state
}}
</span>
<span <span
v-if="actor.origin.country" v-if="actor.origin.country"
@@ -105,7 +217,7 @@
class="bio-item residence" class="bio-item residence"
:class="{ hideable: !!actor.origin }" :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>
<span <span
@@ -146,7 +258,7 @@
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }} {{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<Icon <Icon
v-if="actor.naturalBoobs === false || actor.naturalButt === false" 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" icon="magic-wand2"
class="enhanced" class="enhanced"
/> />
@@ -168,7 +280,7 @@
actor.boobsVolume && `${actor.boobsVolume}cc`, actor.boobsVolume && `${actor.boobsVolume}cc`,
augmentationMap[actor.boobsPlacement] || actor.boobsPlacement, augmentationMap[actor.boobsPlacement] || actor.boobsPlacement,
augmentationMap[actor.boobsIncision] || actor.boobsIncision, augmentationMap[actor.boobsIncision] || actor.boobsIncision,
augmentationMap[actor.boobsImplant] || actor.boobsImplant augmentationMap[actor.boobsImplant] || actor.boobsImplant,
].filter(Boolean).join(' ')" ].filter(Boolean).join(' ')"
class="augmentations-section" class="augmentations-section"
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template> >Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template>
@@ -208,7 +320,7 @@
<span> <span>
<Icon <Icon
v-if="actor.isCircumcised" v-if="actor.isCircumcised"
:title="'Circumcised'" title="Circumcised"
icon="pagebreak" icon="pagebreak"
class="circumcised" class="circumcised"
/> />
@@ -312,10 +424,10 @@
<a <a
v-for="social in socials" v-for="social in socials"
:key="`social-${social.id}`" :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)" :href="getSocialUrl(social)"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
:title="social.platform || social.url"
class="social ellipsis" class="social ellipsis"
> >
<Icon <Icon
@@ -339,7 +451,9 @@
:class="`icon-social icon-${social.platform} icon-generic`" :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 }} <template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
-->
</a> </a>
</ul> </ul>
</div> </div>
@@ -356,7 +470,7 @@
:href="`/actor/edit/${actor.id}/${actor.slug}`" :href="`/actor/edit/${actor.id}/${actor.slug}`"
target="_blank" target="_blank"
class="link" class="link"
>Edit bio</a> >Edit</a>
<a <a
:href="`/actor/revs/${actor.id}/${actor.slug}`" :href="`/actor/revs/${actor.id}/${actor.slug}`"
@@ -420,92 +534,6 @@
</div> </div>
</template> </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> <style>
.header-gender { .header-gender {
display: inline-flex; display: inline-flex;
@@ -735,19 +763,23 @@ const socials = props.actor.socials.map((social) => ({
} }
.socials { .socials {
display: flex;
flex-wrap: wrap;
/*
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0 0; grid-gap: 0 0;
overflow: hidden; overflow: hidden;
*/
gap: .25rem; gap: .25rem;
padding: 0; padding: 0;
} }
.social { .social {
display: flex; display: inline-flex;
height: 2rem;
align-items: center; align-items: center;
padding: .1rem .5rem; justify-content: center;
padding: .75rem .75rem;
border-radius: .25rem; border-radius: .25rem;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -755,10 +787,6 @@ const socials = props.actor.socials.map((social) => ({
font-weight: normal; font-weight: normal;
background: var(--highlight-weak-40); background: var(--highlight-weak-40);
.icon {
margin-right: .5rem;
}
.icon-generic { .icon-generic {
fill: var(--highlight); fill: var(--highlight);
} }
@@ -783,6 +811,7 @@ const socials = props.actor.socials.map((social) => ({
.link { .link {
color: inherit; color: inherit;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
} }
} }
@@ -854,7 +883,7 @@ const socials = props.actor.socials.map((social) => ({
display: none; display: none;
justify-content: center; justify-content: center;
position: absolute; position: absolute;
z-index: 10; z-index: 1;
bottom: -.25rem; bottom: -.25rem;
} }

View File

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

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

@@ -0,0 +1,288 @@
<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 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(',')}`, 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>
<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%;
}
.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> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -36,38 +68,6 @@
</VDropdown> </VDropdown>
</template> </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> <style scoped>
.trigger { .trigger {
height: 100%; 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> <template>
<div <div
class="tile" class="tile"
@@ -41,7 +67,7 @@
<span <span
v-if="actor.ageAtDeath" v-if="actor.ageAtDeath"
:title="`Passed ${formatDate(actor.ageAtDeath, 'MMMM d, yyyy')}`" :title="`Passed ${formatDate(actor.dateOfDeath, 'MMMM d, yyyy')}`"
class="age age-death" class="age age-death"
>{{ actor.ageAtDeath }}</span> >{{ actor.ageAtDeath }}</span>
@@ -74,42 +100,28 @@
</div> </div>
<span class="label"> <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 <img
v-if="actor.entity" 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`" :src="`/logos/${actor.entity.slug}/favicon_dark.png`"
class="favicon" class="favicon"
> >
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="{ content: `Credited as '${actor.alias.name}'`, ariaId: aliasTooltipId }"
icon="at-sign"
class="alias"
/>
</span> </span>
</div> </div>
</template> </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> <style scoped>
.tile { .tile {
display: flex; display: flex;
@@ -128,6 +140,10 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
:deep(.bookmarks) .icon:not(.favorited):not(:hover) { :deep(.bookmarks) .icon:not(.favorited):not(:hover) {
fill: var(--text-light); fill: var(--text-light);
} }
.menu {
fill: var(--text-light);
}
} }
&.unstashed { &.unstashed {
@@ -135,7 +151,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 { .label {
height: 1.75rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -149,7 +174,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
} }
.name { .name {
padding: .35rem .25rem .35rem .5rem; padding: 0 .25rem 0 .5rem;
} }
.favicon { .favicon {
@@ -246,4 +271,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem; height: .75rem;
margin-left: .25rem; margin-left: .25rem;
} }
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</style> </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> <template>
<div class="page"> <div class="page">
<nav class="nav"> <nav class="nav">
<ul class="nav-items nolist"> <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"> <li class="nav-item">
<a <a
href="/admin/revisions/scenes" href="/admin/revisions/scenes"
@@ -39,22 +63,6 @@
</div> </div>
</template> </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> <style scoped>
.page { .page {
display: flex; display: flex;

View File

@@ -1,3 +1,121 @@
<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 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-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> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -169,28 +287,28 @@
<div class="alert-triggers"> <div class="alert-triggers">
<Icon <Icon
v-if="alert.notify && alert.isFromPreset" v-if="alert.notify && alert.isFromPreset"
v-tooltip="'Notify in traxxx, added as quick alert'" v-tooltip="{ content: 'Notify in traxxx, added as quick alert', ariaId: `alert-notify-preset-${alert.id}` }"
icon="bell-plus" icon="bell-plus"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-else-if="alert.notify" v-else-if="alert.notify"
v-tooltip="'Notify in traxxx'" v-tooltip="{ content: 'Notify in traxxx', ariaId: `alert-notify-${alert.id}` }"
icon="bell2" icon="bell2"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-if="alert.stashes.some((stash) => !stash.isPrimary)" 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" icon="folder-heart"
class="trigger" class="trigger"
/> />
<Icon <Icon
v-else-if="alert.stashes.length > 0" v-else-if="alert.stashes.length > 0"
v-tooltip="alert.stashes.length > 0 ? 'Add to Favorites' : undefined" v-tooltip="alert.stashes.length > 0 ? { content: 'Add to Favorites', ariaId: `alert-favorites-${alert.id}` } : undefined"
icon="heart7" icon="heart7"
class="trigger" class="trigger"
/> />
@@ -206,7 +324,7 @@
<div class="alert-actions"> <div class="alert-actions">
<span <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" class="alert-id"
title="Alert ID" title="Alert ID"
>#{{ alert.id }}</span> >#{{ alert.id }}</span>
@@ -227,123 +345,6 @@
</section> </section>
</template> </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> <style scoped>
.alerts { .alerts {
width: 100%; width: 100%;

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> <script setup>
import { ref, inject } from 'vue'; import { inject, ref, useId } from 'vue';
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
import Dialog from '#/components/dialog/dialog.vue'; import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.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({ const props = defineProps({
presetActors: { presetActors: {
@@ -403,6 +24,8 @@ const props = defineProps({
const emit = defineEmits(['alert', 'close']); const emit = defineEmits(['alert', 'close']);
const { assets } = inject('pageContext');
const actors = ref(props.presetActors); const actors = ref(props.presetActors);
const actorResults = ref([]); const actorResults = ref([]);
const actorInput = ref(null); const actorInput = ref(null);
@@ -427,6 +50,13 @@ const actorAnd = ref(true);
const tagAnd = ref(true); const tagAnd = ref(true);
const matchAnd = 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 notify = ref(true);
const email = ref(false); const email = ref(false);
@@ -530,6 +160,387 @@ function selectStash(selectedStash) {
} }
</script> </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 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> <style scoped>
.dialog-body { .dialog-body {
width: 30rem; 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> <template>
<div <div
v-if="restriction" v-if="restriction"
@@ -12,7 +46,6 @@
<iframe <iframe
v-else-if="campaign?.banner?.type === 'html'" v-else-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width" :width="campaign.banner.width"
:height="campaign.banner.height" :height="campaign.banner.height"
:src="bannerSrc" :src="bannerSrc"
@@ -45,41 +78,6 @@
</a> </a>
</template> </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> <style scoped>
.campaign { .campaign {
height: 100%; height: 100%;

View File

@@ -1,3 +1,28 @@
<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-alert
emit('close');
}
}
onMounted(() => emit('open'));
</script>
<template> <template>
<Teleport to="#container"> <Teleport to="#container">
<div <div
@@ -24,31 +49,6 @@
</Teleport> </Teleport>
</template> </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> <style>
.dialog-body { .dialog-body {
display: flex; 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> <template>
<nav class="domains"> <nav class="domains">
<Link <Link
@@ -32,27 +53,6 @@
</nav> </nav>
</template> </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> <style scoped>
.domains { .domains {
display: flex; 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> <template>
<ul <ul
class="actors nolist" class="actors nolist"
@@ -40,43 +79,6 @@
</ul> </ul>
</template> </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> <style scoped>
.actors { .actors {
display: flex; 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> <template>
<div <div
class="figure augmentation" class="figure augmentation"
@@ -208,23 +228,3 @@
</div> </div>
</div> </div>
</template> </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> <template>
<div class="avatar noshrink"> <div class="avatar noshrink">
<img <img
@@ -19,10 +31,16 @@
>{{ avatar.sharpness.toFixed(2) }}</span> >{{ avatar.sharpness.toFixed(2) }}</span>
</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 <a
:href="getPath(avatar)" :href="getPath(avatar)"
target="_blank" target="_blank"
class="avatar-zoom" class="avatar-zoom"
@click.stop
> >
<Icon <Icon
icon="search" icon="search"
@@ -31,17 +49,6 @@
</div> </div>
</template> </template>
<script setup>
import getPath from '#/src/get-path.js';
defineProps({
avatar: {
type: Object,
default: null,
},
});
</script>
<style scoped> <style scoped>
.avatar { .avatar {
display: inline-block; display: inline-block;
@@ -89,7 +96,8 @@ defineProps({
} }
.avatar-meta, .avatar-meta,
.avatar-credit { .avatar-credit,
.avatar-date {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
box-sizing: border-box; box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem; bottom: .75rem;
left: 0; left: 0;
} }
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</style> </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> <script setup>
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
@@ -145,3 +44,104 @@ const bustSizes = {
bustSizes.uk = bustSizes.us; bustSizes.uk = bustSizes.us;
bustSizes.jp = bustSizes.eu; bustSizes.jp = bustSizes.eu;
</script> </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> <template>
<ul <ul
class="movies nolist" class="movies nolist"
@@ -40,47 +83,6 @@
</ul> </ul>
</template> </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> <style scoped>
.movies { .movies {
display: flex; 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> <template>
<div <div
class="figure penis" class="figure penis"
@@ -83,27 +107,3 @@
</div> </div>
</div> </div>
</template> </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> <template>
<div <div
class="place" class="place"
@@ -35,43 +75,3 @@
</div> </div>
</div> </div>
</template> </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> <template>
<ul class="socials nolist"> <ul class="socials nolist">
<li <li
@@ -38,46 +77,6 @@
</ul> </ul>
</template> </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> <style scoped>
.socials { .socials {
display: flex; display: flex;

View File

@@ -1,3 +1,202 @@
<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 (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> <template>
<div class="page"> <div class="page">
<div <div
@@ -21,7 +220,7 @@
class="rev" class="rev"
:class="{ :class="{
reviewed: reviewedRevisions.has(rev.id), 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)"> <template v-if="context === 'admin' || expanded.has(rev.id)">
@@ -65,14 +264,14 @@
<template v-else-if="context === 'admin'"> <template v-else-if="context === 'admin'">
<Icon <Icon
v-tooltip="`Ban user from submitting revisions`" v-tooltip="{ content: 'Ban user from submitting revisions', ariaId: `rev-ban-${rev.id}` }"
icon="user-block" icon="user-block"
class="review-reject review-ban" class="review-reject review-ban"
@click="banEditor(rev)" @click="banEditor(rev)"
/> />
<Icon <Icon
v-tooltip="`Reject revision`" v-tooltip="{ content: 'Reject revision', ariaId: `rev-reject-${rev.id}` }"
icon="blocked" icon="blocked"
class="review-reject" class="review-reject"
@click="reviewRevision(rev, false)" @click="reviewRevision(rev, false)"
@@ -85,7 +284,7 @@
> >
<Icon <Icon
v-tooltip="`Approve and apply revision`" v-tooltip="{ content: 'Approve and apply revision', ariaId: `rev-approve-${rev.id}` }"
icon="checkmark" icon="checkmark"
class="review-approve" class="review-approve"
@click="reviewRevision(rev, true)" @click="reviewRevision(rev, true)"
@@ -209,7 +408,7 @@
<a <a
v-if="rev.user && context !== 'user'" 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}`" :href="`/user/${rev.user.username}`"
target="_blank" target="_blank"
class="rev-avatar nolink ellipsis" class="rev-avatar nolink ellipsis"
@@ -227,181 +426,6 @@
</div> </div>
</template> </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> <style scoped>
.page { .page {
display: flex; 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> <template>
<ul <ul
class="list nolist" class="list nolist"
@@ -54,7 +126,7 @@
</li> </li>
<li class="list-new"> <li class="list-new">
<VDropdown> <VDropdown :aria-id="addSocialDropdownId">
<Icon <Icon
icon="plus2" icon="plus2"
class="add noselect" class="add noselect"
@@ -110,77 +182,6 @@
</ul> </ul>
</template> </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> <style scoped>
.list { .list {
display: flex; 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> <template>
<div class="tags-sections"> <div class="tags-sections">
<div <div
@@ -53,73 +122,6 @@
</div> </div>
</template> </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> <style scoped>
.tags { .tags {
display: flex; display: flex;

View File

@@ -1,3 +1,16 @@
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<template> <template>
<a <a
:href="`/${entity.type}/${entity.slug}`" :href="`/${entity.type}/${entity.slug}`"
@@ -22,19 +35,6 @@
</a> </a>
</template> </template>
<script setup>
defineProps({
entity: {
type: Object,
default: null,
},
showNetworkSymbol: {
type: Boolean,
default: true,
},
});
</script>
<style scoped> <style scoped>
.entity { .entity {
width: 15rem; 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> <template>
<li <li
:key="`filter-actor-${actor.id}`" :key="`filter-actor-${actor.id}`"
@@ -44,37 +75,6 @@
</li> </li>
</template> </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> <style scoped>
.filter-name { .filter-name {
align-items: stretch; 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> <template>
<div class="filter actors-container"> <div class="filter actors-container">
<div <div
@@ -18,6 +94,7 @@
</label> </label>
<div <div
v-tooltip="{ content: selectedGender ? `${selectedGender.slice(0, 1).toUpperCase()}${selectedGender.slice(1)} performers` : `All genders`, ariaId: genderTooltipId }"
class="filter-sort noselect" class="filter-sort noselect"
@click="selectGender" @click="selectGender"
> >
@@ -35,6 +112,7 @@
<div <div
v-show="order === 'name'" v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
@@ -45,6 +123,7 @@
<div <div
v-show="order === 'count'" v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'name'" @click="order = 'name'"
> >
@@ -96,78 +175,6 @@
</div> </div>
</template> </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> <style scoped>
.filter { .filter {
padding: 0; 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> <template>
<div class="filter"> <div class="filter">
<div class="filter-section"> <div class="filter-section">
@@ -53,26 +75,6 @@
</div> </div>
</template> </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> <style scoped>
.input { .input {
width: 100%; 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> <template>
<div class="filter"> <div class="filter">
<RangeFilter <RangeFilter
@@ -42,33 +72,3 @@
</span> </span>
</div> </div>
</template> </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,3 +1,80 @@
<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,
},
channels: {
type: Array,
default: () => [],
},
});
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;
function sort(channelA, channelB) {
if (order.value === 'count') {
return channelB.count - channelA.count;
}
return channelA.name.localeCompare(channelB.name);
}
const entities = computed(() => {
const filteredChannels = props.channels.filter((channel) => channel.id !== pageChannel?.id
&& (searchRegexp.value.test(channel.name)
|| searchRegexp.value.test(channel.slug)
|| (channel.parent && searchRegexp.value.test(channel.parent.name))
|| (channel.parent && searchRegexp.value.test(channel.parent.slug))));
const networks = Object.values(filteredChannels.reduce((acc, channel) => {
if (!acc[channel.id] && (channel.type === 'network' || !channel.parent || channel.isIndependent)) { // network may have already been created by a child
acc[channel.id] = {
...channel,
children: [],
};
return acc;
}
if (!acc[channel.id] && channel.parent && !acc[channel.parent.id] && (channel.type === 'channel' || channel.type === 'studio')) {
acc[channel.parent.id] = {
...channel.parent,
children: [],
};
}
if (!acc[channel.id] && channel.parent && (channel.type === 'channel' || channel.type === 'studio')) {
acc[channel.parent.id].children.push(channel);
}
return acc;
}, {}))
.map((network) => ({
...network,
children: network.children?.sort(sort),
count: network.count || network.children?.reduce((acc, channel) => acc + channel.count, 0),
}))
.sort(sort)
.flatMap((network) => [network, ...(network.children || [])]);
return networks;
});
</script>
<template> <template>
<div class="filter channels-container"> <div class="filter channels-container">
<div class="filters-sort"> <div class="filters-sort">
@@ -14,6 +91,7 @@
<div <div
v-show="order === 'name'" v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
@@ -22,6 +100,7 @@
<div <div
v-show="order === 'count'" v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'name'" @click="order = 'name'"
> >
@@ -75,7 +154,7 @@
v-if="filters.entity?.id === entity.id" v-if="filters.entity?.id === entity.id"
icon="cross2" icon="cross2"
class="filter-remove" class="filter-remove"
@click.native.stop="emit('update', 'entity', null)" @click.stop="emit('update', 'entity', null)"
/> />
</span> </span>
</span> </span>
@@ -85,81 +164,6 @@
</div> </div>
</template> </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,
},
channels: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('name');
const { pageProps } = inject('pageContext');
const { channel: pageChannel } = pageProps;
function sort(channelA, channelB) {
if (order.value === 'count') {
return channelB.count - channelA.count;
}
return channelA.name.localeCompare(channelB.name);
}
const entities = computed(() => {
const filteredChannels = props.channels.filter((channel) => channel.id !== pageChannel?.id
&& (searchRegexp.value.test(channel.name)
|| searchRegexp.value.test(channel.slug)
|| (channel.parent && searchRegexp.value.test(channel.parent.name))
|| (channel.parent && searchRegexp.value.test(channel.parent.slug))));
const networks = Object.values(filteredChannels.reduce((acc, channel) => {
if (!acc[channel.id] && (channel.type === 'network' || !channel.parent || channel.isIndependent)) { // network may have already been created by a child
acc[channel.id] = {
...channel,
children: [],
};
return acc;
}
if (!acc[channel.id] && channel.parent && !acc[channel.parent.id] && (channel.type === 'channel' || channel.type === 'studio')) {
acc[channel.parent.id] = {
...channel.parent,
children: [],
};
}
if (!acc[channel.id] && channel.parent && (channel.type === 'channel' || channel.type === 'studio')) {
acc[channel.parent.id].children.push(channel);
}
return acc;
}, {}))
.map((network) => ({
...network,
children: network.children?.sort(sort),
count: network.count || network.children?.reduce((acc, channel) => acc + channel.count, 0),
}))
.sort(sort)
.flatMap((network) => [network, ...(network.children || [])]);
return networks;
});
</script>
<style scoped> <style scoped>
.filter-items { .filter-items {
max-height: 15rem; 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> <template>
<ul class="countries nolist"> <ul class="countries nolist">
<li <li
@@ -19,27 +34,12 @@
v-if="selectedCountry === country.alpha2" v-if="selectedCountry === country.alpha2"
icon="cross2" icon="cross2"
class="filter-remove" class="filter-remove"
@click.native.stop="emit('country', null)" @click.stop="emit('country', null)"
/> />
</li> </li>
</ul> </ul>
</template> </template>
<script setup>
defineProps({
countries: {
type: Array,
default: () => [],
},
selectedCountry: {
type: String,
default: null,
},
});
const emit = defineEmits(['country']);
</script>
<style scoped> <style scoped>
.countries:not(:last-child) { .countries:not(:last-child) {
border-bottom: solid 1px var(--glass-weak-30); 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> <template>
<div <div
class="countries-container" class="countries-container"
@@ -43,35 +72,6 @@
</div> </div>
</template> </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> <style scoped>
.countries-container { .countries-container {
border-bottom: solid 1px var(--shadow-weak-30); 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> <template>
<div class="filters-frame"> <div class="filters-frame">
<div <div
@@ -47,31 +72,6 @@
</div> </div>
</template> </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> <style>
.filter { .filter {
padding: .5rem; 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> <template>
<ul class="filter genders nolist"> <ul class="filter genders nolist">
<li> <li>
@@ -47,19 +60,6 @@
</ul> </ul>
</template> </template>
<script setup>
import Gender from '#/components/actors/gender.vue';
defineProps({
filters: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update']);
</script>
<style scoped> <style scoped>
.genders { .genders {
display: flex; 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> <template>
<div class="filter"> <div class="filter">
<RangeFilter <RangeFilter
@@ -31,16 +44,3 @@
</RangeFilter> </RangeFilter>
</div> </div>
</template> </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> <script setup>
import Checkbox from '../form/checkbox.vue'; import Checkbox from '../form/checkbox.vue';
import Range from '../form/range.vue'; import Range from '../form/range.vue';
@@ -75,3 +39,39 @@ defineProps({
const emit = defineEmits(['change', 'input', 'enable']); const emit = defineEmits(['change', 'input', 'enable']);
</script> </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> <template>
<div class="filter tags-container"> <div class="filter tags-container">
<div class="filters-sort"> <div class="filters-sort">
@@ -12,28 +138,29 @@
<Icon icon="search" /> <Icon icon="search" />
</label> </label>
<!-- <template v-if="isActorTagsAvailable">
<div <div
v-show="showActorTags" 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" class="filter-sort order noselect"
@click="showActorTags = false" @click="showActorTags = false"
> >
<Icon icon="user-tags" /> <Icon icon="user-tags" />
</div> </div>
<div <div
v-show="!showActorTags" v-show="!showActorTags"
v-tooltip="'All tags'" v-tooltip="{ content: 'All tags', ariaId: allTagsTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="showActorTags = true" @click="showActorTags = true"
> >
<Icon icon="price-tags" /> <Icon icon="price-tags" />
</div> </div>
--> </template>
<div <div
v-show="order === 'priority'" v-show="order === 'priority'"
v-tooltip="{ content: 'Sorted by tag priority', ariaId: sortPriorityTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
@@ -42,6 +169,7 @@
<div <div
v-show="order === 'count'" v-show="order === 'count'"
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'name'" @click="order = 'name'"
> >
@@ -52,6 +180,7 @@
<div <div
v-show="order === 'name'" v-show="order === 'name'"
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'priority'" @click="order = 'priority'"
> >
@@ -119,121 +248,6 @@
</div> </div>
</template> </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> <style scoped>
.filter-items { .filter-items {
max-height: 15rem; max-height: 15rem;

View File

@@ -1,3 +1,7 @@
<script setup>
const emit = defineEmits(['toggle']);
</script>
<template> <template>
<div <div
class="filters-toggle open" class="filters-toggle open"
@@ -5,10 +9,6 @@
><Icon icon="filter" /></div> ><Icon icon="filter" /></div>
</template> </template>
<script setup>
const emit = defineEmits(['toggle']);
</script>
<style scoped> <style scoped>
.filters-toggle { .filters-toggle {
min-width: 2.75rem; 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> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
@@ -50,6 +25,31 @@ function clearYears() {
} }
</script> </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> <style scoped>
.years-container { .years-container {
display: flex; display: flex;

View File

@@ -1,3 +1,10 @@
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<template> <template>
<footer class="footer"> <footer class="footer">
<span class="footer-segment">© traxxx</span> <span class="footer-segment">© traxxx</span>
@@ -27,13 +34,6 @@
</footer> </footer>
</template> </template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { env } = pageContext;
</script>
<style scoped> <style scoped>
.footer { .footer {
display: flex; display: flex;

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> <template>
<nav class="nav-list noselect"> <nav class="nav-list noselect">
<a <a
@@ -37,15 +46,6 @@
</nav> </nav>
</template> </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> <style scoped>
.nav-list { .nav-list {
display: flex; display: flex;

View File

@@ -1,3 +1,24 @@
<script setup>
defineProps({
checked: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['change']);
const uid = String(Math.random()).slice(2);
</script>
<template> <template>
<label class="check-container noselect"> <label class="check-container noselect">
<span <span
@@ -22,27 +43,6 @@
</label> </label>
</template> </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> <style scoped>
.check-container { .check-container {
display: flex; 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> <script setup>
import { import {
ref,
computed, computed,
nextTick, nextTick,
ref,
} from 'vue'; } from 'vue';
const props = defineProps({ const props = defineProps({
@@ -138,6 +85,59 @@ async function setRange(prop, value) {
} }
</script> </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> <style>
.dark .range-container .range { .dark .range-container .range {
--slider-range: var(--lighten-weak-10); --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> <script>
export default { export default {
props: { props: {
@@ -44,12 +21,35 @@ export default {
emits: ['change'], emits: ['change'],
data() { data() {
return { return {
id: Math.floor(new Date().getTime() * Math.random()), id: Math.floor(Date.now() * Math.random()),
}; };
}, },
}; };
</script> </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> <style lang="scss" scoped>
@import 'breakpoints'; @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> <template>
<header class="header"> <header class="header">
<nav class="nav"> <nav class="nav">
@@ -92,6 +153,7 @@
> >
<VDropdown <VDropdown
v-if="user" v-if="user"
:aria-id="notifsDropdownId"
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true" :prevent-overflow="true"
class="notifs-trigger" class="notifs-trigger"
@@ -121,6 +183,7 @@
</VDropdown> </VDropdown>
<VDropdown <VDropdown
:aria-id="menuDropdownId"
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true" :prevent-overflow="true"
class="menu-trigger" class="menu-trigger"
@@ -187,6 +250,32 @@
</a> </a>
</li> </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"
>
<a
:href="`/user/${user.username}/alerts`"
class="menu-button nolink alerts"
>
<Icon icon="alarm" />
Alerts
</a>
</li>
<li <li
v-close-popper v-close-popper
class="menu-item menu-button" class="menu-item menu-button"
@@ -257,64 +346,6 @@
</header> </header>
</template> </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> <style scoped>
.header { .header {
display: flex; display: flex;

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> <template>
<div class="menu"> <div class="menu">
<div class="notifs-header"> <div class="notifs-header">
@@ -88,71 +152,6 @@
</div> </div>
</template> </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> <style scoped>
.notifs { .notifs {
width: 30rem; width: 30rem;

View File

@@ -1,17 +1,6 @@
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
const svg = ref(null);
const props = defineProps({ const props = defineProps({
icon: { icon: {
type: String, type: String,
@@ -27,11 +16,22 @@ const props = defineProps({
}, },
}); });
const svg = ref(null);
import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => { import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => {
svg.value = raw.default; svg.value = raw.default;
}); });
</script> </script>
<template>
<div
:class="{ active }"
:title="title"
class="icon"
v-html="svg"
/>
</template>
<style> <style>
.icon { .icon {
fill: var(--text); 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> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
@@ -19,3 +10,12 @@ defineProps({
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
</script> </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> <template>
<div <div
class="banner-container" class="banner-container"
@@ -49,7 +86,7 @@
<Icon <Icon
v-if="(release.trailer || release.teaser).isRestricted" v-if="(release.trailer || release.teaser).isRestricted"
v-tooltip="'Restricted video'" v-tooltip="{ content: 'Restricted video', ariaId: restrictedTooltipId }"
icon="blocked" icon="blocked"
class="restricted" class="restricted"
/> />
@@ -102,42 +139,6 @@
</div> </div>
</template> </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> <style scoped>
.banner-container { .banner-container {
background-position: center; 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> <script setup>
import { ref, inject } from 'vue';
import { parse } from 'path-to-regexp'; 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 ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.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 Checkbox from '#/components/form/checkbox.vue';
import MovieTile from '#/components/movies/tile.vue';
import Pagination from '#/components/pagination/pagination.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 pageContext = inject('pageContext');
const { pageProps, routeParams, urlParsed } = pageContext; const { pageProps, routeParams, urlParsed } = pageContext;
@@ -236,6 +141,115 @@ function updateFilter(prop, value, reload = true) {
} }
</script> </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> <style scoped>
.movies-page { .movies-page {
display: flex; 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> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -36,39 +69,6 @@
</VDropdown> </VDropdown>
</template> </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> <style scoped>
.trigger { .trigger {
height: 100%; 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> <template>
<div <div
class="movie-tile" class="movie-tile"
@@ -118,35 +147,6 @@
</div> </div>
</template> </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> <style scoped>
.movie-tile { .movie-tile {
display: flex; 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> <template>
<div class="pagination-container"> <div class="pagination-container">
<Campaign <Campaign
@@ -91,122 +207,6 @@
</div> </div>
</template> </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> <style scoped>
.pagination-container { .pagination-container {
display: flex; 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> <template>
<div <div
v-if="timeline" v-if="timeline"
@@ -43,19 +68,19 @@
<span class="chapter-details"> <span class="chapter-details">
<span <span
v-if="typeof chapter.time === 'number'" 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" class="chapter-time"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span> ><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span <span
v-if="chapter.duration" v-if="chapter.duration"
v-tooltip="'Duration'" v-tooltip="{ content: 'Duration', ariaId: `chapter-duration-${chapter.id}` }"
class="chapter-duration" class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span> ><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time <time
v-if="chapter.date" 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" :datetime="chapter.date"
class="chapter-date" class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time> >{{ formatDate(chapter.date, 'MMM d') }}</time>
@@ -90,31 +115,6 @@
</ul> </ul>
</template> </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> <style scoped>
.chapters { .chapters {
display: grid; 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> <template>
<div class="meta"> <div class="meta">
<div class="channel"> <div class="channel">
@@ -44,21 +59,6 @@
</div> </div>
</template> </template>
<script setup>
import { format } from 'date-fns';
defineProps({
scene: {
type: Object,
default: null,
},
user: {
type: Object,
default: null,
},
});
</script>
<style scoped> <style scoped>
.meta { .meta {
display: flex; 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> <template>
<div <div
class="scenes-page" class="scenes-page"
@@ -12,12 +204,19 @@
<!-- onsearch not compatible with FF and Safari --> <!-- onsearch not compatible with FF and Safari -->
<input <input
v-model="filters.search" v-model="filters.search"
type="search" type="input"
placeholder="Search scenes" placeholder="Search scenes"
class="search input" class="search input"
@search="search" @keydown.enter="search"
> >
<Icon
v-if="filters.search"
icon="cross2"
class="search-button"
@click="filters.search = ''; search()"
/>
<Icon <Icon
icon="search" icon="search"
class="search-button" class="search-button"
@@ -200,195 +399,6 @@
</div> </div>
</template> </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> <style scoped>
.scenes-page { .scenes-page {
display: flex; 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> <template>
<div class="editor"> <div class="editor">
<div class="editor-header"> <div class="editor-header">
@@ -89,159 +244,6 @@
</div> </div>
</template> </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> <style scoped>
.editor { .editor {
display: flex; 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> <template>
<div <div
class="tile" class="tile"
@@ -115,33 +142,6 @@
</div> </div>
</template> </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> <style scoped>
.tile { .tile {
display: flex; display: flex;

View File

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

View File

@@ -1,3 +1,42 @@
<script setup>
import Cookies from 'js-cookie';
import { ref } from 'vue';
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>
<template> <template>
<Dialog title="Settings"> <Dialog title="Settings">
<div class="dialog-body"> <div class="dialog-body">
@@ -27,44 +66,6 @@
</Dialog> </Dialog>
</template> </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> <style scoped>
.dialog-body { .dialog-body {
width: 30rem; width: 30rem;

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> <template>
<div <div
class="sidebar-container" class="sidebar-container"
@@ -85,17 +96,6 @@
</div> </div>
</template> </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> <style scoped>
.sidebar-container { .sidebar-container {
width: 100%; 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> <template>
<Dialog <Dialog
title="New stash" title="New stash"
@@ -23,33 +50,6 @@
</Dialog> </Dialog>
</template> </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> <style scoped>
.dialog-body { .dialog-body {
padding: 1rem; padding: 1rem;

View File

@@ -1,3 +1,204 @@
<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,
},
});
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] }),
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> <template>
<div <div
v-if="user" v-if="user"
@@ -37,6 +238,7 @@
<VDropdown <VDropdown
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))" v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId"
:shown="showStashes" :shown="showStashes"
@hide="showStashes = false" @hide="showStashes = false"
> >
@@ -60,6 +262,7 @@
<VDropdown <VDropdown
v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)" v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)"
:aria-id="stashTogglesDropdownId"
:triggers="[]" :triggers="[]"
:shown="showStashes" :shown="showStashes"
:disabled="showSecondary" :disabled="showSecondary"
@@ -70,7 +273,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)" v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)"
:icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'" :icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'"
class="heart favorited noselect" class="heart favorited noselect"
@click.native.stop="unstashItem(pageStash)" @click.stop="unstashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
@@ -78,7 +281,7 @@
v-else v-else
:icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'" :icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'"
class="heart noselect" class="heart noselect"
@click.native.stop="stashItem(pageStash)" @click.stop="stashItem(pageStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
</template> </template>
@@ -88,7 +291,7 @@
v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)" v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)"
icon="heart7" icon="heart7"
class="heart favorited noselect" class="heart favorited noselect"
@click.native.stop="unstashItem(primaryStash)" @click.stop="unstashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
@@ -96,7 +299,7 @@
v-else v-else
icon="heart8" icon="heart8"
class="heart noselect" class="heart noselect"
@click.native.stop="stashItem(primaryStash)" @click.stop="stashItem(primaryStash)"
@contextmenu.prevent="toggleShowStashes(true)" @contextmenu.prevent="toggleShowStashes(true)"
/> />
</template> </template>
@@ -127,196 +330,6 @@
</div> </div>
</template> </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> <style scoped>
.bookmarks { .bookmarks {
display: flex; 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> <template>
<ul class="stash-menu nolist noselect"> <ul class="stash-menu nolist noselect">
<li <li
@@ -26,23 +43,6 @@
</ul> </ul>
</template> </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> <style scoped>
.menu-item { .menu-item {
display: flex; 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> <template>
<div class="stash"> <div class="stash">
<div class="header"> <div class="header">
@@ -44,18 +56,6 @@
</div> </div>
</template> </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> <style scoped>
.stash { .stash {
display: flex; display: flex;

View File

@@ -1,3 +1,25 @@
<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> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -34,28 +56,6 @@
</section> </section>
</template> </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> <style scoped>
.stashes { .stashes {
display: grid; display: grid;

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> <template>
<div class="stash"> <div class="stash">
<div class="stash-header"> <div class="stash-header">
@@ -16,12 +111,12 @@
<Icon <Icon
v-if="!stash.isPublic" v-if="!stash.isPublic"
v-tooltip="'This stash is private'" v-tooltip="{ content: 'This stash is private', ariaId: privateTooltipId }"
icon="eye-blocked" icon="eye-blocked"
class="private noselect" class="private noselect"
/> />
<VDropdown> <VDropdown :aria-id="menuDropdownId">
<Icon <Icon
v-if="profile.id === user?.id" v-if="profile.id === user?.id"
icon="more2" icon="more2"
@@ -122,99 +217,6 @@
</div> </div>
</template> </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> <style scoped>
.stash { .stash {
width: 100%; 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> <template>
<a <a
v-if="photo?.entity" v-if="photo?.entity"
v-tooltip="photo.entity.name" v-tooltip="{ content: photo.entity.name, ariaId: entityTooltipId }"
:to="`/${photo.entity.type}/${photo.entity.slug}`" :to="`/${photo.entity.type}/${photo.entity.slug}`"
> >
<img <img
@@ -30,19 +47,6 @@
</a> </a>
</template> </template>
<script setup>
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
</script>
<style scoped> <style scoped>
.album-logo { .album-logo {
max-height: .75rem; 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> <template>
<div class="photos nobar"> <div class="photos nobar">
<Campaign <Campaign
@@ -40,44 +78,6 @@
</div> </div>
</template> </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> <style scoped>
.photos { .photos {
display: flex; display: flex;

View File

@@ -1,5 +1,50 @@
<script setup>
import { inject, ref, useId } from 'vue';
import { get } from '#/src/api.js';
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['tag']);
const dropdownId = useId();
const pageContext = inject('pageContext');
const tagNames = {
dp: 'double penetration',
};
const defaultTags = pageContext.pageProps.tagIds
? Object.entries(pageContext.pageProps.tagIds).map(([slug, id]) => ({
id,
slug,
name: tagNames[slug] || slug,
}))
: [];
const tags = ref(defaultTags);
const query = ref(null);
const queryInput = ref(null);
async function search() {
tags.value = await get('/tags', { query: query.value });
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<template> <template>
<VDropdown <VDropdown
:aria-id="dropdownId"
:disabled="disabled" :disabled="disabled"
class="trigger" class="trigger"
@show="focus" @show="focus"
@@ -30,49 +75,6 @@
</VDropdown> </VDropdown>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { get } from '#/src/api.js';
const pageContext = inject('pageContext');
const tagNames = {
dp: 'double penetration',
};
const defaultTags = pageContext.pageProps.tagIds
? Object.entries(pageContext.pageProps.tagIds).map(([slug, id]) => ({
id,
slug,
name: tagNames[slug] || slug,
}))
: [];
const tags = ref(defaultTags);
const query = ref(null);
const queryInput = ref(null);
defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['tag']);
async function search() {
tags.value = await get('/tags', { query: query.value });
}
function focus() {
setTimeout(() => {
queryInput.value?.focus();
}, 100);
}
</script>
<style scoped> <style scoped>
.trigger { .trigger {
height: 100%; height: 100%;

View File

@@ -1,49 +1,22 @@
<template>
<div class="tooltip-container">
<div
ref="handle"
class="tooltip-handle"
@click.stop="toggle"
>
<slot />
</div>
<div
v-show="opened"
class="tooltip-layer"
>
<div
ref="tooltip"
class="tooltip-wrapper"
:style="{ transform: `translate(${position.x}px, ${position.y}px)` }"
>
<div
class="tooltip"
@click="close"
>
<slot
name="tooltip"
@click.stop
/>
</div>
</div>
</div>
</div>
</template>
<script setup> <script setup>
import {
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue';
// import { useFloating } from '@floating-ui/vue'; // import { useFloating } from '@floating-ui/vue';
import { computePosition, shift } from '@floating-ui/dom'; import { computePosition, shift } from '@floating-ui/dom';
import {
nextTick,
onBeforeUnmount,
onMounted,
ref,
} from 'vue';
import events from '#/src/events.js'; import events from '#/src/events.js';
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
const handle = ref(null); const handle = ref(null);
const tooltip = ref(null); const tooltip = ref(null);
const opened = ref(false); const opened = ref(false);
@@ -53,13 +26,6 @@ const position = ref({
y: 0, y: 0,
}); });
const props = defineProps({
placement: {
type: String,
default: 'top',
},
});
async function open() { async function open() {
events.emit('blur'); events.emit('blur');
opened.value = true; opened.value = true;
@@ -97,7 +63,8 @@ function close() {
function toggle() { function toggle() {
if (opened.value) { if (opened.value) {
close(); close();
} else { }
else {
open(); open();
} }
} }
@@ -111,6 +78,39 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<template>
<div class="tooltip-container">
<div
ref="handle"
class="tooltip-handle"
@click.stop="toggle"
>
<slot />
</div>
<div
v-show="opened"
class="tooltip-layer"
>
<div
ref="tooltip"
class="tooltip-wrapper"
:style="{ transform: `translate(${position.x}px, ${position.y}px)` }"
>
<div
class="tooltip"
@click="close"
>
<slot
name="tooltip"
@click.stop
/>
</div>
</div>
</div>
</div>
</template>
<style> <style>
.tooltip-layer { .tooltip-layer {
position: absolute; position: absolute;

View File

@@ -1,3 +1,57 @@
<script setup>
import { format, formatDistanceStrict } from 'date-fns';
import { inject, ref, useId } from 'vue';
import { del, get, post } from '#/src/api.js';
import events from '#/src/events.js';
const pageContext = inject('pageContext');
const now = pageContext.meta.now;
const user = pageContext.user;
const keys = ref(pageContext.pageProps.keys);
const newKey = ref(null);
const flushTooltipId = useId();
async function createKey() {
const key = await post('/me/keys', null, {
appendErrorMessage: true,
});
newKey.value = key;
keys.value = await get('/me/keys');
}
async function removeKey(key) {
if (confirm(`Are you sure you want to remove API key '${key.identifier}'? It can not be restored.`)) { // eslint-disable-line no-alert
newKey.value = null;
await del(`/me/keys/${key.identifier}`);
keys.value = await get('/me/keys');
}
}
async function flushKeys() {
if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-alert
newKey.value = null;
await del('/me/keys');
keys.value = [];
}
}
function copyKey(event) {
event.target.select();
navigator.clipboard.writeText(newKey.value.key);
events.emit('feedback', {
type: 'success',
message: 'Key copied to clipboard',
});
}
</script>
<template> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header">
@@ -5,7 +59,7 @@
<div class="keys-actions"> <div class="keys-actions">
<Icon <Icon
v-tooltip="'Flush all keys'" v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
icon="stack-cancel" icon="stack-cancel"
class="keys-flush" class="keys-flush"
@click="flushKeys" @click="flushKeys"
@@ -63,7 +117,7 @@
<Icon icon="plus-circle" /> <Icon icon="plus-circle" />
<time <time
v-tooltip="`Created ${format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')}`" v-tooltip="{ content: `Created ${format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')}`, ariaId: `key-created-${key.id}` }"
:datetime="key.createdAt.toISOString()" :datetime="key.createdAt.toISOString()"
>{{ formatDistanceStrict(key.createdAt, now) }} ago</time> >{{ formatDistanceStrict(key.createdAt, now) }} ago</time>
</span> </span>
@@ -73,7 +127,7 @@
<template v-if="key.lastUsedAt"> <template v-if="key.lastUsedAt">
<time <time
v-tooltip="`Last used ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')} from IP ${key.lastUsedIp}`" v-tooltip="{ content: `Last used ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')} from IP ${key.lastUsedIp}`, ariaId: `key-used-${key.id}` }"
:datetime="key.lastUsedAt.toISOString()" :datetime="key.lastUsedAt.toISOString()"
>{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time> >{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time>
</template> </template>
@@ -99,59 +153,6 @@
</section> </section>
</template> </template>
<script setup>
import { ref, inject } from 'vue';
import { format, formatDistanceStrict } from 'date-fns';
import { get, post, del } from '#/src/api.js';
import events from '#/src/events.js';
const pageContext = inject('pageContext');
const now = pageContext.meta.now;
const user = pageContext.user;
const keys = ref(pageContext.pageProps.keys);
const newKey = ref(null);
async function createKey() {
const key = await post('/me/keys', null, {
appendErrorMessage: true,
});
newKey.value = key;
keys.value = await get('/me/keys');
}
async function removeKey(key) {
if (confirm(`Are you sure you want to remove API key '${key.identifier}'? It can not be restored.`)) { // eslint-disable-line no-restricted-globals, no-alert
newKey.value = null;
await del(`/me/keys/${key.identifier}`);
keys.value = await get('/me/keys');
}
}
async function flushKeys() {
if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-restricted-globals, no-alert
newKey.value = null;
await del('/me/keys');
keys.value = [];
}
}
function copyKey(event) {
event.target.select();
navigator.clipboard.writeText(newKey.value.key);
events.emit('feedback', {
type: 'success',
message: 'Key copied to clipboard',
});
}
</script>
<style scoped> <style scoped>
.keys-header { .keys-header {
display: flex; display: flex;

View File

@@ -1,16 +1,6 @@
<template>
<video
ref="player"
:poster="poster"
class="player video-js vjs-big-play-centered"
@playing="emit('play')"
@pause="emit('pause')"
/>
</template>
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue';
import videoJs from 'video.js'; import videoJs from 'video.js';
import { onMounted, ref, watch } from 'vue';
// import 'videojs-vr/dist/videojs-vr.min'; // import 'videojs-vr/dist/videojs-vr.min';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
@@ -66,6 +56,16 @@ watch(() => props.video, () => updatePlayer());
watch(() => props.poster, () => updatePlayer()); watch(() => props.poster, () => updatePlayer());
</script> </script>
<template>
<video
ref="player"
:poster="poster"
class="player video-js vjs-big-play-centered"
@playing="emit('play')"
@pause="emit('pause')"
/>
</template>
<style> <style>
.player.video-js { .player.video-js {
.vjs-poster { .vjs-poster {

View File

@@ -147,7 +147,7 @@ module.exports = {
login: true, login: true,
signup: true, signup: true,
usernameLength: [2, 24], usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/, usernamePattern: /^[\w-]+$/,
captcha: { captcha: {
enabled: false, enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001', siteKey: '10000000-ffff-ffff-ffff-000000000001',
@@ -178,8 +178,8 @@ module.exports = {
}, },
stashes: { stashes: {
nameLength: [2, 24], nameLength: [2, 24],
namePattern: /^[a-zA-Z0-9!?$&\s_-]+$/, namePattern: /^[\w!?$&\s-]+$/,
viewRefreshCron: '* * * * *', // every minute viewRefreshCrontab: '* * * * *', // every minute
viewRefreshCooldowns: { viewRefreshCooldowns: {
actors: 60, // minutes actors: 60, // minutes
stashes: 1, stashes: 1,
@@ -192,4 +192,8 @@ module.exports = {
s3Path: 'https://s3.wasabisys.com', s3Path: 'https://s3.wasabisys.com',
videoRestrictions: [], // entity slugs, _ prefix for networks, hides trailer and teaser videos videoRestrictions: [], // entity slugs, _ prefix for networks, hides trailer and teaser videos
}, },
sync: {
enabled: true,
crontab: '*/10 * * * *', // every 10 minutes
},
}; };

50
eslint.config.js Normal file
View File

@@ -0,0 +1,50 @@
import antfu from '@antfu/eslint-config';
export default antfu(
{
ignores: [
'common/**',
'static/**',
],
vue: true,
// jsonc/yaml/toml stay off: assets/*.yaml are data files, not source, and weren't linted before.
jsonc: false,
yaml: false,
toml: false,
stylistic: {
indent: 'tab',
quotes: 'single',
semi: true,
},
},
{
languageOptions: {
globals: {
CONFIG: 'readonly',
},
},
rules: {
'default-param-last': 'off',
'no-underscore-dangle': 'off',
'no-console': 'off',
'style/arrow-parens': ['error', 'always'],
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
}],
'prefer-destructuring': 'off',
'import/prefer-default-export': 'off',
'unused-imports/no-unused-vars': ['error', {
args: 'after-used',
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
caughtErrors: 'none',
}],
'vue/multi-word-component-names': 'off',
'vue/no-reserved-component-names': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
},
},
);

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