Compare commits
158 Commits
e6919a4283
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6444cf21eb | |||
| 216e7f59ef | |||
| 479b044798 | |||
| 7c6bde690f | |||
| 981205efaa | |||
| b6408f3244 | |||
| fab895b85e | |||
| 9d869c4a37 | |||
| 583b6984d8 | |||
| ad912e8f03 | |||
| fe1170b22e | |||
| b69e0a104c | |||
| e23bc927e2 | |||
| 29a17b2e94 | |||
| 18693faa5f | |||
| 378f81efe4 | |||
| 05e3cf811a | |||
| 5815ac2bf4 | |||
| cafa6476ca | |||
| 3007150aeb | |||
| 2199bb3f39 | |||
| 7f52da5f21 | |||
| 4ae88132af | |||
| 327885dd17 | |||
| cf758c2494 | |||
| 6e82d2af37 | |||
| f42a48dbcc | |||
| e95145ff1a | |||
| 4031f35c73 | |||
| 522dc33225 | |||
| 02b7dcb292 | |||
| 5dbcf81d84 | |||
| ff2d4eefb5 | |||
| d745b10d24 | |||
| 2d4786a4fd | |||
| 2d0983f4d1 | |||
| dd344ecc3f | |||
| 93c31888b2 | |||
| 6779caccef | |||
| bf69b5acd1 | |||
| 0424f27264 | |||
| 5ae102071b | |||
| 8b716aeb97 | |||
| 1f5bbb6ce5 | |||
| a94c24581f | |||
| 5383f9bfc4 | |||
| beddc9c7ef | |||
| 0e81334c5f | |||
| dbd269c43b | |||
| 5c3d7d77fc | |||
| 0c48b8f7bf | |||
| 8c09fb4cda | |||
| 0a84598fbe | |||
| ee1e347405 | |||
| 6f8d3b1ad2 | |||
| a91350bcab | |||
| b00425e9c0 | |||
| c82a3477bd | |||
| 040dca3829 | |||
| 8674c1d361 | |||
| 6c9cb4201a | |||
| 7c32a5e824 | |||
| 6c2f9cb62e | |||
| 3cece877f7 | |||
| 8dd700f62e | |||
| 804cdcbf59 | |||
| 885fe7c9e9 | |||
| 930cc52373 | |||
| 4636a213b3 | |||
| 469954f613 | |||
| 5bdcd65d42 | |||
| cb91cd4cc7 | |||
| e04ddaed9b | |||
| 605da5e46c | |||
| 360e8ece85 | |||
| 1543bf9d03 | |||
| 287932d9d7 | |||
| 7c4de31c12 | |||
| 497c6150f7 | |||
| 514f51f111 | |||
| 244dc4fff6 | |||
| 4b39f787c9 | |||
| fb92b9c973 | |||
| 181358db7d | |||
| 3790567d44 | |||
| b3af993236 | |||
| 7ae2bb7635 | |||
| a75f0662ad | |||
| ffd68d5037 | |||
| adf9e2334c | |||
| 4125811017 | |||
| 4e8356b072 | |||
| bad116cdc0 | |||
| 9eac6871a4 | |||
| 3b694689f3 | |||
| 1604ddaa78 | |||
| 16181923b6 | |||
| 62dcaba875 | |||
| f67c05f034 | |||
| 8fd792b85c | |||
| 2595fcc365 | |||
| 0af5b8d84c | |||
| d1e8ff48c5 | |||
| d7964f6fc2 | |||
| 0394093407 | |||
| adcd6abfc7 | |||
| 721eaa5d07 | |||
| 994908ef6a | |||
| dda76ac7b0 | |||
| e7eac26e66 | |||
| 15b61ed56f | |||
| f9a51ffdce | |||
| a47b9a86dc | |||
| 89ca64dd88 | |||
| e5e1c5f82b | |||
| ab1e642c36 | |||
| 1cae3ac092 | |||
| afe5876162 | |||
| 72129f69ee | |||
| a2e2704a05 | |||
| 65af850d68 | |||
| f7425cdc03 | |||
| d9f432ef73 | |||
| 3a86651891 | |||
| 20f304c9cd | |||
| 1f58f989f7 | |||
| 96b1a99e04 | |||
| 1bc7dd3a43 | |||
| a048970be6 | |||
| e3171e5693 | |||
| d463b3df5c | |||
| 1ae7befa4b | |||
| dc80e1e199 | |||
| 35ffc2b0f7 | |||
| 383844dda8 | |||
| 77fb6595a2 | |||
| aa3adbe634 | |||
| 59a700c2f3 | |||
| 18f5a6f476 | |||
| 63a178ca57 | |||
| 0ae949a616 | |||
| edc9720623 | |||
| bbc3fbb0a5 | |||
| 1fc468efac | |||
| 143c415797 | |||
| e79a4d48e1 | |||
| 343325440e | |||
| 5c018892d3 | |||
| be61293cbe | |||
| e493194ce1 | |||
| b61631c33c | |||
| fa65da75bc | |||
| a4468f18dc | |||
| fea28b71ba | |||
| 884ad891f3 | |||
| 058161f798 | |||
| aa68748817 | |||
| 928857596f |
3
.babelrc
@@ -5,7 +5,6 @@
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-optional-chaining",
|
||||
"@babel/plugin-syntax-import-attributes"
|
||||
"@babel/plugin-transform-optional-chaining"
|
||||
],
|
||||
}
|
||||
|
||||
52
.eslintrc
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@
|
||||
|
||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
||||
|
||||
<!-- RTA restricted to adults label -->
|
||||
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
|
||||
|
||||
<title>traxxx - Consent</title>
|
||||
<style>
|
||||
:root {
|
||||
@@ -156,6 +159,12 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rta {
|
||||
position: fixed;
|
||||
bottom: .5rem;
|
||||
right: .5rem;
|
||||
}
|
||||
|
||||
@media(max-width: 800px) {
|
||||
.heading {
|
||||
font-size: 1.25rem;
|
||||
@@ -219,5 +228,12 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src="/img/rta.gif"
|
||||
alt="RTA Restricted To Adults"
|
||||
title="RTA Restricted To Adults"
|
||||
class="rta"
|
||||
>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
/* align-items: stretch; */
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem;
|
||||
border: none;
|
||||
@@ -42,7 +43,7 @@
|
||||
fill: var(--glass);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
@@ -55,6 +56,10 @@
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--glass-weak-30);
|
||||
}
|
||||
}
|
||||
|
||||
.button-label {
|
||||
@@ -305,3 +310,23 @@
|
||||
box-shadow: 0 0 3px var(--shadow-weak-10);
|
||||
}
|
||||
}
|
||||
|
||||
.search-cancel {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: .5rem;
|
||||
|
||||
.icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: var(--glass);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,3 +65,12 @@
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
4
assets/img/icons/at-sign.svg
Executable file
@@ -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
@@ -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 |
5
assets/img/icons/checkbox-checked.svg
Executable 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 |
4
assets/img/icons/checkbox-checked2.svg
Executable 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 |
4
assets/img/icons/checkbox-partial.svg
Executable 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 |
4
assets/img/icons/checkbox-partial2.svg
Executable 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 |
4
assets/img/icons/checkbox-unchecked.svg
Executable 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 |
4
assets/img/icons/checkbox-unchecked2.svg
Executable 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 |
12
assets/img/icons/collaboration.svg
Executable 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
@@ -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 |
6
assets/img/icons/file-video.svg
Executable file
@@ -0,0 +1,6 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.341 3.579c-0.347-0.473-0.831-1.027-1.362-1.558s-1.085-1.015-1.558-1.362c-0.806-0.591-1.197-0.659-1.421-0.659h-7.75c-0.689 0-1.25 0.561-1.25 1.25v13.5c0 0.689 0.561 1.25 1.25 1.25h11.5c0.689 0 1.25-0.561 1.25-1.25v-9.75c0-0.224-0.068-0.615-0.659-1.421v0 0zM12.271 2.729c0.48 0.48 0.856 0.912 1.134 1.271h-2.406v-2.405c0.359 0.278 0.792 0.654 1.271 1.134v0 0zM14 14.75c0 0.136-0.114 0.25-0.25 0.25h-11.5c-0.135 0-0.25-0.114-0.25-0.25v-13.5c0-0.135 0.115-0.25 0.25-0.25 0 0 7.749-0 7.75 0v3.5c0 0.276 0.224 0.5 0.5 0.5h3.5v9.75z"></path>
|
||||
<path d="M4 8h5v5h-5v-5z"></path>
|
||||
<path d="M9 10l3-2v5l-3-2z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 756 B |
4
assets/img/icons/interset.svg
Executable file
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 2h-9c-0.825 0-1.5 0.675-1.5 1.5v1.5h-2.5c-0.825 0-1.5 0.675-1.5 1.5v6c0 0.825 0.675 1.5 1.5 1.5h9c0.825 0 1.5-0.675 1.5-1.5v-1.5h2.5c0.825 0 1.5-0.675 1.5-1.5v-6c0-0.825-0.675-1.5-1.5-1.5zM11 12.5c0 0.271-0.229 0.5-0.5 0.5h-9c-0.271 0-0.5-0.229-0.5-0.5v-6c0-0.271 0.229-0.5 0.5-0.5h2.5v3.5c0 0.825 0.675 1.5 1.5 1.5h5.5v1.5zM15 9.5c0 0.271-0.229 0.5-0.5 0.5h-9c-0.271 0-0.5-0.229-0.5-0.5v-6c0-0.271 0.229-0.5 0.5-0.5h9c0.271 0 0.5 0.229 0.5 0.5v6z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 610 B |
4
assets/img/icons/profile.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M13.5 0h-12c-0.825 0-1.5 0.675-1.5 1.5v13c0 0.825 0.675 1.5 1.5 1.5h12c0.825 0 1.5-0.675 1.5-1.5v-13c0-0.825-0.675-1.5-1.5-1.5zM13 14h-11v-12h11v12zM4 9h7v1h-7zM4 11h7v1h-7zM5 4.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5zM7.5 6h-2c-0.825 0-1.5 0.45-1.5 1v1h5v-1c0-0.55-0.675-1-1.5-1z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
4
assets/img/icons/stack-plus.svg
Executable 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,3 +1,132 @@
|
||||
<script setup>
|
||||
import { format, subYears } from 'date-fns';
|
||||
import { parse } from 'path-to-regexp';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import ActorTile from '#/components/actors/tile.vue';
|
||||
import BirthdateFilter from '#/components/filters/birthdate.vue';
|
||||
import BoobsFilter from '#/components/filters/boobs.vue';
|
||||
|
||||
import CountryFilter from '#/components/filters/country.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import GenderFilter from '#/components/filters/gender.vue';
|
||||
import PhysiqueFilter from '#/components/filters/physique.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
import { get } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps, urlParsed, routeParams } = pageContext;
|
||||
|
||||
const q = ref(urlParsed.search.q);
|
||||
const actors = ref([]);
|
||||
const pageStash = pageProps.stash;
|
||||
const countries = ref(pageProps.countries);
|
||||
const cupRange = ref(pageProps.cupRange);
|
||||
|
||||
actors.value = pageProps.actors;
|
||||
|
||||
const currentPage = ref(Number(routeParams.page));
|
||||
const total = ref(Number(pageProps.actorTotal || pageProps.total));
|
||||
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
|
||||
|
||||
const filters = ref({
|
||||
gender: urlParsed.search.gender,
|
||||
ageRequired: !!urlParsed.search.age,
|
||||
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
|
||||
dobRequired: !!urlParsed.search.dob,
|
||||
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
|
||||
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
|
||||
country: urlParsed.search.c,
|
||||
braSizeRequired: !!urlParsed.search.cup,
|
||||
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
|
||||
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
|
||||
heightRequired: !!urlParsed.search.height,
|
||||
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
|
||||
weightRequired: !!urlParsed.search.weight,
|
||||
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
|
||||
avatarRequired: !!urlParsed.search.avatar,
|
||||
});
|
||||
|
||||
function getPath(preserveQuery) {
|
||||
const path = parse(routeParams.path).map((segment) => {
|
||||
if (segment.name === 'page') {
|
||||
return `${segment.prefix}${currentPage.value || 1}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
}).join('');
|
||||
|
||||
if (preserveQuery && urlParsed.searchOriginal) {
|
||||
return `${path}${urlParsed.searchOriginal}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function search(options = {}) {
|
||||
if (options.resetPage !== false) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
if (options.autoScope !== false) {
|
||||
if (q.value) {
|
||||
order.value = 'results.desc';
|
||||
}
|
||||
|
||||
if (!q.value && order.value.includes('results')) {
|
||||
order.value = 'likes.desc';
|
||||
}
|
||||
}
|
||||
|
||||
const query = {
|
||||
q: q.value || undefined,
|
||||
order: order.value,
|
||||
gender: filters.value.gender || undefined,
|
||||
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
|
||||
dob: filters.value.dobRequired ? filters.value.dob : undefined,
|
||||
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
|
||||
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
|
||||
c: filters.value.country || undefined,
|
||||
nb: filters.value.naturalBoobs,
|
||||
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
|
||||
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
|
||||
avatar: filters.value.avatarRequired || undefined,
|
||||
stashId: pageStash?.id,
|
||||
};
|
||||
|
||||
navigate(getPath(false), query, { redirect: false });
|
||||
|
||||
const res = await get('/actors', {
|
||||
...query,
|
||||
page: currentPage.value, // client uses param rather than query pagination
|
||||
});
|
||||
|
||||
actors.value = res.actors;
|
||||
total.value = res.total;
|
||||
|
||||
countries.value = res.countries;
|
||||
|
||||
events.emit('scrollUp');
|
||||
}
|
||||
|
||||
function paginate({ page }) {
|
||||
currentPage.value = page;
|
||||
|
||||
search({ resetPage: false });
|
||||
}
|
||||
|
||||
function updateFilter(prop, value, reload = true) {
|
||||
filters.value[prop] = value;
|
||||
|
||||
if (reload) {
|
||||
search();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Filters :results="total">
|
||||
@@ -5,12 +134,19 @@
|
||||
<!-- onsearch not compatible with FF and Safari -->
|
||||
<input
|
||||
v-model="q"
|
||||
type="search"
|
||||
type="input"
|
||||
placeholder="Search actors"
|
||||
class="input search"
|
||||
@search="search"
|
||||
@keydown.enter="search"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-if="q"
|
||||
icon="cross2"
|
||||
class="search-button"
|
||||
@click="q = ''; search()"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
icon="search"
|
||||
class="search-button"
|
||||
@@ -104,135 +240,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import { format, subYears } from 'date-fns';
|
||||
import { parse } from 'path-to-regexp';
|
||||
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { get } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
|
||||
import ActorTile from '#/components/actors/tile.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import GenderFilter from '#/components/filters/gender.vue';
|
||||
import BirthdateFilter from '#/components/filters/birthdate.vue';
|
||||
import BoobsFilter from '#/components/filters/boobs.vue';
|
||||
import PhysiqueFilter from '#/components/filters/physique.vue';
|
||||
import CountryFilter from '#/components/filters/country.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps, urlParsed, routeParams } = pageContext;
|
||||
|
||||
const q = ref(urlParsed.search.q);
|
||||
const actors = ref([]);
|
||||
const pageStash = pageProps.stash;
|
||||
const countries = ref(pageProps.countries);
|
||||
const cupRange = ref(pageProps.cupRange);
|
||||
|
||||
actors.value = pageProps.actors;
|
||||
|
||||
const currentPage = ref(Number(routeParams.page));
|
||||
const total = ref(Number(pageProps.actorTotal || pageProps.total));
|
||||
const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
|
||||
|
||||
const filters = ref({
|
||||
gender: urlParsed.search.gender,
|
||||
ageRequired: !!urlParsed.search.age,
|
||||
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
|
||||
dobRequired: !!urlParsed.search.dob,
|
||||
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
|
||||
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
|
||||
country: urlParsed.search.c,
|
||||
braSizeRequired: !!urlParsed.search.cup,
|
||||
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
|
||||
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
|
||||
heightRequired: !!urlParsed.search.height,
|
||||
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
|
||||
weightRequired: !!urlParsed.search.weight,
|
||||
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
|
||||
avatarRequired: !!urlParsed.search.avatar,
|
||||
});
|
||||
|
||||
function getPath(preserveQuery) {
|
||||
const path = parse(routeParams.path).map((segment) => {
|
||||
if (segment.name === 'page') {
|
||||
return `${segment.prefix}${1}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
}).join('');
|
||||
|
||||
if (preserveQuery && urlParsed.searchOriginal) {
|
||||
return `${path}${urlParsed.searchOriginal}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function search(options = {}) {
|
||||
if (options.resetPage !== false) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
if (options.autoScope !== false) {
|
||||
if (q.value) {
|
||||
order.value = 'results.desc';
|
||||
}
|
||||
|
||||
if (!q.value && order.value.includes('results')) {
|
||||
order.value = 'likes.desc';
|
||||
}
|
||||
}
|
||||
|
||||
const query = {
|
||||
q: q.value || undefined,
|
||||
order: order.value,
|
||||
gender: filters.value.gender || undefined,
|
||||
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
|
||||
dob: filters.value.dobRequired ? filters.value.dob : undefined,
|
||||
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
|
||||
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
|
||||
c: filters.value.country || undefined,
|
||||
nb: filters.value.naturalBoobs,
|
||||
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
|
||||
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
|
||||
avatar: filters.value.avatarRequired || undefined,
|
||||
stashId: pageStash?.id,
|
||||
};
|
||||
|
||||
navigate(getPath(false), query, { redirect: false });
|
||||
|
||||
const res = await get('/actors', {
|
||||
...query,
|
||||
page: currentPage.value, // client uses param rather than query pagination
|
||||
});
|
||||
|
||||
actors.value = res.actors;
|
||||
total.value = res.total;
|
||||
|
||||
countries.value = res.countries;
|
||||
|
||||
events.emit('scrollUp');
|
||||
}
|
||||
|
||||
function paginate({ page }) {
|
||||
currentPage.value = page;
|
||||
|
||||
search({ resetPage: false });
|
||||
}
|
||||
|
||||
function updateFilter(prop, value, reload = true) {
|
||||
filters.value[prop] = value;
|
||||
|
||||
if (reload) {
|
||||
search();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gender-button {
|
||||
&.selected .gender .icon {
|
||||
|
||||
176
components/actors/add.vue
Normal file
@@ -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>
|
||||
@@ -1,3 +1,99 @@
|
||||
<script setup>
|
||||
import formatTemplate from 'template-format';
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
const props = defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
const enhancedTooltipId = useId();
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, env } = pageContext;
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
// if the profile is empty, the expand button overlaps the header
|
||||
const showExpand = [
|
||||
'age',
|
||||
'bust',
|
||||
'cup',
|
||||
'eyes',
|
||||
'ethnicity',
|
||||
'hairColor',
|
||||
'hasPiercings',
|
||||
'hasTattoos',
|
||||
'height',
|
||||
'hip',
|
||||
'naturalBoobs',
|
||||
'origin',
|
||||
'residence',
|
||||
'waist',
|
||||
'weight',
|
||||
].some((attribute) => !!props.actor[attribute]);
|
||||
|
||||
const augmentationMap = {
|
||||
bbl: 'BBL',
|
||||
fat: 'fat transfer',
|
||||
lift: 'direct lift',
|
||||
lipo: 'lipo without BBL',
|
||||
filler: 'filler',
|
||||
mms: 'MMS',
|
||||
over: 'over-muscle',
|
||||
under: 'under-muscle',
|
||||
mammary: 'under breast',
|
||||
areolar: 'areolar',
|
||||
crescent: 'crescent areolar',
|
||||
lollipop: 'lollipop',
|
||||
axillary: 'armpit',
|
||||
umbilical: 'navel',
|
||||
};
|
||||
|
||||
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
|
||||
.filter((profile) => !!profile.description)
|
||||
.map((profile) => [profile.descriptionHash, {
|
||||
text: profile.description,
|
||||
entity: profile.entity,
|
||||
}])));
|
||||
|
||||
function getSocialUrl(social) {
|
||||
if (social.url) {
|
||||
return social.url;
|
||||
}
|
||||
|
||||
if (pageContext.env.socials.urls[social.platform]) {
|
||||
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
|
||||
const aliases = Object
|
||||
.entries(props.actor.aliases.reduce((acc, alias) => {
|
||||
acc[alias.name] = (props.actor[alias.name] || 0) + 1;
|
||||
return acc;
|
||||
}, {}))
|
||||
.toSorted(([, countA], [, countB]) => countB - countA)
|
||||
.map(([alias]) => alias)
|
||||
.filter((alias) => alias !== props.actor.name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="profile"
|
||||
@@ -20,6 +116,15 @@
|
||||
</div>
|
||||
|
||||
<ul class="bio nolist">
|
||||
<li
|
||||
v-if="aliases.length > 0"
|
||||
class="bio-item"
|
||||
>
|
||||
<dfn class="bio-label"><Icon icon="at-sign" />Aliases</dfn>
|
||||
|
||||
<span class="orientation">{{ aliases.join(', ') }}</span>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="actor.dateOfBirth"
|
||||
class="bio-item"
|
||||
@@ -44,7 +149,7 @@
|
||||
<dfn class="bio-label"><Icon icon="cake" />Age</dfn>
|
||||
|
||||
<span
|
||||
:title="'Exact date of birth or age unknown'"
|
||||
title="Exact date of birth or age unknown"
|
||||
class="birthdate"
|
||||
>> {{ actor.age }}</span>
|
||||
</li>
|
||||
@@ -80,10 +185,17 @@
|
||||
<span
|
||||
v-if="actor.origin.city"
|
||||
class="city"
|
||||
>{{ actor.origin.city }}</span><span
|
||||
>{{ actor.origin.city }}</span>
|
||||
|
||||
<span
|
||||
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
|
||||
class="state"
|
||||
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span>
|
||||
>
|
||||
{{ actor.origin.city
|
||||
? [',', actor.origin.state].join(' ')
|
||||
: actor.origin.state
|
||||
}}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="actor.origin.country"
|
||||
@@ -105,7 +217,7 @@
|
||||
class="bio-item residence"
|
||||
:class="{ hideable: !!actor.origin }"
|
||||
>
|
||||
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
|
||||
<dfn class="bio-label"><Icon icon="location" />{{ actor.dateOfDeath ? 'Lived' : 'Lives' }} in</dfn>
|
||||
|
||||
<span>
|
||||
<span
|
||||
@@ -146,7 +258,7 @@
|
||||
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
|
||||
<Icon
|
||||
v-if="actor.naturalBoobs === false || actor.naturalButt === false"
|
||||
v-tooltip="[actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', ')"
|
||||
v-tooltip="{ content: [actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', '), ariaId: enhancedTooltipId }"
|
||||
icon="magic-wand2"
|
||||
class="enhanced"
|
||||
/>
|
||||
@@ -168,7 +280,7 @@
|
||||
actor.boobsVolume && `${actor.boobsVolume}cc`,
|
||||
augmentationMap[actor.boobsPlacement] || actor.boobsPlacement,
|
||||
augmentationMap[actor.boobsIncision] || actor.boobsIncision,
|
||||
augmentationMap[actor.boobsImplant] || actor.boobsImplant
|
||||
augmentationMap[actor.boobsImplant] || actor.boobsImplant,
|
||||
].filter(Boolean).join(' ')"
|
||||
class="augmentations-section"
|
||||
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">: </template>
|
||||
@@ -208,7 +320,7 @@
|
||||
<span>
|
||||
<Icon
|
||||
v-if="actor.isCircumcised"
|
||||
:title="'Circumcised'"
|
||||
title="Circumcised"
|
||||
icon="pagebreak"
|
||||
class="circumcised"
|
||||
/>
|
||||
@@ -312,10 +424,10 @@
|
||||
<a
|
||||
v-for="social in socials"
|
||||
:key="`social-${social.id}`"
|
||||
v-tooltip="{ content: social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url, ariaId: `social-${social.id}` }"
|
||||
:href="getSocialUrl(social)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:title="social.platform || social.url"
|
||||
class="social ellipsis"
|
||||
>
|
||||
<Icon
|
||||
@@ -339,7 +451,9 @@
|
||||
:class="`icon-social icon-${social.platform} icon-generic`"
|
||||
/>
|
||||
|
||||
<!--
|
||||
<template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
|
||||
-->
|
||||
</a>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -356,7 +470,7 @@
|
||||
:href="`/actor/edit/${actor.id}/${actor.slug}`"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Edit bio</a>
|
||||
>Edit</a>
|
||||
|
||||
<a
|
||||
:href="`/actor/revs/${actor.id}/${actor.slug}`"
|
||||
@@ -420,92 +534,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import formatTemplate from 'template-format';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, env } = pageContext;
|
||||
|
||||
const props = defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
// if the profile is empty, the expand button overlaps the header
|
||||
const showExpand = [
|
||||
'age',
|
||||
'bust',
|
||||
'cup',
|
||||
'eyes',
|
||||
'ethnicity',
|
||||
'hairColor',
|
||||
'hasPiercings',
|
||||
'hasTattoos',
|
||||
'height',
|
||||
'hip',
|
||||
'naturalBoobs',
|
||||
'origin',
|
||||
'residence',
|
||||
'waist',
|
||||
'weight',
|
||||
].some((attribute) => !!props.actor[attribute]);
|
||||
|
||||
const augmentationMap = {
|
||||
bbl: 'BBL',
|
||||
fat: 'fat transfer',
|
||||
lift: 'direct lift',
|
||||
lipo: 'lipo without BBL',
|
||||
filler: 'filler',
|
||||
mms: 'MMS',
|
||||
over: 'over-muscle',
|
||||
under: 'under-muscle',
|
||||
mammary: 'under breast',
|
||||
areolar: 'areolar',
|
||||
crescent: 'crescent areolar',
|
||||
lollipop: 'lollipop',
|
||||
axillary: 'armpit',
|
||||
umbilical: 'navel',
|
||||
};
|
||||
|
||||
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
|
||||
.filter((profile) => !!profile.description)
|
||||
.map((profile) => [profile.descriptionHash, {
|
||||
text: profile.description,
|
||||
entity: profile.entity,
|
||||
}])));
|
||||
|
||||
function getSocialUrl(social) {
|
||||
if (social.url) {
|
||||
return social.url;
|
||||
}
|
||||
|
||||
if (pageContext.env.socials.urls[social.platform]) {
|
||||
return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.header-gender {
|
||||
display: inline-flex;
|
||||
@@ -735,19 +763,23 @@ const socials = props.actor.socials.map((social) => ({
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/*
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
grid-gap: 0 0;
|
||||
overflow: hidden;
|
||||
*/
|
||||
gap: .25rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.social {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .1rem .5rem;
|
||||
justify-content: center;
|
||||
padding: .75rem .75rem;
|
||||
border-radius: .25rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -755,10 +787,6 @@ const socials = props.actor.socials.map((social) => ({
|
||||
font-weight: normal;
|
||||
background: var(--highlight-weak-40);
|
||||
|
||||
.icon {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
.icon-generic {
|
||||
fill: var(--highlight);
|
||||
}
|
||||
@@ -783,6 +811,7 @@ const socials = props.actor.socials.map((social) => ({
|
||||
.link {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,7 +883,7 @@ const socials = props.actor.socials.map((social) => ({
|
||||
display: none;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
z-index: 1;
|
||||
bottom: -.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
gender: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="gender"
|
||||
@@ -8,15 +17,6 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
gender: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gender {
|
||||
&.female .icon {
|
||||
|
||||
302
components/actors/merge.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, useId } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
|
||||
const props = defineProps({
|
||||
actors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'merged']);
|
||||
|
||||
const targetActor = ref(null);
|
||||
const actorInput = ref(null);
|
||||
const comment = ref(null);
|
||||
const actorQuery = ref('');
|
||||
const actorResults = ref([]);
|
||||
const submitted = ref(false);
|
||||
const merged = ref(false);
|
||||
const searchDropdownId = useId();
|
||||
const conflictTooltipId = useId();
|
||||
|
||||
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
|
||||
|
||||
async function searchActors() {
|
||||
const res = await get('/actors', {
|
||||
q: actorQuery.value, // return partial matches
|
||||
limit: 10,
|
||||
global: true,
|
||||
});
|
||||
|
||||
actorResults.value = res.actors;
|
||||
}
|
||||
|
||||
function getActorNames() {
|
||||
if (props.actors.length > 1) {
|
||||
return `${props.actors.length} actors`;
|
||||
}
|
||||
|
||||
if (props.actors[0].entity) {
|
||||
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
|
||||
}
|
||||
|
||||
return props.actors[0].name;
|
||||
}
|
||||
|
||||
async function merge() {
|
||||
submitted.value = true;
|
||||
|
||||
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, {
|
||||
comment: comment.value || null,
|
||||
}, {
|
||||
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
|
||||
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
submitted.value = false;
|
||||
merged.value = true;
|
||||
|
||||
emit('merged');
|
||||
|
||||
// emit('close');
|
||||
}
|
||||
|
||||
function selectActor(actor) {
|
||||
targetActor.value = actor;
|
||||
actorQuery.value = '';
|
||||
actorResults.value = [];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
actorInput.value.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div
|
||||
v-if="merged"
|
||||
class="dialog-body"
|
||||
>
|
||||
Merge successful
|
||||
|
||||
<ul class="options">
|
||||
<li class="option">
|
||||
<a
|
||||
href=""
|
||||
class="link"
|
||||
>Reload page</a>
|
||||
</li>
|
||||
|
||||
<li class="option">
|
||||
<a
|
||||
:href="`/actor/${targetActor?.id}/${targetActor?.slug}`"
|
||||
class="link"
|
||||
>Go to #{{ targetActor?.id }} {{ targetActor?.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="dialog-body"
|
||||
>
|
||||
<ul class="actors nolist">
|
||||
<li
|
||||
v-for="actor in actors"
|
||||
:key="`actor-${actor.id}`"
|
||||
class="actor"
|
||||
>
|
||||
<span class="source">
|
||||
<strong class="source-name">{{ actor.name }}<template v-if="actor.entity"> ({{ actor.entity.name }})</template></strong>
|
||||
<span class="source-id">#{{ actor.id }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="path">merging into</span>
|
||||
|
||||
<div
|
||||
v-if="targetActor"
|
||||
class="target"
|
||||
>
|
||||
<strong class="target-name">
|
||||
<span class="target-id">#{{ targetActor.id }}</span>
|
||||
{{ targetActor.name }}
|
||||
</strong>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
@click="targetActor = null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<VDropdown
|
||||
:aria-id="searchDropdownId"
|
||||
:triggers="[]"
|
||||
:shown="actorResults.length > 0"
|
||||
:auto-hide="false"
|
||||
>
|
||||
<input
|
||||
ref="actorInput"
|
||||
v-model="actorQuery"
|
||||
class="input"
|
||||
placeholder="Search target actor"
|
||||
@input="searchActors"
|
||||
>
|
||||
|
||||
<template #popper>
|
||||
<ul
|
||||
class="results nolist"
|
||||
>
|
||||
<li
|
||||
v-for="actorResult in actorResults"
|
||||
:key="`actor-result-${actorResult.id}`"
|
||||
v-close-popper
|
||||
class="result-item"
|
||||
@click="selectActor(actorResult)"
|
||||
>
|
||||
<div class="result-label">
|
||||
<span class="result-id">#{{ actorResult.id }}</span> {{ actorResult.name }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<textarea
|
||||
v-model="comment"
|
||||
placeholder="Optional comment"
|
||||
rows="2"
|
||||
class="input comment"
|
||||
/>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="sourceTargetConflict && { content: 'Cannot merge actor profile into itself', ariaId: conflictTooltipId }"
|
||||
type="submit"
|
||||
class="button button-primary"
|
||||
:disabled="!targetActor || sourceTargetConflict"
|
||||
@click="merge"
|
||||
>Merge</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.actors {
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
|
||||
&.comment {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--glass-strong-20);
|
||||
}
|
||||
|
||||
.target {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
padding: .25rem .75rem;
|
||||
fill: var(--glass);
|
||||
|
||||
&:hover {
|
||||
fill: var(--error);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.source-id,
|
||||
.target-id {
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.results {
|
||||
padding: .25rem 0;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.result-id {
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
padding: 0 1.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.75;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,37 @@
|
||||
<script setup>
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['actor']);
|
||||
const dropdownId = useId();
|
||||
const actors = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/actors', { q: query.value });
|
||||
|
||||
actors.value = data.actors;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDropdown
|
||||
:aria-id="dropdownId"
|
||||
:disabled="disabled"
|
||||
class="trigger"
|
||||
@show="focus"
|
||||
@@ -36,38 +68,6 @@
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const actors = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actor']);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/actors', { q: query.value });
|
||||
|
||||
actors.value = data.actors;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
<script setup>
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
import Gender from './gender.vue';
|
||||
|
||||
const props = defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, restriction } = pageContext;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
|
||||
const entityTooltipId = useId();
|
||||
const aliasTooltipId = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tile"
|
||||
@@ -41,7 +67,7 @@
|
||||
|
||||
<span
|
||||
v-if="actor.ageAtDeath"
|
||||
:title="`Passed ${formatDate(actor.ageAtDeath, 'MMMM d, yyyy')}`"
|
||||
:title="`Passed ${formatDate(actor.dateOfDeath, 'MMMM d, yyyy')}`"
|
||||
class="age age-death"
|
||||
>{{ actor.ageAtDeath }}</span>
|
||||
|
||||
@@ -74,42 +100,32 @@
|
||||
</div>
|
||||
|
||||
<span class="label">
|
||||
<span class="name ellipsis">{{ actor.name }}</span>
|
||||
<span
|
||||
class="name ellipsis"
|
||||
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
|
||||
>{{ actor.name }}</span>
|
||||
|
||||
<img
|
||||
v-if="actor.entity"
|
||||
v-tooltip="actor.entity.name"
|
||||
v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
|
||||
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
|
||||
class="favicon"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-if="actor.alias && actor.alias.name !== actor.name"
|
||||
v-tooltip="{
|
||||
content: `Credited as '${actor.alias.name}'`,
|
||||
triggers: ['hover', 'click'],
|
||||
ariaId: aliasTooltipId,
|
||||
}"
|
||||
icon="at-sign"
|
||||
class="alias"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
import Gender from './gender.vue';
|
||||
|
||||
const props = defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, restriction } = pageContext;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id === currentStash?.id));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tile {
|
||||
display: flex;
|
||||
@@ -128,6 +144,10 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
||||
:deep(.bookmarks) .icon:not(.favorited):not(:hover) {
|
||||
fill: var(--text-light);
|
||||
}
|
||||
|
||||
.menu {
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
|
||||
&.unstashed {
|
||||
@@ -135,7 +155,16 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: .5rem .75rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
fill: var(--highlight-strong-20);
|
||||
}
|
||||
|
||||
.label {
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -149,7 +178,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
||||
}
|
||||
|
||||
.name {
|
||||
padding: .35rem .25rem .35rem .5rem;
|
||||
padding: 0 .25rem 0 .5rem;
|
||||
}
|
||||
|
||||
.favicon {
|
||||
@@ -246,4 +275,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
||||
height: .75rem;
|
||||
margin-left: .25rem;
|
||||
}
|
||||
|
||||
.alias {
|
||||
height: 100%;
|
||||
fill: var(--glass-weak-20);
|
||||
padding: 0 .25rem;
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
async function initCaches() {
|
||||
await post('/caches', null, {
|
||||
successFeedback: 'Reloaded caches',
|
||||
errorFeedback: 'Failed to reload caches',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<nav class="nav">
|
||||
<ul class="nav-items nolist">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="/admin/actors"
|
||||
class="nav-link nolink"
|
||||
:class="{ active: pageContext.routeParams.section === 'actors' }"
|
||||
>Actors</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="/admin/revisions/scenes"
|
||||
@@ -25,6 +49,17 @@
|
||||
:class="{ active: pageContext.routeParams.section === 'entities' }"
|
||||
>Entity Health</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="pageContext.user.role === 'admin'"
|
||||
class="nav-item"
|
||||
>
|
||||
<a
|
||||
href="/admin/emails"
|
||||
class="nav-link nolink"
|
||||
:class="{ active: pageContext.urlPathname.startsWith('/admin/emails') }"
|
||||
>E-mails</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
@@ -39,22 +74,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
async function initCaches() {
|
||||
await post('/caches', null, {
|
||||
successFeedback: 'Reloaded caches',
|
||||
errorFeedback: 'Failed to reload caches',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,133 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
|
||||
import { del, get } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const alerts = ref(pageContext.pageProps.alerts);
|
||||
const showAlertDialog = ref(false);
|
||||
const done = ref(true);
|
||||
|
||||
const query = ref('');
|
||||
const filterActors = ref(false);
|
||||
const filterEntities = ref(false);
|
||||
const filterTags = ref(false);
|
||||
const filterExpressions = ref(false);
|
||||
const filterScenes = ref(false);
|
||||
const filterCombined = ref(null);
|
||||
|
||||
const filteredAlerts = computed(() => {
|
||||
const queryRegex = new RegExp(query.value, 'i');
|
||||
|
||||
return alerts.value.filter((alert) => {
|
||||
if (filterActors.value && alert.actors.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterEntities.value && alert.entities.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterTags.value && alert.tags.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterExpressions.value && alert.matches.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterScenes.value && alert.scenes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches, ...alert.scenes].length > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches, ...alert.scenes].length === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (queryRegex.test(alert.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.scenes.some((scene) => queryRegex.test(scene.title))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
async function reloadAlerts() {
|
||||
alerts.value = await get('/alerts');
|
||||
}
|
||||
|
||||
async function removeAlert(alert) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-alert
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
const alertLabel = [
|
||||
...alert.actors.map((actor) => actor.name),
|
||||
...alert.tags.map((tag) => tag.name),
|
||||
...alert.entities.map((entity) => entity.name),
|
||||
...alert.matches.map((match) => match.expression),
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
try {
|
||||
await del(`/alerts/${alert.id}`, {
|
||||
undoFeedback: `Removed alert for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
await reloadAlerts();
|
||||
}
|
||||
finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const filterCombinedStates = [null, true, false];
|
||||
|
||||
function toggleFilterCombined() {
|
||||
const index = filterCombinedStates.indexOf(filterCombined.value);
|
||||
|
||||
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Alerts</h3>
|
||||
|
||||
<button
|
||||
class="button"
|
||||
@click="showAlertDialog = true"
|
||||
@@ -22,37 +147,45 @@
|
||||
|
||||
<div class="filters-section">
|
||||
<Icon
|
||||
v-tooltip="'Only show actor alerts'"
|
||||
icon="star"
|
||||
title="Only show actor alerts"
|
||||
class="noselect"
|
||||
:class="{ active: filterActors }"
|
||||
@click="filterActors = !filterActors"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'Only show tag alerts'"
|
||||
icon="price-tags"
|
||||
title="Only show tag alerts"
|
||||
class="noselect"
|
||||
:class="{ active: filterTags }"
|
||||
@click="filterTags = !filterTags"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'Only show channel alerts'"
|
||||
icon="device_hub"
|
||||
title="Only show channel alerts"
|
||||
class="noselect"
|
||||
:class="{ active: filterEntities }"
|
||||
@click="filterEntities = !filterEntities"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'Only show expression alerts'"
|
||||
icon="regexp"
|
||||
title="Only show expression alerts"
|
||||
class="noselect"
|
||||
:class="{ active: filterExpressions }"
|
||||
@click="filterExpressions = !filterExpressions"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'Only show scene release date alerts'"
|
||||
icon="clapboard-play"
|
||||
class="noselect"
|
||||
:class="{ active: filterScenes }"
|
||||
@click="filterScenes = !filterScenes"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="filterCombined === false"
|
||||
icon="target3"
|
||||
@@ -163,50 +296,64 @@
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-for="scene in alert.scenes"
|
||||
:key="`alert-${alert.id}-${scene.id}`"
|
||||
class="alert-scene"
|
||||
>
|
||||
<time
|
||||
:datetime="scene.date.toISOString()"
|
||||
class="alert-date"
|
||||
>{{ format(scene.date, 'yyyy-MM-dd') }}</time>
|
||||
|
||||
<a
|
||||
:href="`/scene/${scene.id}/${scene.slug}`"
|
||||
class="alert-value alert-title ellipsis link"
|
||||
>{{ scene.title }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="alert-meta">
|
||||
<div class="alert-triggers">
|
||||
<Icon
|
||||
v-if="alert.notify && alert.isFromPreset"
|
||||
v-tooltip="'Notify in traxxx, added as quick alert'"
|
||||
v-tooltip="{ content: alert.notify ? 'Notify in traxxx, added as quick alert' : undefined, ariaId: `alert-notify-preset-${alert.id}` }"
|
||||
icon="bell-plus"
|
||||
class="trigger"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="alert.notify"
|
||||
v-tooltip="'Notify in traxxx'"
|
||||
v-else
|
||||
v-tooltip="{ content: alert.notify ? 'Notify in traxxx' : undefined, ariaId: `alert-notify-${alert.id}` }"
|
||||
icon="bell2"
|
||||
class="trigger"
|
||||
:class="{ trigger: alert.notify }"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'E-mail me'"
|
||||
icon="envelop5"
|
||||
:class="{ trigger: alert.email }"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="alert.stashes.some((stash) => !stash.isPrimary)"
|
||||
v-tooltip="`Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`"
|
||||
v-tooltip="{ content: `Add to ${alert.stashes.map((stash) => stash.name).join(', ')}`, ariaId: `alert-stashes-${alert.id}` }"
|
||||
icon="folder-heart"
|
||||
class="trigger"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="alert.stashes.length > 0"
|
||||
v-tooltip="alert.stashes.length > 0 ? 'Add to Favorites' : undefined"
|
||||
v-else
|
||||
v-tooltip="alert.stashes.length > 0 ? { content: 'Add to Favorites', ariaId: `alert-favorites-${alert.id}` } : undefined"
|
||||
icon="heart7"
|
||||
class="trigger"
|
||||
:class="{ trigger: alert.stashes.length > 0 }"
|
||||
/>
|
||||
|
||||
<!--
|
||||
<Icon
|
||||
icon="envelop5"
|
||||
title="E-mail me"
|
||||
:class="{ trigger: alert.email }"
|
||||
/>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<div class="alert-actions">
|
||||
<span
|
||||
v-tooltip="format(alert.createdAt, 'yyyy-MM-dd hh:mm')"
|
||||
v-tooltip="{ content: format(alert.createdAt, 'yyyy-MM-dd hh:mm'), ariaId: `alert-created-${alert.id}` }"
|
||||
class="alert-id"
|
||||
title="Alert ID"
|
||||
>#{{ alert.id }}</span>
|
||||
@@ -227,124 +374,11 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
|
||||
import { get, del } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const alerts = ref(pageContext.pageProps.alerts);
|
||||
const showAlertDialog = ref(false);
|
||||
const done = ref(true);
|
||||
|
||||
const query = ref('');
|
||||
const filterActors = ref(false);
|
||||
const filterEntities = ref(false);
|
||||
const filterTags = ref(false);
|
||||
const filterExpressions = ref(false);
|
||||
const filterCombined = ref(null);
|
||||
|
||||
const filteredAlerts = computed(() => {
|
||||
const queryRegex = new RegExp(query.value, 'i');
|
||||
|
||||
return alerts.value.filter((alert) => {
|
||||
if (filterActors.value && alert.actors.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterEntities.value && alert.entities.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterTags.value && alert.tags.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterExpressions.value && alert.matches.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === false && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCombined.value === true && [...alert.actors, ...alert.entities, ...alert.tags, ...alert.matches].length === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (queryRegex.test(alert.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.actors.some((actor) => queryRegex.test(actor.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.tags.some((tag) => queryRegex.test(tag.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.entities.some((entity) => queryRegex.test(entity.name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.matches.some((match) => queryRegex.test(match.expression))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
async function reloadAlerts() {
|
||||
alerts.value = await get('/alerts');
|
||||
}
|
||||
|
||||
async function removeAlert(alert) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to remove alert #${alert.id}?`)) { // eslint-disable-line no-restricted-globals, no-alert
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
const alertLabel = [
|
||||
...alert.actors.map((actor) => actor.name),
|
||||
...alert.tags.map((tag) => tag.name),
|
||||
...alert.entities.map((entity) => entity.name),
|
||||
...alert.matches.map((match) => match.expression),
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
try {
|
||||
await del(`/alerts/${alert.id}`, {
|
||||
undoFeedback: `Removed alert for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
await reloadAlerts();
|
||||
} finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const filterCombinedStates = [null, true, false];
|
||||
|
||||
function toggleFilterCombined() {
|
||||
const index = filterCombinedStates.indexOf(filterCombined.value);
|
||||
|
||||
filterCombined.value = filterCombinedStates[(index + 1) % filterCombinedStates.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
@@ -475,6 +509,16 @@ function toggleFilterCombined() {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.alert-scene {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
.alert-date {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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> {{ match.expression }}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
><Icon icon="plus3" />Add expression</button>
|
||||
|
||||
<template #popper>
|
||||
<form @submit.prevent="addMatch">
|
||||
<select
|
||||
v-model="matchProperty"
|
||||
class="input"
|
||||
>
|
||||
<option value="title">Title</option>
|
||||
<option value="description">Description</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-model="matchExpression"
|
||||
placeholder="Expression, // for regex"
|
||||
class="input"
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section then">
|
||||
<h3 class="heading">THEN</h3>
|
||||
|
||||
<label class="field notify">
|
||||
<span>Notify me in traxxx</span>
|
||||
<Checkbox
|
||||
:checked="notify"
|
||||
@change="(checked) => notify = checked"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!--
|
||||
<label class="field email">
|
||||
<span>E-mail me</span>
|
||||
|
||||
<Checkbox
|
||||
:checked="email"
|
||||
@change="(checked) => email = checked"
|
||||
/>
|
||||
</label>
|
||||
-->
|
||||
|
||||
<div class="stash">
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
class="field-item tag"
|
||||
>
|
||||
<div class="field-tile field-label stash">
|
||||
<Icon
|
||||
v-if="stash.isPrimary"
|
||||
class="favorites"
|
||||
icon="heart7"
|
||||
/>{{ stash.name }}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<template v-if="stashes.length < assets.stashes.length">
|
||||
<li class="field-add">
|
||||
<button
|
||||
v-if="stashes.length === 0"
|
||||
type="button"
|
||||
class="button favorites"
|
||||
@click="selectStash(assets.primaryStash)"
|
||||
><Icon icon="heart7" />Add to favorites</button>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown>
|
||||
<button
|
||||
type="button"
|
||||
class="button field-add"
|
||||
><Icon icon="folder-heart" />Add to stash</button>
|
||||
|
||||
<template #popper>
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
|
||||
:key="`stash-result-${stash.id}`"
|
||||
v-close-popper
|
||||
class="result-item result-stash result-label"
|
||||
@click="selectStash(stash)"
|
||||
>
|
||||
{{ stash.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section dialog-actions">
|
||||
<button
|
||||
class="button button-submit"
|
||||
>Set alert</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
const { assets } = inject('pageContext');
|
||||
import { get, post } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
presetActors: {
|
||||
@@ -403,6 +24,12 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['alert', 'close']);
|
||||
|
||||
const {
|
||||
assets,
|
||||
user,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const actors = ref(props.presetActors);
|
||||
const actorResults = ref([]);
|
||||
const actorInput = ref(null);
|
||||
@@ -427,6 +54,13 @@ const actorAnd = ref(true);
|
||||
const tagAnd = ref(true);
|
||||
const matchAnd = ref(true);
|
||||
|
||||
const fieldsLogicTooltipId = useId();
|
||||
const addActorDropdownId = useId();
|
||||
const addTagDropdownId = useId();
|
||||
const addEntityDropdownId = useId();
|
||||
const addMatchDropdownId = useId();
|
||||
const addStashDropdownId = useId();
|
||||
|
||||
const notify = ref(true);
|
||||
const email = ref(false);
|
||||
|
||||
@@ -530,6 +164,388 @@ function selectStash(selectedStash) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
title="New alert"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<form
|
||||
class="dialog-body"
|
||||
@submit.prevent="createAlert"
|
||||
>
|
||||
<div class="dialog-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">IF</h3>
|
||||
</div>
|
||||
|
||||
<div class="field actors">
|
||||
<span
|
||||
v-tooltip="{ content: fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.', ariaId: fieldsLogicTooltipId }"
|
||||
class="field-logic fields-logic noselect"
|
||||
@click="fieldsAnd = !fieldsAnd"
|
||||
>
|
||||
<Icon
|
||||
v-show="fieldsAnd"
|
||||
icon="link3"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-show="!fieldsAnd"
|
||||
icon="unlink3"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<ul
|
||||
class="field-items nolist noselect"
|
||||
:class="{ and: actorAnd, or: !actorAnd }"
|
||||
>
|
||||
<li
|
||||
v-for="(actor, index) in actors"
|
||||
:key="`actor-${actor.id}`"
|
||||
class="field-item actor"
|
||||
>
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="field-logic item-logic"
|
||||
@click="actorAnd = !actorAnd"
|
||||
>{{ actorAnd ? 'AND' : 'OR' }}</div>
|
||||
|
||||
<div class="field-tile">
|
||||
<div class="field-label">{{ actor.name }}</div>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown
|
||||
:aria-id="addActorDropdownId"
|
||||
@show="focusActorInput"
|
||||
>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
><Icon icon="plus3" />Add actor</button>
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="actorInput"
|
||||
v-model="actorQuery"
|
||||
class="input"
|
||||
@input="searchActors"
|
||||
>
|
||||
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="actor in actorResults"
|
||||
:key="`actor-result-${actor.id}`"
|
||||
v-close-popper
|
||||
class="result-item"
|
||||
@click="selectActor(actor)"
|
||||
>
|
||||
<img
|
||||
v-if="actor.avatar"
|
||||
class="field-avatar"
|
||||
:src="getPath(actor.avatar, 'lazy')"
|
||||
>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="field-avatar"
|
||||
/>
|
||||
|
||||
<div class="result-label">
|
||||
{{ actor.name }}
|
||||
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="field tags">
|
||||
<span
|
||||
class="field-logic noselect"
|
||||
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
|
||||
|
||||
<ul
|
||||
class="field-items nolist noselect"
|
||||
:class="{ and: actorAnd, or: !actorAnd }"
|
||||
>
|
||||
<li
|
||||
v-for="(tag, index) in tags"
|
||||
:key="`tag-${tag.id}`"
|
||||
class="field-item tag"
|
||||
>
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="field-logic item-logic"
|
||||
@click="tagAnd = !tagAnd"
|
||||
>{{ tagAnd ? 'AND' : 'OR' }}</div>
|
||||
|
||||
<div class="field-tile field-label">{{ tag.name }}</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addTagDropdownId">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
><Icon icon="plus3" />Add tag</button>
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="tagInput"
|
||||
v-model="tagQuery"
|
||||
class="input"
|
||||
@input="searchTags"
|
||||
>
|
||||
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="tag in tagResults"
|
||||
:key="`tag-result-${tag.id}`"
|
||||
v-close-popper
|
||||
class="result-item result-label"
|
||||
@click="selectTag(tag)"
|
||||
>{{ tag.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="field entities">
|
||||
<span
|
||||
class="field-logic noselect"
|
||||
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
|
||||
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="(entity, index) in entities"
|
||||
:key="`entity-${entity.id}`"
|
||||
class="field-item entity"
|
||||
>
|
||||
<div
|
||||
v-if="index > 0"
|
||||
v-tooltip.click="{
|
||||
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
|
||||
triggers: ['click'],
|
||||
autoHide: true,
|
||||
ariaId: `entity-and-tooltip-${entity.id}`,
|
||||
}"
|
||||
class="field-logic"
|
||||
>OR</div>
|
||||
|
||||
<div class="field-tile field-label">
|
||||
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
|
||||
{{ entity.name }}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addEntityDropdownId">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
><Icon icon="plus3" />Add channel</button>
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="entityInput"
|
||||
v-model="entityQuery"
|
||||
class="input"
|
||||
@input="searchEntities"
|
||||
>
|
||||
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="entity in entityResults"
|
||||
:key="`entity-result-${entity.id}`"
|
||||
v-close-popper
|
||||
class="result-item result-label"
|
||||
@click="selectEntity(entity)"
|
||||
>
|
||||
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
|
||||
|
||||
{{ entity.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="field matches">
|
||||
<span
|
||||
class="field-logic noselect"
|
||||
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
|
||||
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="(match, index) in matches"
|
||||
:key="`match-${match.property}-${match.expression}`"
|
||||
class="field-item match"
|
||||
>
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="field-logic item-logic"
|
||||
@click="matchAnd = !matchAnd"
|
||||
>{{ matchAnd ? 'AND' : 'OR' }}</div>
|
||||
|
||||
<div class="field-tile field-label">
|
||||
<strong>{{ match.property }}:</strong> {{ match.expression }}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addMatchDropdownId">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
><Icon icon="plus3" />Add expression</button>
|
||||
|
||||
<template #popper>
|
||||
<form @submit.prevent="addMatch">
|
||||
<select
|
||||
v-model="matchProperty"
|
||||
class="input"
|
||||
>
|
||||
<option value="title">Title</option>
|
||||
<option value="description">Description</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-model="matchExpression"
|
||||
placeholder="Expression, // for regex"
|
||||
class="input"
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section then">
|
||||
<h3 class="heading">THEN</h3>
|
||||
|
||||
<label class="field notify">
|
||||
<span>Notify me in traxxx</span>
|
||||
<Checkbox
|
||||
:checked="notify"
|
||||
@change="(checked) => notify = checked"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="env.emailEnabled && user.email"
|
||||
class="field email"
|
||||
>
|
||||
<span>E-mail me</span>
|
||||
|
||||
<Checkbox
|
||||
:checked="email"
|
||||
@change="(checked) => email = checked"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="stash">
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
class="field-item tag"
|
||||
>
|
||||
<div class="field-tile field-label stash">
|
||||
<Icon
|
||||
v-if="stash.isPrimary"
|
||||
class="favorites"
|
||||
icon="heart7"
|
||||
/>{{ stash.name }}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon="cross2"
|
||||
class="field-remove"
|
||||
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<template v-if="stashes.length < assets.stashes.length">
|
||||
<li class="field-add">
|
||||
<button
|
||||
v-if="stashes.length === 0"
|
||||
type="button"
|
||||
class="button favorites"
|
||||
@click="selectStash(assets.primaryStash)"
|
||||
><Icon icon="heart7" />Add to favorites</button>
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addStashDropdownId">
|
||||
<button
|
||||
type="button"
|
||||
class="button field-add"
|
||||
><Icon icon="folder-heart" />Add to stash</button>
|
||||
|
||||
<template #popper>
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="stash in assets.stashes.filter((stash) => !stashes.some((selectedStash) => selectedStash.id === stash.id))"
|
||||
:key="`stash-result-${stash.id}`"
|
||||
v-close-popper
|
||||
class="result-item result-stash result-label"
|
||||
@click="selectStash(stash)"
|
||||
>
|
||||
{{ stash.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section dialog-actions">
|
||||
<button
|
||||
class="button button-submit"
|
||||
>Set alert</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
width: 30rem;
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
campaign: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
backdrop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const pageContext = inject('pageContext');
|
||||
const { restriction } = pageContext;
|
||||
|
||||
// console.log(props.campaign);
|
||||
|
||||
const bannerSrc = (() => {
|
||||
if (props.campaign.banner) {
|
||||
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
|
||||
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
|
||||
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
|
||||
}
|
||||
|
||||
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
|
||||
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="restriction"
|
||||
@@ -12,7 +46,6 @@
|
||||
|
||||
<iframe
|
||||
v-else-if="campaign?.banner?.type === 'html'"
|
||||
ref="iframe"
|
||||
:width="campaign.banner.width"
|
||||
:height="campaign.banner.height"
|
||||
:src="bannerSrc"
|
||||
@@ -45,41 +78,6 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { restriction } = pageContext;
|
||||
|
||||
const props = defineProps({
|
||||
campaign: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
backdrop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// console.log(props.campaign);
|
||||
|
||||
const bannerSrc = (() => {
|
||||
if (props.campaign.banner) {
|
||||
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
|
||||
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
|
||||
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
|
||||
}
|
||||
|
||||
if (props.campaign.banner.entity.type === 'channel' && props.campaign.banner.entity.parent?.type === 'network') {
|
||||
return `/banners/${props.campaign.banner.entity.parent.slug}/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.campaign {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
confirmClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'close', 'event']);
|
||||
|
||||
function close() {
|
||||
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-alert
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => emit('open'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#container">
|
||||
<div
|
||||
@@ -6,6 +33,7 @@
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
v-bind="$attrs"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dialog-header">
|
||||
@@ -24,31 +52,6 @@
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
confirmClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'close', 'event']);
|
||||
|
||||
function close() {
|
||||
if (!props.confirmClose || confirm('You have unchanged changes, are you sure you want to close the dialog?')) { // eslint-disable-line no-restricted-globals, no-alert
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => emit('open'));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-body {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
stash: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
domains: {
|
||||
type: Array,
|
||||
default: () => ['scenes', 'actors', 'movies'],
|
||||
},
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="domains">
|
||||
<Link
|
||||
@@ -32,27 +53,6 @@
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
stash: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
domains: {
|
||||
type: Array,
|
||||
default: () => ['scenes', 'actors', 'movies'],
|
||||
},
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.domains {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import ActorSearch from '#/components/actors/search.vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actors']);
|
||||
|
||||
const newActors = ref([]);
|
||||
|
||||
function addActor(actor) {
|
||||
newActors.value = newActors.value.concat(actor);
|
||||
|
||||
emit('actors', props.edits.actors.concat(actor.id));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => {
|
||||
newActors.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="actors nolist"
|
||||
@@ -40,43 +79,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import ActorSearch from '#/components/actors/search.vue';
|
||||
|
||||
const newActors = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actors']);
|
||||
|
||||
function addActor(actor) {
|
||||
newActors.value = newActors.value.concat(actor);
|
||||
|
||||
emit('actors', props.edits.actors.concat(actor.id));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newActors.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actors {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['augmentation']);
|
||||
const augmentation = reactive(props.edits.augmentation);
|
||||
|
||||
watch(augmentation, () => emit('augmentation', augmentation));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="figure augmentation"
|
||||
@@ -208,23 +228,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['augmentation']);
|
||||
const augmentation = reactive(props.edits.augmentation);
|
||||
|
||||
watch(augmentation, () => emit('augmentation', augmentation));
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
avatar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar noshrink">
|
||||
<img
|
||||
@@ -19,10 +31,16 @@
|
||||
>{{ avatar.sharpness.toFixed(2) }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
:title="`Added ${format(avatar.createdAt, 'yyyy-MM-dd')}, may not reflect photo age`"
|
||||
class="avatar-date"
|
||||
>{{ format(avatar.createdAt, '\'\'yy-MM') }}</span>
|
||||
|
||||
<a
|
||||
:href="getPath(avatar)"
|
||||
target="_blank"
|
||||
class="avatar-zoom"
|
||||
@click.stop
|
||||
>
|
||||
<Icon
|
||||
icon="search"
|
||||
@@ -31,17 +49,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
avatar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
@@ -89,7 +96,8 @@ defineProps({
|
||||
}
|
||||
|
||||
.avatar-meta,
|
||||
.avatar-credit {
|
||||
.avatar-credit,
|
||||
.avatar-date {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
@@ -112,4 +120,10 @@ defineProps({
|
||||
bottom: .75rem;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.avatar-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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)"> inch</template>
|
||||
<template v-else> 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)"> inch</template>
|
||||
<template v-else> cm</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
@@ -145,3 +44,104 @@ const bustSizes = {
|
||||
bustSizes.uk = bustSizes.us;
|
||||
bustSizes.jp = bustSizes.eu;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="figure">
|
||||
<div class="value-section">
|
||||
<span class="value-label">Units</span>
|
||||
|
||||
<select
|
||||
:value="units"
|
||||
class="input"
|
||||
:disabled="!editing.has('figure')"
|
||||
@change="emit('units', $event.target.value)"
|
||||
>
|
||||
<option value="us">USA</option>
|
||||
<option value="uk">UK</option>
|
||||
<option value="eu">Europe/Asia</option>
|
||||
<option value="jp">Japan</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="it">Italy</option>
|
||||
<option value="fr">France</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="figure-bust">
|
||||
<div class="value-section">
|
||||
<span class="value-label">Bust</span>
|
||||
|
||||
<select
|
||||
v-model="figure.bust"
|
||||
class="select input"
|
||||
placeholder="Bust"
|
||||
:disabled="!editing.has('figure')"
|
||||
>
|
||||
<option
|
||||
key="figure-bust-unknown"
|
||||
:value="null"
|
||||
/>
|
||||
|
||||
<option
|
||||
v-for="bust in bustSizes[units]"
|
||||
:key="`figure-bust-${bust}`"
|
||||
:value="Array.isArray(bust) ? bust[0] : bust"
|
||||
>{{ Array.isArray(bust) ? bust.join('/') : bust }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="value-section">
|
||||
<span class="value-label">Cup</span>
|
||||
|
||||
<select
|
||||
v-model="figure.cup"
|
||||
class="select input"
|
||||
placeholder="Cup"
|
||||
:disabled="!editing.has('figure')"
|
||||
>
|
||||
<option
|
||||
key="figure-cup-unknown"
|
||||
:value="null"
|
||||
/>
|
||||
|
||||
<option
|
||||
v-for="cup in cupSizes[units]"
|
||||
:key="`figure-cup-${cup}`"
|
||||
:value="Array.isArray(cup) ? cup[0] : cup"
|
||||
>{{ Array.isArray(cup) ? cup.join('/') : cup }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="value-section">
|
||||
<span class="value-label">Waist</span>
|
||||
|
||||
<span>
|
||||
<input
|
||||
v-model="figure.waist"
|
||||
type="number"
|
||||
class="input"
|
||||
:disabled="!editing.has('figure')"
|
||||
>
|
||||
|
||||
<template v-if="['us', 'uk'].includes(units)"> inch</template>
|
||||
<template v-else> 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)"> inch</template>
|
||||
<template v-else> cm</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import MovieSearch from '#/components/movies/search.vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['movies']);
|
||||
|
||||
const newMovies = ref([]);
|
||||
|
||||
function addMovie(movie) {
|
||||
newMovies.value = newMovies.value.concat(movie);
|
||||
|
||||
console.log(movie);
|
||||
|
||||
emit('movies', props.edits.movies.concat(movie.id));
|
||||
|
||||
console.log(props.edits);
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => {
|
||||
newMovies.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="movies nolist"
|
||||
@@ -40,47 +83,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import MovieSearch from '#/components/movies/search.vue';
|
||||
|
||||
const newMovies = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['movies']);
|
||||
|
||||
function addMovie(movie) {
|
||||
newMovies.value = newMovies.value.concat(movie);
|
||||
|
||||
console.log(movie);
|
||||
|
||||
emit('movies', props.edits.movies.concat(movie.id));
|
||||
|
||||
console.log(props.edits);
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newMovies.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.movies {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
units: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['penis', 'units']);
|
||||
const penis = reactive(props.edits.penis);
|
||||
|
||||
watch(penis, () => emit('penis', penis));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="figure penis"
|
||||
@@ -83,27 +107,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
units: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['penis', 'units']);
|
||||
const penis = reactive(props.edits.penis);
|
||||
|
||||
watch(penis, () => emit('penis', penis));
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
<script setup>
|
||||
import { inject, reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['place']);
|
||||
const pageContext = inject('pageContext');
|
||||
const countries = pageContext.pageProps.countries;
|
||||
|
||||
const place = reactive(props.edits[props.item.key]);
|
||||
|
||||
watch(place, () => emit('place', place));
|
||||
|
||||
const topCountries = [
|
||||
'AU',
|
||||
'BR',
|
||||
'CZ',
|
||||
'DE',
|
||||
'JP',
|
||||
'RU',
|
||||
'GB',
|
||||
'US',
|
||||
];
|
||||
|
||||
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="place"
|
||||
@@ -35,43 +75,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, inject } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const countries = pageContext.pageProps.countries;
|
||||
|
||||
const emit = defineEmits(['place']);
|
||||
const place = reactive(props.edits[props.item.key]);
|
||||
|
||||
watch(place, () => emit('place', place));
|
||||
|
||||
const topCountries = [
|
||||
'AU',
|
||||
'BR',
|
||||
'CZ',
|
||||
'DE',
|
||||
'JP',
|
||||
'RU',
|
||||
'GB',
|
||||
'US',
|
||||
];
|
||||
|
||||
const sortedCountries = countries.toSorted((countryA, countryB) => topCountries.indexOf(countryB.alpha2) - topCountries.indexOf(countryA.alpha2));
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
<script setup>
|
||||
import formatTemplate from 'template-format';
|
||||
|
||||
import { inject } from 'vue';
|
||||
|
||||
defineProps({
|
||||
rev: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
socials: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
function getUrl(social) {
|
||||
if (social.url) {
|
||||
return social.url;
|
||||
}
|
||||
|
||||
if (env.socials.urls[social.platform]) {
|
||||
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="socials nolist">
|
||||
<li
|
||||
@@ -38,46 +77,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import formatTemplate from 'template-format';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
|
||||
defineProps({
|
||||
rev: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
socials: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
function getUrl(social) {
|
||||
if (social.url) {
|
||||
return social.url;
|
||||
}
|
||||
|
||||
if (env.socials.urls[social.platform]) {
|
||||
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.socials {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,210 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import Avatar from '#/components/edit/avatar.vue';
|
||||
import Socials from '#/components/edit/revision-socials.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
import { formatDuration } from '#/utils/format.js';
|
||||
|
||||
defineProps({
|
||||
context: {
|
||||
type: String,
|
||||
default: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const revisions = ref(pageContext.pageProps.revisions);
|
||||
const domain = pageContext.routeParams.domain;
|
||||
|
||||
const actors = ref(pageContext.pageProps.actors);
|
||||
const tags = ref(pageContext.pageProps.tags);
|
||||
const movies = ref(pageContext.pageProps.movies);
|
||||
const avatars = ref(pageContext.pageProps.avatars);
|
||||
|
||||
const actorsById = computed(() => Object.fromEntries(actors.value.map((actor) => [actor.id, actor])));
|
||||
const tagsById = computed(() => Object.fromEntries(tags.value.map((tag) => [tag.id, tag])));
|
||||
const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) => [movie.id, movie])));
|
||||
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
|
||||
|
||||
const feedbacks = ref({});
|
||||
const showReviewed = ref(false);
|
||||
const reviewedRevisions = ref(new Set());
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
// tags: tagsById,
|
||||
movies: moviesById,
|
||||
};
|
||||
|
||||
const dateKeys = [
|
||||
'date',
|
||||
'dateOfBirth',
|
||||
'dateOfDeath',
|
||||
'productionDate',
|
||||
'createdAt',
|
||||
];
|
||||
|
||||
const curatedKeys = {
|
||||
duration: (duration) => formatDuration(duration),
|
||||
};
|
||||
|
||||
const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
|
||||
if (Array.isArray(value) && mappedKeys[key]) {
|
||||
return [key, value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (key === 'tags') {
|
||||
return [key, value.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.actorId
|
||||
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
|
||||
: tagsById.value[tag.id]?.name,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaTag) => deltaTag.id === tag.id && (!Object.hasOwn(tag, 'actorId') || deltaTag.actorId === tag.actorId))),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (key === 'socials') {
|
||||
// new socials don't have IDs yet, so we need to compare the values
|
||||
return [key, value.map((item) => ({
|
||||
...item,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (dateKeys.includes(key) && value) {
|
||||
return [key, new Date(value)];
|
||||
}
|
||||
|
||||
if (curatedKeys[key]) {
|
||||
return [key, curatedKeys[key](value)];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}));
|
||||
|
||||
const curatedDeltas = revision.deltas.map((delta) => {
|
||||
if (Array.isArray(delta.value) && mappedKeys[delta.key]) {
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
|
||||
modified: !revision.base[delta.key].includes(itemId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (delta.key === 'tags') {
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.actorId
|
||||
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
|
||||
: tagsById.value[tag.id]?.name,
|
||||
modified: !revision.base[delta.key].some((baseTag) => baseTag.id === tag.id && (!Object.hasOwn(baseTag, 'actorId') || baseTag.actorId === tag.actorId)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (delta.key === 'socials') {
|
||||
// new socials don't have IDs yet, so we need to compare the values
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((social) => ({
|
||||
...social,
|
||||
modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (delta.key === 'mergeTarget' || delta.key === 'mergeSource') {
|
||||
// new socials don't have IDs yet, so we need to compare the values
|
||||
return {
|
||||
...delta,
|
||||
value: `#${delta.actorId} ${delta.actorName} (${delta.profileIds.length} profiles${delta.profileIds.length > 0 ? `: ${delta.profileIds.join()}` : ''})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (dateKeys.includes(delta.key) && delta.value) {
|
||||
return {
|
||||
...delta,
|
||||
value: new Date(delta.value),
|
||||
};
|
||||
}
|
||||
|
||||
if (curatedKeys[delta.key]) {
|
||||
return {
|
||||
...delta,
|
||||
value: curatedKeys[delta.key](delta.value),
|
||||
};
|
||||
}
|
||||
|
||||
return delta;
|
||||
});
|
||||
|
||||
return {
|
||||
...revision,
|
||||
base: curatedBase,
|
||||
deltas: curatedDeltas,
|
||||
};
|
||||
}));
|
||||
|
||||
async function reloadRevisions() {
|
||||
const updatedRevisions = await get(`/revisions/${domain}`, {
|
||||
isFinalized: showReviewed.value ? undefined : false,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
actors.value = updatedRevisions.actors;
|
||||
tags.value = updatedRevisions.tags;
|
||||
movies.value = updatedRevisions.movies;
|
||||
avatars.value = updatedRevisions.avatars;
|
||||
revisions.value = updatedRevisions.revisions;
|
||||
}
|
||||
|
||||
async function reviewRevision(revision, isApproved) {
|
||||
reviewedRevisions.value.add(revision.id);
|
||||
|
||||
try {
|
||||
await post(`/revisions/${domain}/${revision.id}/reviews`, {
|
||||
isApproved,
|
||||
feedback: feedbacks.value[revision.id],
|
||||
}, {
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
|
||||
revisionId: revision.id,
|
||||
});
|
||||
|
||||
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
|
||||
}
|
||||
catch (error) {
|
||||
reviewedRevisions.value.delete(revision.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function banEditor(revision) {
|
||||
await post('/bans', {
|
||||
userId: revision.user.id,
|
||||
banIp: true,
|
||||
});
|
||||
|
||||
await reviewRevision(revision, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div
|
||||
@@ -21,7 +228,7 @@
|
||||
class="rev"
|
||||
:class="{
|
||||
reviewed: reviewedRevisions.has(rev.id),
|
||||
expanded: context === 'admin' || expanded.has(rev.id)
|
||||
expanded: context === 'admin' || expanded.has(rev.id),
|
||||
}"
|
||||
>
|
||||
<template v-if="context === 'admin' || expanded.has(rev.id)">
|
||||
@@ -65,14 +272,14 @@
|
||||
|
||||
<template v-else-if="context === 'admin'">
|
||||
<Icon
|
||||
v-tooltip="`Ban user from submitting revisions`"
|
||||
v-tooltip="{ content: 'Ban user from submitting revisions', ariaId: `rev-ban-${rev.id}` }"
|
||||
icon="user-block"
|
||||
class="review-reject review-ban"
|
||||
@click="banEditor(rev)"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="`Reject revision`"
|
||||
v-tooltip="{ content: 'Reject revision', ariaId: `rev-reject-${rev.id}` }"
|
||||
icon="blocked"
|
||||
class="review-reject"
|
||||
@click="reviewRevision(rev, false)"
|
||||
@@ -85,7 +292,7 @@
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-tooltip="`Approve and apply revision`"
|
||||
v-tooltip="{ content: 'Approve and apply revision', ariaId: `rev-approve-${rev.id}` }"
|
||||
icon="checkmark"
|
||||
class="review-approve"
|
||||
@click="reviewRevision(rev, true)"
|
||||
@@ -209,7 +416,7 @@
|
||||
|
||||
<a
|
||||
v-if="rev.user && context !== 'user'"
|
||||
v-tooltip="rev.user.username"
|
||||
v-tooltip="{ content: rev.user.username, ariaId: `rev-user-${rev.id}` }"
|
||||
:href="`/user/${rev.user.username}`"
|
||||
target="_blank"
|
||||
class="rev-avatar nolink ellipsis"
|
||||
@@ -227,181 +434,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import Avatar from '#/components/edit/avatar.vue';
|
||||
import Socials from '#/components/edit/revision-socials.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
import { formatDuration } from '#/utils/format.js';
|
||||
|
||||
defineProps({
|
||||
context: {
|
||||
type: String,
|
||||
default: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const revisions = ref(pageContext.pageProps.revisions);
|
||||
const domain = pageContext.routeParams.domain;
|
||||
|
||||
const actors = ref(pageContext.pageProps.actors);
|
||||
const tags = ref(pageContext.pageProps.tags);
|
||||
const movies = ref(pageContext.pageProps.movies);
|
||||
const avatars = ref(pageContext.pageProps.avatars);
|
||||
|
||||
const actorsById = computed(() => Object.fromEntries(actors.value.map((actor) => [actor.id, actor])));
|
||||
const tagsById = computed(() => Object.fromEntries(tags.value.map((tag) => [tag.id, tag])));
|
||||
const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) => [movie.id, movie])));
|
||||
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
|
||||
|
||||
const feedbacks = ref({});
|
||||
const showReviewed = ref(false);
|
||||
const reviewedRevisions = ref(new Set());
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
tags: tagsById,
|
||||
movies: moviesById,
|
||||
};
|
||||
|
||||
const dateKeys = [
|
||||
'date',
|
||||
'dateOfBirth',
|
||||
'dateOfDeath',
|
||||
'productionDate',
|
||||
'createdAt',
|
||||
];
|
||||
|
||||
const curatedKeys = {
|
||||
duration: (duration) => formatDuration(duration),
|
||||
};
|
||||
|
||||
const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
|
||||
if (Array.isArray(value) && mappedKeys[key]) {
|
||||
return [key, value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (key === 'socials') {
|
||||
// new socials don't have IDs yet, so we need to compare the values
|
||||
return [key, value.map((item) => ({
|
||||
...item,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (dateKeys.includes(key) && value) {
|
||||
return [key, new Date(value)];
|
||||
}
|
||||
|
||||
if (curatedKeys[key]) {
|
||||
return [key, curatedKeys[key](value)];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}));
|
||||
|
||||
const curatedDeltas = revision.deltas.map((delta) => {
|
||||
if (Array.isArray(delta.value) && mappedKeys[delta.key]) {
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
|
||||
modified: !revision.base[delta.key].includes(itemId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (delta.key === 'socials') {
|
||||
// new socials don't have IDs yet, so we need to compare the values
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((social) => ({
|
||||
...social,
|
||||
modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (dateKeys.includes(delta.key) && delta.value) {
|
||||
return {
|
||||
...delta,
|
||||
value: new Date(delta.value),
|
||||
};
|
||||
}
|
||||
|
||||
if (curatedKeys[delta.key]) {
|
||||
return {
|
||||
...delta,
|
||||
value: curatedKeys[delta.key](delta.value),
|
||||
};
|
||||
}
|
||||
|
||||
return delta;
|
||||
});
|
||||
|
||||
return {
|
||||
...revision,
|
||||
base: curatedBase,
|
||||
deltas: curatedDeltas,
|
||||
};
|
||||
}));
|
||||
|
||||
async function reloadRevisions() {
|
||||
const updatedRevisions = await get(`/revisions/${domain}`, {
|
||||
isFinalized: showReviewed.value ? undefined : false,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
actors.value = updatedRevisions.actors;
|
||||
tags.value = updatedRevisions.tags;
|
||||
movies.value = updatedRevisions.movies;
|
||||
avatars.value = updatedRevisions.avatars;
|
||||
revisions.value = updatedRevisions.revisions;
|
||||
}
|
||||
|
||||
async function reviewRevision(revision, isApproved) {
|
||||
reviewedRevisions.value.add(revision.id);
|
||||
|
||||
try {
|
||||
await post(`/revisions/${domain}/${revision.id}/reviews`, {
|
||||
isApproved,
|
||||
feedback: feedbacks.value[revision.id],
|
||||
}, {
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
|
||||
revisionId: revision.id,
|
||||
});
|
||||
|
||||
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
|
||||
} catch (error) {
|
||||
reviewedRevisions.value.delete(revision.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function banEditor(revision) {
|
||||
await post('/bans', {
|
||||
userId: revision.user.id,
|
||||
banIp: true,
|
||||
});
|
||||
|
||||
await reviewRevision(revision, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,75 @@
|
||||
<script setup>
|
||||
import formatTemplate from 'template-format';
|
||||
|
||||
import {
|
||||
inject,
|
||||
ref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['socials']);
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
|
||||
const socials = ref(props.edits.socials);
|
||||
|
||||
const platform = ref('');
|
||||
const handle = ref('');
|
||||
const url = ref('');
|
||||
const addSocialDropdownId = useId();
|
||||
|
||||
watch(socials, () => emit('socials', socials.value));
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
function addSocial() {
|
||||
if (!handle.value && !url.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle.value && !platform.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
socials.value = socials.value.concat({
|
||||
platform: platform.value || null,
|
||||
handle: handle.value || null,
|
||||
url: url.value || null,
|
||||
});
|
||||
|
||||
emit('socials', socials.value);
|
||||
|
||||
platform.value = '';
|
||||
handle.value = '';
|
||||
url.value = '';
|
||||
}
|
||||
|
||||
function getUrl(social) {
|
||||
if (social.url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (env.socials.urls[social.platform]) {
|
||||
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list nolist"
|
||||
@@ -54,7 +126,7 @@
|
||||
</li>
|
||||
|
||||
<li class="list-new">
|
||||
<VDropdown>
|
||||
<VDropdown :aria-id="addSocialDropdownId">
|
||||
<Icon
|
||||
icon="plus2"
|
||||
class="add noselect"
|
||||
@@ -110,77 +182,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
inject,
|
||||
} from 'vue';
|
||||
|
||||
import formatTemplate from 'template-format';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
|
||||
const props = defineProps({
|
||||
edits: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['socials']);
|
||||
const socials = ref(props.edits.socials);
|
||||
|
||||
const platform = ref('');
|
||||
const handle = ref('');
|
||||
const url = ref('');
|
||||
|
||||
watch(socials, () => emit('socials', socials));
|
||||
|
||||
const iconMap = {
|
||||
twitter: 'twitter-x',
|
||||
};
|
||||
|
||||
function addSocial() {
|
||||
if (!handle.value && !url.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle.value && !platform.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
socials.value = socials.value.concat({
|
||||
platform: platform.value || null,
|
||||
handle: handle.value || null,
|
||||
url: url.value || null,
|
||||
});
|
||||
|
||||
emit('socials', socials.value);
|
||||
|
||||
platform.value = '';
|
||||
handle.value = '';
|
||||
url.value = '';
|
||||
}
|
||||
|
||||
function getUrl(social) {
|
||||
if (social.url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (env.socials.urls[social.platform]) {
|
||||
return formatTemplate(env.socials.urls[social.platform], { handle: social.handle });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import TagSearch from '#/components/tags/search.vue';
|
||||
|
||||
import events from '#/src/events.js';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['tags']);
|
||||
|
||||
const newTags = ref([]);
|
||||
|
||||
const tags = [
|
||||
{
|
||||
tags: props.item.value.filter((tag) => tag.actorId === null),
|
||||
actor: null,
|
||||
actorId: null,
|
||||
},
|
||||
...props.scene.actors.map((actor) => ({
|
||||
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
|
||||
actor,
|
||||
actorId: actor?.id || null,
|
||||
})),
|
||||
];
|
||||
|
||||
function addTag(newTag, actor) {
|
||||
const actorId = actor?.id || null;
|
||||
|
||||
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
|
||||
events.emit('feedback', {
|
||||
type: 'error',
|
||||
message: 'Tag already added',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newTagWithActorId = {
|
||||
...newTag,
|
||||
actorId,
|
||||
};
|
||||
|
||||
newTags.value = newTags.value.concat(newTagWithActorId);
|
||||
emit('tags', props.edits.tags.concat(newTagWithActorId));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => {
|
||||
newTags.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-sections">
|
||||
<div
|
||||
@@ -53,73 +122,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import events from '#/src/events.js';
|
||||
|
||||
import TagSearch from '#/components/tags/search.vue';
|
||||
|
||||
const newTags = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['tags']);
|
||||
|
||||
const tags = [
|
||||
{
|
||||
tags: props.item.value.filter((tag) => tag.actorId === null),
|
||||
actor: null,
|
||||
actorId: null,
|
||||
},
|
||||
...props.scene.actors.map((actor) => ({
|
||||
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
|
||||
actor,
|
||||
actorId: actor?.id || null,
|
||||
})),
|
||||
];
|
||||
|
||||
function addTag(newTag, actor) {
|
||||
const actorId = actor?.id || null;
|
||||
|
||||
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
|
||||
events.emit('feedback', {
|
||||
type: 'error',
|
||||
message: 'Tag already added',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newTagWithActorId = {
|
||||
...newTag,
|
||||
actorId,
|
||||
};
|
||||
|
||||
newTags.value = newTags.value.concat(newTagWithActorId);
|
||||
emit('tags', props.edits.tags.concat(newTagWithActorId));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newTags.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags {
|
||||
display: flex;
|
||||
|
||||
307
components/emails/alert.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
trigger: {
|
||||
type: Object,
|
||||
default: () => mockTrigger,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockTrigger = {
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
alerts: [
|
||||
{
|
||||
sceneId: 684061,
|
||||
alert: {
|
||||
id: 565,
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
notify: true,
|
||||
email: true,
|
||||
all: true,
|
||||
allActors: true,
|
||||
allEntities: true,
|
||||
allTags: true,
|
||||
allMatches: true,
|
||||
actors: [],
|
||||
tags: [],
|
||||
entities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
matches: [],
|
||||
stashIds: [],
|
||||
},
|
||||
scene: {
|
||||
id: 684061,
|
||||
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
|
||||
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
|
||||
date: '2026-02-17T00:00:00.000Z',
|
||||
description: 'Vanessa Blake’s curves are impossible to ignore—especially her thick, round, bootylicious ass. The scene opens with the camera following Vanessa through the house as she struts confidently in a slow, teasing booty walk, giving viewers a full view of her luscious curves. She pauses at the stairs, turning back to flaunt her massive ass before letting her hands wander over her body. Feeling the heat build, Vanessa begins to pleasure herself, showcasing her insatiable appetite for big black cock. Moving to the couch, she grabs a dildo and slowly works it into her ass, preparing herself for what’s coming next. Sitting back and watching the show is Lexington Steele, enjoying every second as she stretches and teases in anticipation. Lexington finally steps in, and Vanessa eagerly drops to her knees, wrapping her lips around his massive BBC and giving him a deep, hungry blowjob. The action quickly escalates to the couch, where they fuck in multiple intense positions, Vanessa’s thick body bouncing with every thrust. When she’s fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
|
||||
actors: [
|
||||
{
|
||||
id: 919,
|
||||
name: 'Vanessa Blake',
|
||||
},
|
||||
{
|
||||
id: 1374,
|
||||
name: 'Lexington Steele',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
919,
|
||||
1374,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'MILF',
|
||||
slug: 'milf',
|
||||
},
|
||||
{
|
||||
name: 'facial',
|
||||
slug: 'facial',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
12,
|
||||
19,
|
||||
30,
|
||||
29,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
100,
|
||||
112,
|
||||
119,
|
||||
null,
|
||||
130,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
sceneId: 684060,
|
||||
alert: {
|
||||
id: 565,
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
notify: true,
|
||||
email: true,
|
||||
all: true,
|
||||
allActors: true,
|
||||
allEntities: true,
|
||||
allTags: true,
|
||||
allMatches: true,
|
||||
actors: [],
|
||||
tags: [],
|
||||
entities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
matches: [],
|
||||
stashIds: [],
|
||||
},
|
||||
scene: {
|
||||
id: 684060,
|
||||
title: 'Elizabeth Skylar’s Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
|
||||
slug: 'elizabeth-skylar-s-big-assets-drive-the-manuel',
|
||||
date: '2026-02-21T00:00:00.000Z',
|
||||
description: 'Elizabeth Skylar is ready to indulge her cravings, and there’s only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
|
||||
actors: [
|
||||
{
|
||||
id: 44001,
|
||||
name: 'Elizabeth Skylar',
|
||||
},
|
||||
{
|
||||
id: 512,
|
||||
name: 'Manuel Ferrara',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
512,
|
||||
44001,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'anal',
|
||||
slug: 'anal',
|
||||
},
|
||||
{
|
||||
name: 'ass to mouth',
|
||||
tag: 'atm',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
30,
|
||||
29,
|
||||
31,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
119,
|
||||
125,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ trigger.username }}</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
|
||||
|
||||
<div
|
||||
v-for="(alert, index) in trigger.alerts"
|
||||
:key="alert.alert.id"
|
||||
style="margin-bottom: 15px; font-size: 14px;"
|
||||
:style="{
|
||||
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0,
|
||||
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none',
|
||||
}"
|
||||
>
|
||||
<Row style="margin-bottom: 8px;">
|
||||
<Column valign="top">
|
||||
{{ format(alert.scene.date, 'yyyy-MM-dd') }} |
|
||||
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span>
|
||||
|
||||
<span v-else>
|
||||
New
|
||||
|
||||
<template
|
||||
v-if="alert.alert.tags.length > 0"
|
||||
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template>
|
||||
|
||||
scene
|
||||
|
||||
<template
|
||||
v-if="alert.alert.actors.length > 0"
|
||||
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }} </template>
|
||||
|
||||
<template
|
||||
v-if="alert.alert.entities.length > 0"
|
||||
>for {{ alert.alert.entities[0].name }} </template>
|
||||
|
||||
<template
|
||||
v-if="alert.alert.matches.length > 0"
|
||||
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template>
|
||||
</span>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
width="50"
|
||||
valign="top"
|
||||
style="text-align: right;"
|
||||
>
|
||||
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Row>
|
||||
<Column
|
||||
width="175"
|
||||
valign="top"
|
||||
>
|
||||
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`">
|
||||
<Img
|
||||
v-if="alert.scene.posterUrl"
|
||||
:src="alert.scene.posterUrl"
|
||||
width="160"
|
||||
:alt="alert.scene.title"
|
||||
style="margin-top: 2px; border-radius: 8px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
|
||||
>No thumb</div>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
<Column valign="top">
|
||||
<div style="margin-bottom: 5px;">
|
||||
<Link
|
||||
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"
|
||||
style="font-weight: bold; line-height: 1.5; color: inherit;"
|
||||
>{{ alert.scene.title }}</Link>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Enjoy!</Heading>
|
||||
|
||||
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -1,3 +1,16 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
entity: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showNetworkSymbol: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="`/${entity.type}/${entity.slug}`"
|
||||
@@ -22,19 +35,6 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
entity: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showNetworkSymbol: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.entity {
|
||||
width: 15rem;
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
<script setup>
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'available',
|
||||
},
|
||||
toggleActor: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actor']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:key="`filter-actor-${actor.id}`"
|
||||
@@ -44,37 +75,6 @@
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
|
||||
defineProps({
|
||||
actor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'available',
|
||||
},
|
||||
toggleActor: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actor']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-name {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -1,3 +1,79 @@
|
||||
<script setup>
|
||||
import { UseVirtualList } from '@vueuse/components';
|
||||
import { computed, inject, ref, useId } from 'vue';
|
||||
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
import Actor from '#/components/filters/actor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
actors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const search = ref('');
|
||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||
const selectedGender = ref(null);
|
||||
const order = ref('count');
|
||||
const genderTooltipId = useId();
|
||||
const sortAlphaTooltipId = useId();
|
||||
const sortCountTooltipId = useId();
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const pageProps = pageContext.pageProps;
|
||||
const { actor: pageActor } = pageProps;
|
||||
|
||||
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
|
||||
|
||||
const availableActors = computed(() => props.actors
|
||||
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
|
||||
&& actor.id !== pageActor?.id
|
||||
&& searchRegexp.value.test(actor.name)
|
||||
&& (!selectedGender.value || actor.gender === selectedGender.value))
|
||||
.sort((actorA, actorB) => {
|
||||
if (order.value === 'count') {
|
||||
return actorB.count - actorA.count;
|
||||
}
|
||||
|
||||
return actorA.name.localeCompare(actorB.name);
|
||||
}));
|
||||
|
||||
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
|
||||
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
|
||||
|
||||
function toggleActor(actor, combine) {
|
||||
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
|
||||
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (combine) {
|
||||
emit('update', 'actors', props.filters.actors.concat(actor));
|
||||
}
|
||||
else {
|
||||
emit('update', 'actors', [actor]);
|
||||
}
|
||||
}
|
||||
|
||||
function selectGender() {
|
||||
const genderIndex = genders.value.indexOf(selectedGender.value);
|
||||
|
||||
if (genderIndex >= genders.value.length - 1) {
|
||||
selectedGender.value = genders.value[0];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedGender.value = genders.value[genderIndex + 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter actors-container">
|
||||
<div
|
||||
@@ -18,6 +94,7 @@
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-tooltip="{ content: selectedGender ? `${selectedGender.slice(0, 1).toUpperCase()}${selectedGender.slice(1)} performers` : `All genders`, ariaId: genderTooltipId }"
|
||||
class="filter-sort noselect"
|
||||
@click="selectGender"
|
||||
>
|
||||
@@ -35,6 +112,7 @@
|
||||
|
||||
<div
|
||||
v-show="order === 'name'"
|
||||
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'count'"
|
||||
>
|
||||
@@ -45,6 +123,7 @@
|
||||
|
||||
<div
|
||||
v-show="order === 'count'"
|
||||
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'name'"
|
||||
>
|
||||
@@ -96,78 +175,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { UseVirtualList } from '@vueuse/components';
|
||||
|
||||
import Actor from '#/components/filters/actor.vue';
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
actors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const search = ref('');
|
||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||
const selectedGender = ref(null);
|
||||
const order = ref('count');
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const pageProps = pageContext.pageProps;
|
||||
const { actor: pageActor } = pageProps;
|
||||
|
||||
const selectedActors = computed(() => props.filters.actors.map((filterActor) => props.actors.find((actor) => actor.id === filterActor.id)).filter(Boolean));
|
||||
|
||||
const availableActors = computed(() => props.actors
|
||||
.filter((actor) => !props.filters.actors.some((filterActor) => filterActor.id === actor.id)
|
||||
&& actor.id !== pageActor?.id
|
||||
&& searchRegexp.value.test(actor.name)
|
||||
&& (!selectedGender.value || actor.gender === selectedGender.value))
|
||||
.sort((actorA, actorB) => {
|
||||
if (order.value === 'count') {
|
||||
return actorB.count - actorA.count;
|
||||
}
|
||||
|
||||
return actorA.name.localeCompare(actorB.name);
|
||||
}));
|
||||
|
||||
const genders = computed(() => [null, ...['female', 'male', 'transsexual', 'other'].filter((gender) => props.actors.some((actor) => actor.gender === gender))]);
|
||||
const isAggActorsLimited = computed(() => props.actors.length >= pageContext.env.maxAggregateSize);
|
||||
|
||||
function toggleActor(actor, combine) {
|
||||
if (props.filters.actors.some((filterActor) => filterActor.id === actor.id)) {
|
||||
emit('update', 'actors', props.filters.actors.filter((filterActor) => filterActor.id !== actor.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (combine) {
|
||||
emit('update', 'actors', props.filters.actors.concat(actor));
|
||||
} else {
|
||||
emit('update', 'actors', [actor]);
|
||||
}
|
||||
}
|
||||
|
||||
function selectGender() {
|
||||
const genderIndex = genders.value.indexOf(selectedGender.value);
|
||||
|
||||
if (genderIndex >= genders.value.length - 1) {
|
||||
selectedGender.value = genders.value[0];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedGender.value = genders.value[genderIndex + 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter {
|
||||
padding: 0;
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
<script setup>
|
||||
import { format, subYears } from 'date-fns';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// <select> doesn't work well with :value for some reason
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
|
||||
|
||||
const dobType = ref(props.filters.dobType);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter">
|
||||
<div class="filter-section">
|
||||
@@ -53,26 +75,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { format, subYears } from 'date-fns';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
|
||||
const maxDob = format(subYears(new Date(), 18), 'yyyy-MM-dd');
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const dobType = ref(props.filters.dobType); // <select> doesn't work well with :value for some reason
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
|
||||
cupRange: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
function cupRangeToCups(cupA, cupB) {
|
||||
const smallestCode = cupA.charCodeAt(0);
|
||||
const biggestCode = cupB.charCodeAt(0);
|
||||
|
||||
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
|
||||
}
|
||||
|
||||
const naturalBoobsValues = [true, undefined, false];
|
||||
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter">
|
||||
<RangeFilter
|
||||
@@ -42,33 +72,3 @@
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// cup range should probably not be used, because it becomes impossible to filter for larger values than the current results
|
||||
cupRange: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function cupRangeToCups(cupA, cupB) {
|
||||
const smallestCode = cupA.charCodeAt(0);
|
||||
const biggestCode = cupB.charCodeAt(0);
|
||||
|
||||
return Array.from({ length: biggestCode - smallestCode + 1 }, (value, index) => String.fromCharCode(smallestCode + index));
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const naturalBoobsValues = [true, undefined, false];
|
||||
const cups = computed(() => (props.cupRange ? cupRangeToCups(props.cupRange[0], props.cupRange[1]) : 'ABCDEFGHIJKZ'.split(''))); // add Z to allow for 'bigger than K' without wasting fidelity
|
||||
</script>
|
||||
|
||||
@@ -1,92 +1,5 @@
|
||||
<template>
|
||||
<div class="filter channels-container">
|
||||
<div class="filters-sort">
|
||||
<label class="filter-search">
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="`Filter ${channels.length} channels`"
|
||||
class="input input-inline filters-search"
|
||||
>
|
||||
|
||||
<Icon icon="search" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-show="order === 'name'"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'count'"
|
||||
>
|
||||
<Icon icon="sort-alpha-asc" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="order === 'count'"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'name'"
|
||||
>
|
||||
<Icon icon="sort-numeric-desc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entities.length === 0 && !filters.entity"
|
||||
class="filter-empty"
|
||||
>No channels</div>
|
||||
|
||||
<template v-else>
|
||||
<ul
|
||||
class="filter-items nolist"
|
||||
>
|
||||
<li
|
||||
v-for="entity in entities"
|
||||
:key="`filter-channel-${entity.id}`"
|
||||
class="filter-item"
|
||||
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
|
||||
@click="emit('update', 'entity', entity)"
|
||||
>
|
||||
<span class="filter-name">
|
||||
<span
|
||||
class="filter-text"
|
||||
:title="entity.name"
|
||||
>
|
||||
<img
|
||||
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
|
||||
:src="`/logos/${entity.slug}/favicon_dark.png`"
|
||||
class="favicon"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
icon="arrow-up4"
|
||||
/>
|
||||
|
||||
{{ entity.name }}
|
||||
</span>
|
||||
|
||||
<span class="filter-details">
|
||||
<span
|
||||
v-if="entity.count"
|
||||
:title="entity.count"
|
||||
class="filter-count"
|
||||
>{{ abbreviateNumber(entity.count) }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="filters.entity?.id === entity.id"
|
||||
icon="cross2"
|
||||
class="filter-remove"
|
||||
@click.native.stop="emit('update', 'entity', null)"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { computed, inject, ref, useId } from 'vue';
|
||||
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
@@ -106,6 +19,8 @@ const emit = defineEmits(['update']);
|
||||
const search = ref('');
|
||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||
const order = ref('name');
|
||||
const sortAlphaTooltipId = useId();
|
||||
const sortCountTooltipId = useId();
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
const { channel: pageChannel } = pageProps;
|
||||
@@ -160,6 +75,95 @@ const entities = computed(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter channels-container">
|
||||
<div class="filters-sort">
|
||||
<label class="filter-search">
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="`Filter ${channels.length} channels`"
|
||||
class="input input-inline filters-search"
|
||||
>
|
||||
|
||||
<Icon icon="search" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-show="order === 'name'"
|
||||
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'count'"
|
||||
>
|
||||
<Icon icon="sort-alpha-asc" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="order === 'count'"
|
||||
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'name'"
|
||||
>
|
||||
<Icon icon="sort-numeric-desc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entities.length === 0 && !filters.entity"
|
||||
class="filter-empty"
|
||||
>No channels</div>
|
||||
|
||||
<template v-else>
|
||||
<ul
|
||||
class="filter-items nolist"
|
||||
>
|
||||
<li
|
||||
v-for="entity in entities"
|
||||
:key="`filter-channel-${entity.id}`"
|
||||
class="filter-item"
|
||||
:class="{ channel: !entity.isIndependent && entity.type !== 'network', selected: filters.entity?.id === entity.id }"
|
||||
@click="emit('update', 'entity', entity)"
|
||||
>
|
||||
<span class="filter-name">
|
||||
<span
|
||||
class="filter-text"
|
||||
:title="entity.name"
|
||||
>
|
||||
<img
|
||||
v-if="entity.isIndependent || entity.type === 'network' || !entity.parent"
|
||||
:src="`/logos/${entity.slug}/favicon_dark.png`"
|
||||
class="favicon"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
icon="arrow-up4"
|
||||
/>
|
||||
|
||||
{{ entity.name }}
|
||||
</span>
|
||||
|
||||
<span class="filter-details">
|
||||
<span
|
||||
v-if="entity.count"
|
||||
:title="entity.count"
|
||||
class="filter-count"
|
||||
>{{ abbreviateNumber(entity.count) }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="filters.entity?.id === entity.id"
|
||||
icon="cross2"
|
||||
class="filter-remove"
|
||||
@click.stop="emit('update', 'entity', null)"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-items {
|
||||
max-height: 15rem;
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
countries: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedCountry: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['country']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="countries nolist">
|
||||
<li
|
||||
@@ -19,27 +34,12 @@
|
||||
v-if="selectedCountry === country.alpha2"
|
||||
icon="cross2"
|
||||
class="filter-remove"
|
||||
@click.native.stop="emit('country', null)"
|
||||
@click.stop="emit('country', null)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
countries: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedCountry: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['country']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.countries:not(:last-child) {
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import Countries from './countries.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
countries: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps } = pageContext;
|
||||
|
||||
const allCountries = computed(() => props.countries || pageProps.countries);
|
||||
const countryQuery = ref('');
|
||||
|
||||
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
|
||||
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
|
||||
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="countries-container"
|
||||
@@ -43,35 +72,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
|
||||
import Countries from './countries.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
countries: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps } = pageContext;
|
||||
|
||||
const allCountries = computed(() => props.countries || pageProps.countries);
|
||||
const countryQuery = ref('');
|
||||
|
||||
const topCountryAlpha2s = ['AU', 'BR', 'CZ', 'DE', 'JP', 'RU', 'GB', 'US'];
|
||||
const topCountries = computed(() => topCountryAlpha2s.map((alpha2) => allCountries.value.find((country) => country.alpha2 === alpha2)).filter(Boolean));
|
||||
const filteredCountries = computed(() => allCountries.value.filter((country) => new RegExp(countryQuery.value, 'i').test(country.name)));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.countries-container {
|
||||
border-bottom: solid 1px var(--shadow-weak-30);
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
results: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// desktop defaults to open, compact defaults to closed
|
||||
// we can't measure viewframe in SSR, so use separate toggles
|
||||
const showFilters = ref(true);
|
||||
const showFiltersCompact = ref(false);
|
||||
|
||||
function toggleFilters(state) {
|
||||
showFilters.value = state ?? !showFilters.value;
|
||||
showFiltersCompact.value = state ?? !showFiltersCompact.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filters-frame">
|
||||
<div
|
||||
@@ -47,31 +72,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
results: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// desktop defaults to open, compact defaults to closed
|
||||
// we can't measure viewframe in SSR, so use separate toggles
|
||||
const showFilters = ref(true);
|
||||
const showFiltersCompact = ref(false);
|
||||
|
||||
function toggleFilters(state) {
|
||||
showFilters.value = state ?? !showFilters.value;
|
||||
showFiltersCompact.value = state ?? !showFiltersCompact.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter {
|
||||
padding: .5rem;
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
<script setup>
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
|
||||
defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="filter genders nolist">
|
||||
<li>
|
||||
@@ -47,19 +60,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Gender from '#/components/actors/gender.vue';
|
||||
|
||||
defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.genders {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
<script setup>
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
|
||||
defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter">
|
||||
<RangeFilter
|
||||
@@ -31,16 +44,3 @@
|
||||
</RangeFilter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import RangeFilter from '#/components/filters/range.vue';
|
||||
|
||||
defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
@@ -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"> {{ unit }}</template></span>
|
||||
</label>
|
||||
|
||||
<span class="filter-split">
|
||||
<Range
|
||||
:min="min"
|
||||
:max="max"
|
||||
:value="value"
|
||||
:values="values"
|
||||
:disabled="disabled"
|
||||
:allow-enable="allowEnable"
|
||||
@enable="emit('enable', true)"
|
||||
@input="(range) => emit('input', range)"
|
||||
@change="(range) => emit('change', range)"
|
||||
>
|
||||
<template #start><slot name="start" /></template>
|
||||
<template #end><slot name="end" /></template>
|
||||
</Range>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Checkbox from '../form/checkbox.vue';
|
||||
import Range from '../form/range.vue';
|
||||
@@ -75,3 +39,39 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['change', 'input', 'enable']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-section">
|
||||
<label class="filter-label noselect">
|
||||
<span class="label">
|
||||
<Checkbox
|
||||
:checked="!disabled"
|
||||
class="checkbox"
|
||||
@change="(checked) => $emit('enable', checked)"
|
||||
/>{{ label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!disabled"
|
||||
class="label-values"
|
||||
>{{ value[0] }} - {{ value[1] }}<template v-if="unit"> {{ 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>
|
||||
|
||||
@@ -1,3 +1,129 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref, useId } from 'vue';
|
||||
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
actorTags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const {
|
||||
tag: pageTag,
|
||||
actor: pageActor,
|
||||
} = pageProps;
|
||||
|
||||
const search = ref('');
|
||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||
const order = ref('priority');
|
||||
const showActorTags = ref(!!pageActor);
|
||||
const actorTagsTooltipId = useId();
|
||||
const allTagsTooltipId = useId();
|
||||
const sortPriorityTooltipId = useId();
|
||||
const sortCountTooltipId = useId();
|
||||
const sortAlphaTooltipId = useId();
|
||||
|
||||
const priorityTags = [
|
||||
'anal',
|
||||
'dp',
|
||||
'threesome',
|
||||
'gangbang',
|
||||
'blowbang',
|
||||
'orgy',
|
||||
'airtight',
|
||||
'dap',
|
||||
'dvp',
|
||||
'triple-penetration',
|
||||
'tap',
|
||||
'tvp',
|
||||
'transsexual',
|
||||
'spitroast',
|
||||
'mfm',
|
||||
'fmf',
|
||||
'bdsm',
|
||||
'deepthroat',
|
||||
'blowjob',
|
||||
'lesbian',
|
||||
];
|
||||
|
||||
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor));
|
||||
|
||||
const groupedTags = computed(() => {
|
||||
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one
|
||||
const tags = showActorTags.value && isActorTagsAvailable.value
|
||||
? props.actorTags
|
||||
: props.tags;
|
||||
|
||||
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
|
||||
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
|
||||
&& tag.id !== pageTag?.id
|
||||
&& searchRegexp.value.test(tag.name));
|
||||
|
||||
if (order.value === 'count') {
|
||||
const available = filtered.sort((tagA, tagB) => {
|
||||
if (order.value === 'count') {
|
||||
return tagB.count - tagA.count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available,
|
||||
};
|
||||
}
|
||||
|
||||
if (order.value === 'priority') {
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available: [
|
||||
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
|
||||
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available: filtered,
|
||||
};
|
||||
});
|
||||
|
||||
function toggleTag(tag, combine) {
|
||||
emit('update', 'onlyActorTags', showActorTags.value, false);
|
||||
|
||||
if (props.filters.tags.includes(tag.slug)) {
|
||||
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
|
||||
return;
|
||||
}
|
||||
|
||||
if (combine) {
|
||||
emit('update', 'tags', props.filters.tags.concat(tag.slug));
|
||||
}
|
||||
else {
|
||||
emit('update', 'tags', [tag.slug]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter tags-container">
|
||||
<div class="filters-sort">
|
||||
@@ -12,10 +138,10 @@
|
||||
<Icon icon="search" />
|
||||
</label>
|
||||
|
||||
<!--
|
||||
<template v-if="isActorTagsAvailable">
|
||||
<div
|
||||
v-show="showActorTags"
|
||||
v-tooltip="'Tags relevant to the selected actors'"
|
||||
v-tooltip="{ content: 'Tags relevant to the selected actors', ariaId: actorTagsTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="showActorTags = false"
|
||||
>
|
||||
@@ -24,16 +150,17 @@
|
||||
|
||||
<div
|
||||
v-show="!showActorTags"
|
||||
v-tooltip="'All tags'"
|
||||
v-tooltip="{ content: 'All tags', ariaId: allTagsTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="showActorTags = true"
|
||||
>
|
||||
<Icon icon="price-tags" />
|
||||
</div>
|
||||
-->
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-show="order === 'priority'"
|
||||
v-tooltip="{ content: 'Sorted by tag priority', ariaId: sortPriorityTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'count'"
|
||||
>
|
||||
@@ -42,6 +169,7 @@
|
||||
|
||||
<div
|
||||
v-show="order === 'count'"
|
||||
v-tooltip="{ content: 'Sorted by number of scenes', ariaId: sortCountTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'name'"
|
||||
>
|
||||
@@ -52,6 +180,7 @@
|
||||
|
||||
<div
|
||||
v-show="order === 'name'"
|
||||
v-tooltip="{ content: 'Sorted alphanumerically', ariaId: sortAlphaTooltipId }"
|
||||
class="filter-sort order noselect"
|
||||
@click="order = 'priority'"
|
||||
>
|
||||
@@ -119,121 +248,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
actorTags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
// const { tag: pageTag, actor: pageActor } = pageProps;
|
||||
const { tag: pageTag } = pageProps;
|
||||
|
||||
const search = ref('');
|
||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||
const order = ref('priority');
|
||||
// const showActorTags = ref(true);
|
||||
|
||||
const priorityTags = [
|
||||
'anal',
|
||||
'dp',
|
||||
'threesome',
|
||||
'gangbang',
|
||||
'blowbang',
|
||||
'orgy',
|
||||
'airtight',
|
||||
'dap',
|
||||
'dvp',
|
||||
'triple-penetration',
|
||||
'tap',
|
||||
'tvp',
|
||||
'transsexual',
|
||||
'spitroast',
|
||||
'mfm',
|
||||
'fmf',
|
||||
'bdsm',
|
||||
'deepthroat',
|
||||
'blowjob',
|
||||
'lesbian',
|
||||
];
|
||||
|
||||
const groupedTags = computed(() => {
|
||||
/*
|
||||
const tags = showActorTags.value && props.actorTags && (props.filters.actors.length > 0 || pageActor)
|
||||
? props.actorTags
|
||||
: props.tags;
|
||||
*/
|
||||
const tags = props.tags;
|
||||
|
||||
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
|
||||
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
|
||||
&& tag.id !== pageTag?.id
|
||||
&& searchRegexp.value.test(tag.name));
|
||||
|
||||
if (order.value === 'count') {
|
||||
const available = filtered.sort((tagA, tagB) => {
|
||||
if (order.value === 'count') {
|
||||
return tagB.count - tagA.count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available,
|
||||
};
|
||||
}
|
||||
|
||||
if (order.value === 'priority') {
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available: [
|
||||
...priorityTags.map((priorityTag) => filtered.find((tag) => tag.slug === priorityTag)).filter(Boolean),
|
||||
...filtered.filter((tag) => !priorityTags.includes(tag.slug)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// page: [pageTag],
|
||||
selected,
|
||||
available: filtered,
|
||||
};
|
||||
});
|
||||
|
||||
function toggleTag(tag, combine) {
|
||||
if (props.filters.tags.includes(tag.slug)) {
|
||||
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
|
||||
return;
|
||||
}
|
||||
|
||||
if (combine) {
|
||||
emit('update', 'tags', props.filters.tags.concat(tag.slug));
|
||||
} else {
|
||||
emit('update', 'tags', [tag.slug]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-items {
|
||||
max-height: 15rem;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filters-toggle open"
|
||||
@@ -5,10 +9,6 @@
|
||||
><Icon icon="filter" /></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters-toggle {
|
||||
min-width: 2.75rem;
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
<template>
|
||||
<div class="years-container">
|
||||
<select
|
||||
v-model="selected"
|
||||
class="years nobar"
|
||||
multiple
|
||||
@change="updateYears"
|
||||
>
|
||||
<option
|
||||
v-for="year in years"
|
||||
:key="`year-${year.year}`"
|
||||
class="year"
|
||||
:value="year.year"
|
||||
>{{ year.year }}</option>
|
||||
</select>
|
||||
|
||||
<Icon
|
||||
v-show="selected.length > 0"
|
||||
icon="cross2"
|
||||
class="clear"
|
||||
@click="clearYears"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
@@ -50,6 +25,31 @@ function clearYears() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="years-container">
|
||||
<select
|
||||
v-model="selected"
|
||||
class="years nobar"
|
||||
multiple
|
||||
@change="updateYears"
|
||||
>
|
||||
<option
|
||||
v-for="year in years"
|
||||
:key="`year-${year.year}`"
|
||||
class="year"
|
||||
:value="year.year"
|
||||
>{{ year.year }}</option>
|
||||
</select>
|
||||
|
||||
<Icon
|
||||
v-show="selected.length > 0"
|
||||
icon="cross2"
|
||||
class="clear"
|
||||
@click="clearYears"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.years-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<span class="footer-segment">© traxxx</span>
|
||||
|
||||
<div class="footer-row">
|
||||
<a
|
||||
v-if="env.links.matrix"
|
||||
:href="env.links.matrix"
|
||||
@@ -23,28 +29,39 @@
|
||||
:href="env.links.content"
|
||||
target="_blank"
|
||||
class="footer-segment footer-link"
|
||||
>Content Removal / DMCA</a>
|
||||
>Content Removal (DMCA)</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-row">
|
||||
<span class="footer-segment">© traxxx v{{ env.version }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { env } = pageContext;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
background: var(--background-base-20);
|
||||
box-shadow: inset 0 0 3px var(--shadow-weak-30);
|
||||
font-size: .8rem;
|
||||
font-size: .75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
|
||||
&:first-child .footer-segment {
|
||||
padding-top: .6rem;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
const emit = defineEmits(['sidebar']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="nav-list noselect">
|
||||
<a
|
||||
@@ -37,15 +46,6 @@
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
const emit = defineEmits(['sidebar']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-list {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<script setup>
|
||||
import { useId } from 'vue';
|
||||
|
||||
defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['change']);
|
||||
|
||||
const uid = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="check-container noselect">
|
||||
<span
|
||||
@@ -22,27 +45,6 @@
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const uid = String(Math.random()).slice(2);
|
||||
|
||||
defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['change']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.check-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,61 +1,8 @@
|
||||
<template>
|
||||
<div class="range-container">
|
||||
<div
|
||||
class="label label-start"
|
||||
:class="{ disabled }"
|
||||
@click="setRange(range.a === minValue ? 'a' : 'b', min)"
|
||||
>
|
||||
<slot name="start" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="range"
|
||||
:class="{ disabled }"
|
||||
:style="{ background: `linear-gradient(90deg, var(--slider-track) ${minPercentage}%, var(--slider-range) ${minPercentage}%, var(--slider-range) ${maxPercentage}%, var(--slider-track) ${maxPercentage}%)` }"
|
||||
@click="setNearest"
|
||||
>
|
||||
<input
|
||||
v-model.number="range.a"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:data-value="range.a"
|
||||
:disabled="disabled"
|
||||
type="range"
|
||||
class="slider"
|
||||
@input="set('input')"
|
||||
@change="set('change')"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
<input
|
||||
v-model.number="range.b"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:data-value="range.b"
|
||||
:disabled="disabled"
|
||||
type="range"
|
||||
class="slider"
|
||||
@input="set('input')"
|
||||
@change="set('change')"
|
||||
@click.stop
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="label label-end"
|
||||
:class="{ disabled }"
|
||||
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
|
||||
>
|
||||
<slot name="end" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
nextTick,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -138,6 +85,59 @@ async function setRange(prop, value) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="range-container">
|
||||
<div
|
||||
class="label label-start"
|
||||
:class="{ disabled }"
|
||||
@click="setRange(range.a === minValue ? 'a' : 'b', min)"
|
||||
>
|
||||
<slot name="start" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="range"
|
||||
:class="{ disabled }"
|
||||
:style="{ background: `linear-gradient(90deg, var(--slider-track) ${minPercentage}%, var(--slider-range) ${minPercentage}%, var(--slider-range) ${maxPercentage}%, var(--slider-track) ${maxPercentage}%)` }"
|
||||
@click="setNearest"
|
||||
>
|
||||
<input
|
||||
v-model.number="range.a"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:data-value="range.a"
|
||||
:disabled="disabled"
|
||||
type="range"
|
||||
class="slider"
|
||||
@input="set('input')"
|
||||
@change="set('change')"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
<input
|
||||
v-model.number="range.b"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:data-value="range.b"
|
||||
:disabled="disabled"
|
||||
type="range"
|
||||
class="slider"
|
||||
@input="set('input')"
|
||||
@change="set('change')"
|
||||
@click.stop
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="label label-end"
|
||||
:class="{ disabled }"
|
||||
@click="setRange(range.b === maxValue ? 'b' : 'a', max)"
|
||||
>
|
||||
<slot name="end" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.dark .range-container .range {
|
||||
--slider-range: var(--lighten-weak-10);
|
||||
|
||||
@@ -1,26 +1,3 @@
|
||||
<template>
|
||||
<label
|
||||
class="toggle-container noselect"
|
||||
:class="{ light: $store.state.ui.theme === 'dark' }"
|
||||
>
|
||||
<input
|
||||
:id="`toggle-${id}`"
|
||||
:checked="checked"
|
||||
:true-value="trueValue"
|
||||
:false-value="falseValue"
|
||||
:disabled="disabled"
|
||||
type="checkbox"
|
||||
class="toggle-input"
|
||||
@change="$emit('change', $event.target.checked)"
|
||||
>
|
||||
|
||||
<label
|
||||
:for="`toggle-${id}`"
|
||||
class="toggle"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
@@ -44,12 +21,35 @@ export default {
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
id: Math.floor(new Date().getTime() * Math.random()),
|
||||
id: Math.floor(Date.now() * Math.random()),
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="toggle-container noselect"
|
||||
:class="{ light: $store.state.ui.theme === 'dark' }"
|
||||
>
|
||||
<input
|
||||
:id="`toggle-${id}`"
|
||||
:checked="checked"
|
||||
:true-value="trueValue"
|
||||
:false-value="falseValue"
|
||||
:disabled="disabled"
|
||||
type="checkbox"
|
||||
class="toggle-input"
|
||||
@change="$emit('change', $event.target.checked)"
|
||||
>
|
||||
|
||||
<label
|
||||
:for="`toggle-${id}`"
|
||||
class="toggle"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'breakpoints';
|
||||
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
ref,
|
||||
useId,
|
||||
} from 'vue';
|
||||
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
import Notifications from '#/components/header/notifications.vue';
|
||||
import Settings from '#/components/settings/settings.vue';
|
||||
// import getPath from '#/src/get-path.js';
|
||||
|
||||
import { del } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
import logo from '../../assets/img/logo.svg?raw';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const theme = ref(pageContext.env.theme);
|
||||
|
||||
const user = pageContext.user;
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
const unseen = ref(pageContext.assets?.unseenNotifications);
|
||||
const query = ref(pageContext.urlParsed.search.q || '');
|
||||
const allowLogin = pageContext.env.allowLogin;
|
||||
const searchFocused = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const showAlertDialog = ref(false);
|
||||
const notifsDropdownId = useId();
|
||||
const menuDropdownId = useId();
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
|
||||
|
||||
function search() {
|
||||
navigate('/search', { q: query.value }, { redirect: true });
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme;
|
||||
|
||||
Cookies.set('theme', newTheme);
|
||||
events.emit('theme', newTheme);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await del('/session');
|
||||
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
|
||||
}
|
||||
|
||||
function blurSearch(event) {
|
||||
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
|
||||
searchFocused.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header">
|
||||
<nav class="nav">
|
||||
@@ -92,6 +153,7 @@
|
||||
>
|
||||
<VDropdown
|
||||
v-if="user"
|
||||
:aria-id="notifsDropdownId"
|
||||
:triggers="['click']"
|
||||
:prevent-overflow="true"
|
||||
class="notifs-trigger"
|
||||
@@ -121,6 +183,7 @@
|
||||
</VDropdown>
|
||||
|
||||
<VDropdown
|
||||
:aria-id="menuDropdownId"
|
||||
:triggers="['click']"
|
||||
:prevent-overflow="true"
|
||||
class="menu-trigger"
|
||||
@@ -187,14 +250,59 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-item"
|
||||
>
|
||||
<a
|
||||
:href="`/user/${user.username}/stashes`"
|
||||
class="menu-button nolink stashes"
|
||||
>
|
||||
<Icon icon="folder-heart" />
|
||||
Stashes
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-item menu-div"
|
||||
>
|
||||
<a
|
||||
:href="`/user/${user.username}/alerts`"
|
||||
class="menu-button nolink alerts"
|
||||
>
|
||||
<Icon icon="alarm" />
|
||||
Alerts
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="showSettings = true"
|
||||
@touchstart="showSettings = true"
|
||||
>
|
||||
<Icon icon="equalizer" />
|
||||
Settings
|
||||
<Icon icon="filter" />
|
||||
Filters
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="theme === 'dark'"
|
||||
v-close-popper
|
||||
class="menu-item menu-button menu-div"
|
||||
@click="setTheme('light')"
|
||||
>
|
||||
<Icon icon="sun3" />
|
||||
Light theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
v-close-popper
|
||||
class="menu-item menu-button menu-div"
|
||||
@click="setTheme('dark')"
|
||||
>
|
||||
<Icon icon="moon" />
|
||||
Dark theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
@@ -211,28 +319,6 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="theme === 'dark'"
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="setTheme('light')"
|
||||
@touchstart="setTheme('light')"
|
||||
>
|
||||
<Icon icon="sun3" />
|
||||
Light theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="setTheme('dark')"
|
||||
@touchstart="setTheme('dark')"
|
||||
>
|
||||
<Icon icon="moon" />
|
||||
Dark theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-button menu-item logout"
|
||||
@@ -257,64 +343,6 @@
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
inject,
|
||||
} from 'vue';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { del } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
// import getPath from '#/src/get-path.js';
|
||||
|
||||
import Notifications from '#/components/header/notifications.vue';
|
||||
import Settings from '#/components/settings/settings.vue';
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
|
||||
import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const theme = ref(pageContext.env.theme);
|
||||
|
||||
const user = pageContext.user;
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
const unseen = ref(pageContext.assets?.unseenNotifications);
|
||||
const query = ref(pageContext.urlParsed.search.q || '');
|
||||
const allowLogin = pageContext.env.allowLogin;
|
||||
const searchFocused = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const showAlertDialog = ref(false);
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
|
||||
|
||||
function search() {
|
||||
navigate('/search', { q: query.value }, { redirect: true });
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme;
|
||||
|
||||
Cookies.set('theme', newTheme);
|
||||
events.emit('theme', newTheme);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await del('/session');
|
||||
navigate('/login?consent', null, { redirect: true }); // pass consent variable to reinstate in new session
|
||||
}
|
||||
|
||||
function blurSearch(event) {
|
||||
if (!event.relatedTarget || !Object.hasOwn(event.relatedTarget?.dataset, 'search')) {
|
||||
searchFocused.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -540,6 +568,7 @@ function blurSearch(event) {
|
||||
align-items: center;
|
||||
padding: .75rem .5rem .75rem .75rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
@@ -549,7 +578,6 @@ function blurSearch(event) {
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.logout) .icon {
|
||||
fill: var(--primary);
|
||||
@@ -561,6 +589,10 @@ function blurSearch(event) {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-div {
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
|
||||
/*
|
||||
.favorites .icon {
|
||||
fill: var(--primary);
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
<script setup>
|
||||
import {
|
||||
defineEmits,
|
||||
inject,
|
||||
onMounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
import { get, patch } from '#/src/api.js';
|
||||
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
const emit = defineEmits(['unseen', 'addAlert']);
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
|
||||
const notifications = ref([]);
|
||||
const done = ref(true);
|
||||
|
||||
async function fetchNotifications() {
|
||||
done.value = false;
|
||||
|
||||
const res = await get(`/users/${user?.id}/notifications`);
|
||||
notifications.value = res.notifications;
|
||||
|
||||
done.value = true;
|
||||
|
||||
emit('unseen', res.unseen);
|
||||
}
|
||||
|
||||
async function markSeen(notif) {
|
||||
if (notif.isSeen || !done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
|
||||
seen: true,
|
||||
});
|
||||
|
||||
await fetchNotifications();
|
||||
|
||||
done.value = true;
|
||||
}
|
||||
|
||||
async function markAllSeen() {
|
||||
done.value = false;
|
||||
|
||||
await patch(`/users/${user?.id}/notifications`, {
|
||||
seen: true,
|
||||
});
|
||||
|
||||
await fetchNotifications();
|
||||
|
||||
done.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu">
|
||||
<div class="notifs-header">
|
||||
@@ -44,7 +108,15 @@
|
||||
class="notif-body notif-link nolink"
|
||||
>
|
||||
<span class="notif-trigger">
|
||||
<span class="notif-details">
|
||||
<span
|
||||
v-if="notif.matchedSceneIds.length > 0"
|
||||
class="notif-details"
|
||||
>Scene is released</span>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="notif-details"
|
||||
>
|
||||
New
|
||||
|
||||
<template
|
||||
@@ -88,71 +160,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
defineEmits,
|
||||
onMounted,
|
||||
inject,
|
||||
} from 'vue';
|
||||
|
||||
import { get, patch } from '#/src/api.js';
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
|
||||
const notifications = ref([]);
|
||||
const done = ref(true);
|
||||
|
||||
const emit = defineEmits(['unseen', 'addAlert']);
|
||||
|
||||
async function fetchNotifications() {
|
||||
done.value = false;
|
||||
|
||||
const res = await get(`/users/${user?.id}/notifications`);
|
||||
notifications.value = res.notifications;
|
||||
|
||||
done.value = true;
|
||||
|
||||
emit('unseen', res.unseen);
|
||||
}
|
||||
|
||||
async function markSeen(notif) {
|
||||
if (notif.isSeen || !done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
|
||||
seen: true,
|
||||
});
|
||||
|
||||
await fetchNotifications();
|
||||
|
||||
done.value = true;
|
||||
}
|
||||
|
||||
async function markAllSeen() {
|
||||
done.value = false;
|
||||
|
||||
await patch(`/users/${user?.id}/notifications`, {
|
||||
seen: true,
|
||||
});
|
||||
|
||||
await fetchNotifications();
|
||||
|
||||
done.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notifs {
|
||||
width: 30rem;
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ active }"
|
||||
:title="title"
|
||||
class="icon"
|
||||
v-html="svg"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const svg = ref(null);
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
@@ -27,11 +16,22 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const svg = ref(null);
|
||||
|
||||
import(`../../assets/img/icons/${props.icon}.svg?raw`).then((raw) => {
|
||||
svg.value = raw.default;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{ active }"
|
||||
:title="title"
|
||||
class="icon"
|
||||
v-html="svg"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
fill: var(--text);
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
<template>
|
||||
<a
|
||||
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
|
||||
rel="noopener"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
@@ -19,3 +10,12 @@ defineProps({
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:class="{ active: pageContext.urlPathname === $attrs.href || active }"
|
||||
rel="noopener"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
<script setup>
|
||||
import { computed, ref, useId } from 'vue';
|
||||
|
||||
import Player from '#/components/video/player.vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
release: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const playing = ref(false);
|
||||
const paused = ref(false);
|
||||
|
||||
const poster = computed(() => {
|
||||
if (props.release.poster) {
|
||||
return getPath(props.release.poster, 'thumbnail');
|
||||
}
|
||||
|
||||
if (props.release.covers?.length > 0) {
|
||||
return getPath(props.release.covers[0], 'thumbnail');
|
||||
}
|
||||
|
||||
if (props.release.photos?.length > 0) {
|
||||
return getPath(props.release.photos[0], 'thumbnail');
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
|
||||
const restrictedTooltipId = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="banner-container"
|
||||
@@ -49,7 +86,7 @@
|
||||
|
||||
<Icon
|
||||
v-if="(release.trailer || release.teaser).isRestricted"
|
||||
v-tooltip="'Restricted video'"
|
||||
v-tooltip="{ content: 'Restricted video', ariaId: restrictedTooltipId }"
|
||||
icon="blocked"
|
||||
class="restricted"
|
||||
/>
|
||||
@@ -102,42 +139,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
import Player from '#/components/video/player.vue';
|
||||
|
||||
const props = defineProps({
|
||||
release: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const playing = ref(false);
|
||||
const paused = ref(false);
|
||||
|
||||
const poster = computed(() => {
|
||||
if (props.release.poster) {
|
||||
return getPath(props.release.poster, 'thumbnail');
|
||||
}
|
||||
|
||||
if (props.release.covers?.length > 0) {
|
||||
return getPath(props.release.covers[0], 'thumbnail');
|
||||
}
|
||||
|
||||
if (props.release.photos?.length > 0) {
|
||||
return getPath(props.release.photos[0], 'thumbnail');
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banner-container {
|
||||
background-position: center;
|
||||
|
||||
@@ -1,115 +1,20 @@
|
||||
<template>
|
||||
<div class="movies-page">
|
||||
<Filters
|
||||
v-if="showFilters"
|
||||
:class="{ loading }"
|
||||
>
|
||||
<div class="filter">
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="search"
|
||||
placeholder="Search movies"
|
||||
class="search input"
|
||||
@search="search"
|
||||
>
|
||||
</div>
|
||||
|
||||
<YearsFilter
|
||||
:filters="filters"
|
||||
:years="aggYears"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<TagsFilter
|
||||
:filters="filters"
|
||||
:tags="aggTags"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<ChannelsFilter
|
||||
:filters="filters"
|
||||
:channels="aggChannels"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<ActorsFilter
|
||||
:filters="filters"
|
||||
:actors="aggActors"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<div class="filter">
|
||||
<Checkbox
|
||||
:checked="filters.requireCover"
|
||||
label="Require box cover"
|
||||
@change="(checked) => updateFilter('requireCover', checked, true)"
|
||||
/>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
<div class="movies-container">
|
||||
<div class="movies-header">
|
||||
<div class="meta">{{ total }} results</div>
|
||||
|
||||
<select
|
||||
v-model="scope"
|
||||
class="input"
|
||||
@change="search({ autoScope: false })"
|
||||
>
|
||||
<!-- not selected in SSR without prop -->
|
||||
<option
|
||||
v-if="pageStash"
|
||||
:selected="scope === 'stashed'"
|
||||
value="stashed"
|
||||
>Added</option>
|
||||
|
||||
<option
|
||||
v-if="filters.search"
|
||||
:selected="scope === 'results'"
|
||||
value="results"
|
||||
>Relevance</option>
|
||||
|
||||
<option value="likes">Popular</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="new">New</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul class="movies nolist">
|
||||
<li
|
||||
v-for="movie in movies"
|
||||
:key="`movie-${movie.id}`"
|
||||
>
|
||||
<MovieTile :movie="movie" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="currentPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import { parse } from 'path-to-regexp';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { get } from '#/src/api.js';
|
||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||
import events from '#/src/events.js';
|
||||
|
||||
import MovieTile from '#/components/movies/tile.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import YearsFilter from '#/components/filters/years.vue';
|
||||
import ActorsFilter from '#/components/filters/actors.vue';
|
||||
import TagsFilter from '#/components/filters/tags.vue';
|
||||
import ChannelsFilter from '#/components/filters/channels.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import TagsFilter from '#/components/filters/tags.vue';
|
||||
|
||||
import YearsFilter from '#/components/filters/years.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import MovieTile from '#/components/movies/tile.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
import { get } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps, routeParams, urlParsed } = pageContext;
|
||||
@@ -236,6 +141,115 @@ function updateFilter(prop, value, reload = true) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="movies-page">
|
||||
<Filters
|
||||
v-if="showFilters"
|
||||
:class="{ loading }"
|
||||
>
|
||||
<div class="filter search-container">
|
||||
<!-- onsearch not compatible with FF and Safari -->
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="input"
|
||||
placeholder="Search movies"
|
||||
class="search input"
|
||||
@keydown.enter="search"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-if="filters.search"
|
||||
icon="cross2"
|
||||
class="search-button"
|
||||
@click="filters.search = ''; search()"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
icon="search"
|
||||
class="search-button"
|
||||
@click="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<YearsFilter
|
||||
:filters="filters"
|
||||
:years="aggYears"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<TagsFilter
|
||||
:filters="filters"
|
||||
:tags="aggTags"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<ChannelsFilter
|
||||
:filters="filters"
|
||||
:channels="aggChannels"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<ActorsFilter
|
||||
:filters="filters"
|
||||
:actors="aggActors"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<div class="filter">
|
||||
<Checkbox
|
||||
:checked="filters.requireCover"
|
||||
label="Require box cover"
|
||||
@change="(checked) => updateFilter('requireCover', checked, true)"
|
||||
/>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
<div class="movies-container">
|
||||
<div class="movies-header">
|
||||
<div class="meta">{{ total }} results</div>
|
||||
|
||||
<select
|
||||
v-model="scope"
|
||||
class="input"
|
||||
@change="search({ autoScope: false })"
|
||||
>
|
||||
<!-- not selected in SSR without prop -->
|
||||
<option
|
||||
v-if="pageStash"
|
||||
:selected="scope === 'stashed'"
|
||||
value="stashed"
|
||||
>Added</option>
|
||||
|
||||
<option
|
||||
v-if="filters.search"
|
||||
:selected="scope === 'results'"
|
||||
value="results"
|
||||
>Relevance</option>
|
||||
|
||||
<option value="likes">Popular</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="new">New</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul class="movies nolist">
|
||||
<li
|
||||
v-for="movie in movies"
|
||||
:key="`movie-${movie.id}`"
|
||||
>
|
||||
<MovieTile :movie="movie" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="currentPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.movies-page {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['movie']);
|
||||
const dropdownId = useId();
|
||||
const movies = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/movies', { q: query.value });
|
||||
|
||||
movies.value = data.movies;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDropdown
|
||||
:aria-id="dropdownId"
|
||||
:disabled="disabled"
|
||||
class="trigger"
|
||||
@show="focus"
|
||||
@@ -36,39 +69,6 @@
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const movies = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['movie']);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/movies', { q: query.value });
|
||||
|
||||
movies.value = data.movies;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
movie: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="movie-tile"
|
||||
@@ -118,35 +147,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
|
||||
const props = defineProps({
|
||||
movie: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const favorited = ref(props.movie.stashes?.some((movieStash) => movieStash.id === currentStash?.id));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.movie-tile {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,119 @@
|
||||
<script setup>
|
||||
import { parse } from 'path-to-regexp';
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
redirect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
includeQuery: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
useMaxMatches: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['navigation']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const {
|
||||
routeParams,
|
||||
urlParsed,
|
||||
pageProps,
|
||||
env,
|
||||
campaigns,
|
||||
} = pageContext;
|
||||
|
||||
const currentPage = computed(() => props.page || Number(routeParams?.page));
|
||||
|
||||
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
|
||||
const total = computed(() => props.total || Number(pageProps.total));
|
||||
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
|
||||
|
||||
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
|
||||
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
|
||||
|
||||
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
|
||||
const page = currentPage.value - index - 1;
|
||||
|
||||
if (page < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return page;
|
||||
}).filter(Boolean));
|
||||
|
||||
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
|
||||
const page = currentPage.value + index + 1;
|
||||
|
||||
if (page > pageTotal.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return page;
|
||||
}).filter(Boolean));
|
||||
|
||||
function go(page, event) {
|
||||
if (!props.redirect) {
|
||||
event.preventDefault();
|
||||
history.pushState({}, '', event.target.href);
|
||||
}
|
||||
|
||||
emit('navigation', {
|
||||
href: event.target.href,
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
function getPath(page) {
|
||||
const query = typeof window === 'undefined'
|
||||
? urlParsed.searchOriginal
|
||||
: window.location.search;
|
||||
|
||||
if (!routeParams.path && props.includeQuery && query) {
|
||||
return `${pageContext.urlParsed.pathname}${page}${query}`;
|
||||
}
|
||||
|
||||
if (!routeParams.path) {
|
||||
return `${pageContext.urlParsed.pathname}${page}`;
|
||||
}
|
||||
|
||||
const path = parse(routeParams.path)
|
||||
.map((segment) => {
|
||||
if (segment.name === 'page') {
|
||||
return `/${page}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
if (props.includeQuery && query) {
|
||||
return `${path}${query}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pagination-container">
|
||||
<Campaign
|
||||
@@ -91,122 +207,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { parse } from 'path-to-regexp';
|
||||
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
redirect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
includeQuery: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
useMaxMatches: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['navigation']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const {
|
||||
routeParams,
|
||||
urlParsed,
|
||||
pageProps,
|
||||
env,
|
||||
campaigns,
|
||||
} = pageContext;
|
||||
|
||||
const currentPage = computed(() => props.page || Number(routeParams?.page));
|
||||
|
||||
const limit = computed(() => props.limit || Number(pageProps.limit) || 30);
|
||||
const total = computed(() => props.total || Number(pageProps.total));
|
||||
const pageTotal = computed(() => Math.ceil((props.useMaxMatches ? Math.min(total.value, env.maxMatches) : total.value) / limit.value));
|
||||
|
||||
const hasNextPage = computed(() => currentPage.value + 1 <= pageTotal.value);
|
||||
const hasPrevPage = computed(() => currentPage.value - 1 >= 1);
|
||||
|
||||
const prevPages = computed(() => Array.from({ length: 4 }, (value, index) => {
|
||||
const page = currentPage.value - index - 1;
|
||||
|
||||
if (page < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return page;
|
||||
}).filter(Boolean));
|
||||
|
||||
const nextPages = computed(() => Array.from({ length: 4 }, (value, index) => {
|
||||
const page = currentPage.value + index + 1;
|
||||
|
||||
if (page > pageTotal.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return page;
|
||||
}).filter(Boolean));
|
||||
|
||||
function go(page, event) {
|
||||
if (!props.redirect) {
|
||||
event.preventDefault();
|
||||
history.pushState({}, '', event.target.href); // eslint-disable-line no-restricted-globals
|
||||
}
|
||||
|
||||
emit('navigation', {
|
||||
href: event.target.href,
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
function getPath(page) {
|
||||
const query = typeof window === 'undefined'
|
||||
? urlParsed.searchOriginal
|
||||
: window.location.search;
|
||||
|
||||
if (!routeParams.path && props.includeQuery && query) {
|
||||
return `${pageContext.urlParsed.pathname}${page}${query}`;
|
||||
}
|
||||
|
||||
if (!routeParams.path) {
|
||||
return `${pageContext.urlParsed.pathname}${page}`;
|
||||
}
|
||||
|
||||
const path = parse(routeParams.path)
|
||||
.map((segment) => {
|
||||
if (segment.name === 'page') {
|
||||
return `/${page}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
if (props.includeQuery && query) {
|
||||
return `${path}${query}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDate, formatDuration } from '#/utils/format.js';
|
||||
|
||||
const props = defineProps({
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const lastChapter = props.chapters.at(-1);
|
||||
const duration = lastChapter.time + lastChapter.duration;
|
||||
|
||||
const timeline = computed(() => {
|
||||
if (props.chapters.every((chapter) => chapter.time)) {
|
||||
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="timeline"
|
||||
@@ -43,19 +68,19 @@
|
||||
<span class="chapter-details">
|
||||
<span
|
||||
v-if="typeof chapter.time === 'number'"
|
||||
v-tooltip="'Time in video'"
|
||||
v-tooltip="{ content: 'Time in video', ariaId: `chapter-time-${chapter.id}` }"
|
||||
class="chapter-time"
|
||||
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
|
||||
|
||||
<span
|
||||
v-if="chapter.duration"
|
||||
v-tooltip="'Duration'"
|
||||
v-tooltip="{ content: 'Duration', ariaId: `chapter-duration-${chapter.id}` }"
|
||||
class="chapter-duration"
|
||||
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
|
||||
|
||||
<time
|
||||
v-if="chapter.date"
|
||||
v-tooltip="formatDate(chapter.date, 'yyyy-MM-dd hh:mm')"
|
||||
v-tooltip="{ content: formatDate(chapter.date, 'yyyy-MM-dd hh:mm'), ariaId: `chapter-date-${chapter.id}` }"
|
||||
:datetime="chapter.date"
|
||||
class="chapter-date"
|
||||
>{{ formatDate(chapter.date, 'MMM d') }}</time>
|
||||
@@ -90,31 +115,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDuration, formatDate } from '#/utils/format.js';
|
||||
|
||||
const props = defineProps({
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const lastChapter = props.chapters.at(-1);
|
||||
const duration = lastChapter.time + lastChapter.duration;
|
||||
|
||||
const timeline = computed(() => {
|
||||
if (props.chapters.every((chapter) => chapter.time)) {
|
||||
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapters {
|
||||
display: grid;
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meta">
|
||||
<div class="channel">
|
||||
@@ -44,21 +59,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,195 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import { parse } from 'path-to-regexp';
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
import ActorsFilter from '#/components/filters/actors.vue';
|
||||
import ChannelsFilter from '#/components/filters/channels.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
|
||||
import TagsFilter from '#/components/filters/tags.vue';
|
||||
import YearsFilter from '#/components/filters/years.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
import SceneTile from '#/components/scenes/tile.vue';
|
||||
import { get } from '#/src/api.js';
|
||||
// import events from '#/src/events.js';
|
||||
import entityPrefixes from '#/src/entities-prefixes.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||
|
||||
const props = defineProps({
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showMeta: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showScopeTabs: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultScope: {
|
||||
type: String,
|
||||
default: 'latest',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
pageProps,
|
||||
routeParams,
|
||||
urlParsed,
|
||||
campaigns,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const {
|
||||
actor: pageActor,
|
||||
tag: pageTag,
|
||||
entity: pageEntity,
|
||||
stash: pageStash,
|
||||
} = pageProps;
|
||||
|
||||
const scenes = ref(pageProps.scenes);
|
||||
const aggYears = ref(pageProps.aggYears || []);
|
||||
const aggActors = ref(pageProps.aggActors || []);
|
||||
const aggTags = ref(pageProps.aggTags || []);
|
||||
const aggActorTags = ref(pageProps.aggActorTags || []);
|
||||
const aggChannels = ref(pageProps.aggChannels || []);
|
||||
|
||||
const currentPage = ref(Number(routeParams.page));
|
||||
const scope = ref(routeParams.scope || props.defaultScope);
|
||||
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
|
||||
const loading = ref(false);
|
||||
const scenesContainer = ref(null);
|
||||
|
||||
const view = ref(env.scenesView || 'grid');
|
||||
|
||||
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
|
||||
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
|
||||
|
||||
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
|
||||
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
|
||||
|
||||
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
|
||||
|
||||
const filters = ref({
|
||||
search: urlParsed.search.q,
|
||||
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
|
||||
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
|
||||
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
|
||||
entity: queryEntity,
|
||||
actors: queryActors,
|
||||
});
|
||||
|
||||
const sceneCampaign = campaigns?.scenes;
|
||||
const campaignIndex = campaigns?.index;
|
||||
|
||||
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
|
||||
|
||||
function getPath(targetScope, preserveQuery) {
|
||||
const path = parse(routeParams.path).map((segment) => {
|
||||
if (segment.name === 'scope') {
|
||||
return `${segment.prefix}${targetScope}`;
|
||||
}
|
||||
|
||||
if (segment.name === 'page') {
|
||||
return `${segment.prefix}${1}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
}).join('');
|
||||
|
||||
if (preserveQuery && urlParsed.searchOriginal) {
|
||||
return `${path}${urlParsed.searchOriginal}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function search(options = {}) {
|
||||
if (options.resetPage !== false) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
if (options.autoScope !== false) {
|
||||
if (filters.value.search) {
|
||||
scope.value = 'results';
|
||||
}
|
||||
|
||||
if (!filters.value.search && scope.value === 'results') {
|
||||
scope.value = 'latest';
|
||||
}
|
||||
}
|
||||
|
||||
const query = {
|
||||
q: filters.value.search || undefined,
|
||||
};
|
||||
|
||||
const entity = filters.value.entity || pageEntity;
|
||||
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
|
||||
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
navigate(getPath(scope.value, false), {
|
||||
...query,
|
||||
years: filters.value.years.join(',') || undefined,
|
||||
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
|
||||
tags: filters.value.tags.join(',') || undefined,
|
||||
at: (filters.value.tags.length > 0 && filters.value.onlyActorTags) || undefined,
|
||||
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
|
||||
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
|
||||
}, { redirect: false });
|
||||
|
||||
const res = await get('/scenes', {
|
||||
...query,
|
||||
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
|
||||
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
|
||||
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
|
||||
at: !!filters.value.onlyActorTags,
|
||||
stashId: pageStash?.id,
|
||||
e: entitySlug,
|
||||
scope: scope.value,
|
||||
page: currentPage.value, // client uses param rather than query pagination
|
||||
});
|
||||
|
||||
scenes.value = res.scenes;
|
||||
aggYears.value = res.aggYears;
|
||||
aggActors.value = res.aggActors;
|
||||
aggTags.value = res.aggTags;
|
||||
aggActorTags.value = res.aggActorTags;
|
||||
aggChannels.value = res.aggChannels;
|
||||
|
||||
total.value = res.total;
|
||||
loading.value = false;
|
||||
|
||||
// events.emit('scrollUp');
|
||||
scenesContainer.value?.scrollIntoView(true);
|
||||
}
|
||||
|
||||
function updateFilter(prop, value, reload = true) {
|
||||
filters.value[prop] = value;
|
||||
|
||||
if (reload) {
|
||||
search();
|
||||
}
|
||||
}
|
||||
|
||||
function setView(newView) {
|
||||
view.value = newView;
|
||||
Cookies.set('scenesView', newView);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="scenes-page"
|
||||
@@ -12,12 +204,19 @@
|
||||
<!-- onsearch not compatible with FF and Safari -->
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="search"
|
||||
type="input"
|
||||
placeholder="Search scenes"
|
||||
class="search input"
|
||||
@search="search"
|
||||
@keydown.enter="search"
|
||||
>
|
||||
|
||||
<Icon
|
||||
v-if="filters.search"
|
||||
icon="cross2"
|
||||
class="search-button"
|
||||
@click="filters.search = ''; search()"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
icon="search"
|
||||
class="search-button"
|
||||
@@ -200,195 +399,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
inject,
|
||||
} from 'vue';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import { parse } from 'path-to-regexp';
|
||||
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { get } from '#/src/api.js';
|
||||
// import events from '#/src/events.js';
|
||||
import entityPrefixes from '#/src/entities-prefixes.js';
|
||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import YearsFilter from '#/components/filters/years.vue';
|
||||
import ActorsFilter from '#/components/filters/actors.vue';
|
||||
import TagsFilter from '#/components/filters/tags.vue';
|
||||
import ChannelsFilter from '#/components/filters/channels.vue';
|
||||
import SceneTile from '#/components/scenes/tile.vue';
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showMeta: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showScopeTabs: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultScope: {
|
||||
type: String,
|
||||
default: 'latest',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
pageProps,
|
||||
routeParams,
|
||||
urlParsed,
|
||||
campaigns,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const {
|
||||
actor: pageActor,
|
||||
tag: pageTag,
|
||||
entity: pageEntity,
|
||||
stash: pageStash,
|
||||
} = pageProps;
|
||||
|
||||
const scenes = ref(pageProps.scenes);
|
||||
const aggYears = ref(pageProps.aggYears || []);
|
||||
const aggActors = ref(pageProps.aggActors || []);
|
||||
const aggTags = ref(pageProps.aggTags || []);
|
||||
const aggActorTags = ref(pageProps.aggActorTags || []);
|
||||
const aggChannels = ref(pageProps.aggChannels || []);
|
||||
|
||||
const currentPage = ref(Number(routeParams.page));
|
||||
const scope = ref(routeParams.scope || props.defaultScope);
|
||||
const total = ref(Number(pageProps.sceneTotal || pageProps.total));
|
||||
const loading = ref(false);
|
||||
const scenesContainer = ref(null);
|
||||
|
||||
const view = ref(env.scenesView || 'grid');
|
||||
|
||||
const actorIds = urlParsed.search.actors?.split(',').map((identifier) => parseActorIdentifier(identifier)?.id).filter(Boolean) || [];
|
||||
const queryActors = actorIds.map((urlActorId) => aggActors.value.find((aggActor) => aggActor.id === urlActorId)).filter(Boolean);
|
||||
|
||||
const networks = Object.fromEntries(aggChannels.value.map((channel) => (channel.type === 'network' ? channel : channel.parent)).filter(Boolean).map((parent) => [`_${parent.slug}`, parent]));
|
||||
const channels = Object.fromEntries(aggChannels.value.filter((channel) => channel.type === 'channel').map((channel) => [channel.slug, channel]));
|
||||
|
||||
const queryEntity = networks[urlParsed.search.e] || channels[urlParsed.search.e];
|
||||
|
||||
const filters = ref({
|
||||
search: urlParsed.search.q,
|
||||
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
|
||||
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
|
||||
entity: queryEntity,
|
||||
actors: queryActors,
|
||||
});
|
||||
|
||||
const sceneCampaign = campaigns?.scenes;
|
||||
const campaignIndex = campaigns?.index;
|
||||
|
||||
const campaignScenes = computed(() => scenes.value.flatMap((scene, index) => (sceneCampaign && index === campaignIndex ? ['campaign', scene] : scene)));
|
||||
|
||||
function getPath(targetScope, preserveQuery) {
|
||||
const path = parse(routeParams.path).map((segment) => {
|
||||
if (segment.name === 'scope') {
|
||||
return `${segment.prefix}${targetScope}`;
|
||||
}
|
||||
|
||||
if (segment.name === 'page') {
|
||||
return `${segment.prefix}${1}`;
|
||||
}
|
||||
|
||||
return `${segment.prefix || ''}${routeParams[segment.name] || segment}`;
|
||||
}).join('');
|
||||
|
||||
if (preserveQuery && urlParsed.searchOriginal) {
|
||||
return `${path}${urlParsed.searchOriginal}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function search(options = {}) {
|
||||
if (options.resetPage !== false) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
if (options.autoScope !== false) {
|
||||
if (filters.value.search) {
|
||||
scope.value = 'results';
|
||||
}
|
||||
|
||||
if (!filters.value.search && scope.value === 'results') {
|
||||
scope.value = 'latest';
|
||||
}
|
||||
}
|
||||
|
||||
const query = {
|
||||
q: filters.value.search || undefined,
|
||||
};
|
||||
|
||||
const entity = filters.value.entity || pageEntity;
|
||||
// const entitySlug = entity?.type === 'network' ? `_${entity.slug}` : entity?.slug;
|
||||
const entitySlug = entity && `${entityPrefixes[entity.type]}${entity.slug}`;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
navigate(getPath(scope.value, false), {
|
||||
...query,
|
||||
years: filters.value.years.join(',') || undefined,
|
||||
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
|
||||
tags: filters.value.tags.join(',') || undefined,
|
||||
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
|
||||
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
|
||||
}, { redirect: false });
|
||||
|
||||
const res = await get('/scenes', {
|
||||
...query,
|
||||
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
|
||||
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
|
||||
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
|
||||
stashId: pageStash?.id,
|
||||
e: entitySlug,
|
||||
scope: scope.value,
|
||||
page: currentPage.value, // client uses param rather than query pagination
|
||||
});
|
||||
|
||||
scenes.value = res.scenes;
|
||||
aggYears.value = res.aggYears;
|
||||
aggActors.value = res.aggActors;
|
||||
aggTags.value = res.aggTags;
|
||||
aggActorTags.value = res.aggActorTags;
|
||||
aggChannels.value = res.aggChannels;
|
||||
|
||||
total.value = res.total;
|
||||
loading.value = false;
|
||||
|
||||
// events.emit('scrollUp');
|
||||
scenesContainer.value?.scrollIntoView(true);
|
||||
}
|
||||
|
||||
function updateFilter(prop, value, reload = true) {
|
||||
filters.value[prop] = value;
|
||||
|
||||
if (reload) {
|
||||
search();
|
||||
}
|
||||
}
|
||||
|
||||
function setView(newView) {
|
||||
view.value = newView;
|
||||
Cookies.set('scenesView', newView);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scenes-page {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,158 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
import defaultTemplate from '#/assets/summary.yaml?raw';
|
||||
import { del, get, post } from '#/src/api.js';
|
||||
// import slugify from '#/utils/slugify.js';
|
||||
import events from '#/src/events.js';
|
||||
|
||||
import processSummaryTemplate from '#/utils/process-summary-template.js';
|
||||
|
||||
const props = defineProps({
|
||||
release: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
});
|
||||
|
||||
const templates = ref(pageContext.assets.templates);
|
||||
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
|
||||
|
||||
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
|
||||
|
||||
const template = ref(initialTemplate?.template || defaultTemplate);
|
||||
const hasError = ref(false);
|
||||
const input = ref(null);
|
||||
const changed = ref(false);
|
||||
|
||||
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
|
||||
|
||||
function getSummary() {
|
||||
return processSummaryTemplate(template.value, props.release, pageContext.env);
|
||||
}
|
||||
|
||||
const summary = ref(getSummary());
|
||||
|
||||
function selectTemplate(templateId) {
|
||||
selectedTemplate.value = templateId;
|
||||
|
||||
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
|
||||
|
||||
template.value = nextTemplate.template;
|
||||
templateName.value = nextTemplate.name;
|
||||
summary.value = getSummary();
|
||||
|
||||
cookies.set('selectedTemplate', String(templateId));
|
||||
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
|
||||
}
|
||||
|
||||
function update() {
|
||||
hasError.value = false;
|
||||
changed.value = true;
|
||||
|
||||
try {
|
||||
summary.value = getSummary();
|
||||
}
|
||||
catch (error) {
|
||||
hasError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(explicit) {
|
||||
if (!explicit && !changed.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
parse(template.value);
|
||||
|
||||
const createdTemplate = await post('/templates', {
|
||||
name: templateName.value,
|
||||
template: template.value,
|
||||
}, {
|
||||
successFeedback: `Saved summary template '${templateName.value}'`,
|
||||
errorFeedback: `Failed to save summary template '${templateName.value}'`,
|
||||
});
|
||||
|
||||
changed.value = false;
|
||||
templates.value = await get(`/users/${pageContext.user.id}/templates`);
|
||||
|
||||
selectTemplate(createdTemplate.id);
|
||||
}
|
||||
catch (error) {
|
||||
events.emit('feedback', {
|
||||
type: 'error',
|
||||
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function add() {
|
||||
selectedTemplate.value = null;
|
||||
template.value = '';
|
||||
templateName.value = `custom_${Date.now()}`;
|
||||
summary.value = '';
|
||||
|
||||
input.value.focus();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-alert
|
||||
await del(`/templates/${selectedTemplate.value}`, {
|
||||
undoFeedback: `Deleted summary template '${templateName.value}'`,
|
||||
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
|
||||
});
|
||||
|
||||
templates.value = await get(`/users/${pageContext.user.id}/templates`);
|
||||
|
||||
selectTemplate(templates.value.at(-1)?.id);
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(summary.value);
|
||||
|
||||
events.emit('feedback', {
|
||||
type: 'success',
|
||||
message: 'Summary copied to clipboard',
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-alert
|
||||
template.value = defaultTemplate;
|
||||
|
||||
update();
|
||||
|
||||
events.emit('feedback', {
|
||||
type: 'undo',
|
||||
message: 'Reset summary template',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function indent() {
|
||||
// YAML does not support tabs
|
||||
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (changed.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor-header">
|
||||
@@ -89,159 +244,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from 'vue';
|
||||
import { parse } from 'yaml';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
// import slugify from '#/utils/slugify.js';
|
||||
import events from '#/src/events.js';
|
||||
import { get, post, del } from '#/src/api.js';
|
||||
import processSummaryTemplate from '#/utils/process-summary-template.js';
|
||||
|
||||
import defaultTemplate from '#/assets/summary.yaml?raw'; // eslint-disable-line import/no-unresolved
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
release: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const templates = ref(pageContext.assets.templates);
|
||||
const selectedTemplate = ref(Number(pageContext.urlParsed.search.t) || templates.value.at(0)?.id || null);
|
||||
|
||||
const initialTemplate = templates.value.find((storedTemplate) => storedTemplate.id === selectedTemplate.value) || null;
|
||||
|
||||
const template = ref(initialTemplate?.template || defaultTemplate);
|
||||
const hasError = ref(false);
|
||||
const input = ref(null);
|
||||
const changed = ref(false);
|
||||
|
||||
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
|
||||
|
||||
function getSummary() {
|
||||
return processSummaryTemplate(template.value, props.release, pageContext.env);
|
||||
}
|
||||
|
||||
const summary = ref(getSummary());
|
||||
|
||||
function selectTemplate(templateId) {
|
||||
selectedTemplate.value = templateId;
|
||||
|
||||
const nextTemplate = templates.value.find((storedTemplate) => storedTemplate.id === templateId);
|
||||
|
||||
template.value = nextTemplate.template;
|
||||
templateName.value = nextTemplate.name;
|
||||
summary.value = getSummary();
|
||||
|
||||
cookies.set('selectedTemplate', String(templateId));
|
||||
window.location.href = `${window.location.origin}${window.location.pathname}?t=${nextTemplate.id}`;
|
||||
}
|
||||
|
||||
function update() {
|
||||
hasError.value = false;
|
||||
changed.value = true;
|
||||
|
||||
try {
|
||||
summary.value = getSummary();
|
||||
} catch (error) {
|
||||
hasError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(explicit) {
|
||||
if (!explicit && !changed.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
parse(template.value);
|
||||
|
||||
const createdTemplate = await post('/templates', {
|
||||
name: templateName.value,
|
||||
template: template.value,
|
||||
}, {
|
||||
successFeedback: `Saved summary template '${templateName.value}'`,
|
||||
errorFeedback: `Failed to save summary template '${templateName.value}'`,
|
||||
});
|
||||
|
||||
changed.value = false;
|
||||
templates.value = await get(`/users/${pageContext.user.id}/templates`);
|
||||
|
||||
selectTemplate(createdTemplate.id);
|
||||
} catch (error) {
|
||||
events.emit('feedback', {
|
||||
type: 'error',
|
||||
message: `Failed to save summary template '${templateName.value}': ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function add() {
|
||||
selectedTemplate.value = null;
|
||||
template.value = '';
|
||||
templateName.value = `custom_${Date.now()}`;
|
||||
summary.value = '';
|
||||
|
||||
input.value.focus();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (confirm(`Are you sure you want to delete summary template ${templateName.value}?`)) { // eslint-disable-line no-restricted-globals, no-alert
|
||||
await del(`/templates/${selectedTemplate.value}`, {
|
||||
undoFeedback: `Deleted summary template '${templateName.value}'`,
|
||||
errorFeedback: `Failed to remove summary template '${templateName.value}'`,
|
||||
});
|
||||
|
||||
templates.value = await get(`/users/${pageContext.user.id}/templates`);
|
||||
|
||||
selectTemplate(templates.value.at(-1)?.id);
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(summary.value);
|
||||
|
||||
events.emit('feedback', {
|
||||
type: 'success',
|
||||
message: 'Summary copied to clipboard',
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (confirm('Are you sure you want to reset the summary template to the default? Your custom template will be discarded.')) { // eslint-disable-line no-restricted-globals, no-alert
|
||||
template.value = defaultTemplate;
|
||||
|
||||
update();
|
||||
|
||||
events.emit('feedback', {
|
||||
type: 'undo',
|
||||
message: 'Reset summary template',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function indent() {
|
||||
// YAML does not support tabs
|
||||
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (changed.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Meta from '#/components/scenes/meta.vue';
|
||||
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
|
||||
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
|
||||
|
||||
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
|
||||
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tile"
|
||||
@@ -115,33 +142,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
import Meta from '#/components/scenes/meta.vue';
|
||||
import Heart from '#/components/stashes/heart.vue';
|
||||
|
||||
const props = defineProps({
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const currentStash = pageStash || pageContext.assets?.primaryStash;
|
||||
|
||||
const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
|
||||
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
|
||||
|
||||
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
|
||||
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tile {
|
||||
display: flex;
|
||||
@@ -243,8 +243,8 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
||||
}
|
||||
|
||||
.actors {
|
||||
height: 1.15rem;
|
||||
margin-bottom: .15rem;
|
||||
height: 1lh;
|
||||
margin-bottom: .3rem;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<script setup>
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
serie: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="serie-tile">
|
||||
<a
|
||||
@@ -22,17 +33,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
defineProps({
|
||||
serie: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.serie-tile {
|
||||
display: flex;
|
||||
|
||||
76
components/settings/filters.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
});
|
||||
|
||||
const tags = {
|
||||
'anal': 'anal',
|
||||
'anal-prolapse': 'anal prolapse',
|
||||
'extreme-insertion': 'extreme insertion (oversized dildos)',
|
||||
'pissing': 'pissing',
|
||||
'gay': 'gay',
|
||||
'transsexual': 'transsexual',
|
||||
'bisexual': 'bisexual',
|
||||
'compilation': 'compilation',
|
||||
'bts': 'behind the scenes',
|
||||
'vr': 'virtual reality',
|
||||
};
|
||||
|
||||
const storedTags = cookies.get('tags');
|
||||
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
|
||||
|
||||
function toggleTag(tag, isChecked) {
|
||||
if (isChecked) {
|
||||
checkedTags.value.add(tag);
|
||||
}
|
||||
else {
|
||||
checkedTags.value.delete(tag);
|
||||
}
|
||||
|
||||
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dialog-section">
|
||||
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
|
||||
|
||||
<ul class="tags nolist">
|
||||
<li
|
||||
v-for="(label, slug) in tags"
|
||||
:key="`tag-${slug}`"
|
||||
class="tags-item"
|
||||
>
|
||||
<label class="tag noselect">
|
||||
<Checkbox
|
||||
:value="slug"
|
||||
:checked="checkedTags.has(slug)"
|
||||
class="minus"
|
||||
@change="(checked) => toggleTag(slug, checked)"
|
||||
/>{{ label }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: .5rem 0;
|
||||
text-transform: capitalize;
|
||||
|
||||
.check-container {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
components/settings/notifications.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// import Checkbox from '#/components/form/checkbox.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dialog-section">
|
||||
<div class="setting">
|
||||
Combine e-mail alerts
|
||||
<select
|
||||
class="input"
|
||||
>
|
||||
<option>Never</option>
|
||||
<option>Daily</option>
|
||||
<option>Weekly</option>
|
||||
<option>Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +1,141 @@
|
||||
<script setup>
|
||||
// this component is set up as a generic settings panel, but as most account settings will be handled through the profile page, it is parading as a dedicated filters dialog
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import Filters from '#/components/settings/filters.vue';
|
||||
import Notifications from '#/components/settings/notifications.vue';
|
||||
|
||||
defineProps({
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
const activeSection = ref('filters');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog title="Settings">
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-section">
|
||||
<h3 class="dialog-heading">Filter</h3>
|
||||
|
||||
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
|
||||
|
||||
<ul class="tags nolist">
|
||||
<li
|
||||
v-for="(label, slug) in tags"
|
||||
:key="`tag-${slug}`"
|
||||
class="tags-item"
|
||||
<Dialog
|
||||
title="Filters"
|
||||
class="settings-dialog"
|
||||
>
|
||||
<label class="tag noselect">
|
||||
<Checkbox
|
||||
:value="slug"
|
||||
:checked="checkedTags.has(slug)"
|
||||
class="minus"
|
||||
@change="(checked) => toggleTag(slug, checked)"
|
||||
/>{{ label }}
|
||||
</label>
|
||||
<div class="dialog-body">
|
||||
<ul
|
||||
v-if="!activeSection"
|
||||
class="sections"
|
||||
>
|
||||
<li
|
||||
class="section"
|
||||
@click="activeSection = 'filters'"
|
||||
>
|
||||
<Icon icon="filter" />
|
||||
Filters
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="section"
|
||||
@click="activeSection = 'notifications'"
|
||||
>
|
||||
<Icon icon="bell2" />
|
||||
Notifications
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-else-if="showNavigation"
|
||||
class="section-header"
|
||||
>
|
||||
<span
|
||||
class="section-back"
|
||||
@click="activeSection = null"
|
||||
><Icon icon="arrow-left3" />Back to overview</span>
|
||||
|
||||
<strong class="section-label">{{ activeSection }}</strong>
|
||||
</div>
|
||||
|
||||
<Filters v-if="activeSection === 'filters'" />
|
||||
<Notifications v-if="activeSection === 'notifications'" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
});
|
||||
|
||||
const tags = {
|
||||
anal: 'anal',
|
||||
'anal-prolapse': 'anal prolapse',
|
||||
'extreme-insertion': 'extreme insertion (oversized dildos)',
|
||||
pissing: 'pissing',
|
||||
gay: 'gay',
|
||||
transsexual: 'transsexual',
|
||||
bisexual: 'bisexual',
|
||||
compilation: 'compilation',
|
||||
bts: 'behind the scenes',
|
||||
vr: 'virtual reality',
|
||||
};
|
||||
|
||||
const storedTags = cookies.get('tags');
|
||||
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
|
||||
|
||||
function toggleTag(tag, isChecked) {
|
||||
if (isChecked) {
|
||||
checkedTags.value.add(tag);
|
||||
} else {
|
||||
checkedTags.value.delete(tag);
|
||||
}
|
||||
|
||||
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
width: 30rem;
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tags-item {
|
||||
display: block;
|
||||
.dialog-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
.sections {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
padding: .5rem 0;
|
||||
text-transform: capitalize;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: .25rem;
|
||||
font-size: 1.1rem;
|
||||
|
||||
.check-container {
|
||||
margin-right: 1rem;
|
||||
.icon {
|
||||
fill: var(--glass-strong-10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
}
|
||||
|
||||
.section-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: .75rem 1rem;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--primary);
|
||||
text-transform: capitalize;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import logo from '#/assets/img/logo.svg?raw';
|
||||
|
||||
const emit = defineEmits(['sidebar']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sidebar-container"
|
||||
@@ -85,17 +96,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import logo from '#/assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
|
||||
|
||||
const emit = defineEmits(['sidebar']);
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const emit = defineEmits(['created', 'close']);
|
||||
|
||||
const stashName = ref(null);
|
||||
const stashNameInput = ref(null);
|
||||
|
||||
async function createStash() {
|
||||
const newStash = await post('/stashes', {
|
||||
name: stashName.value,
|
||||
public: false,
|
||||
}, {
|
||||
successFeedback: `Stash '${stashName.value}' created`,
|
||||
errorFeedback: `Failed to create stash '${stashName.value}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
emit('created', newStash);
|
||||
stashName.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
title="New stash"
|
||||
@@ -23,33 +50,6 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
|
||||
const emit = defineEmits(['created', 'close']);
|
||||
|
||||
const stashName = ref(null);
|
||||
const stashNameInput = ref(null);
|
||||
|
||||
async function createStash() {
|
||||
const newStash = await post('/stashes', {
|
||||
name: stashName.value,
|
||||
public: false,
|
||||
}, {
|
||||
successFeedback: `Stash '${stashName.value}' created`,
|
||||
errorFeedback: `Failed to create stash '${stashName.value}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
emit('created', newStash);
|
||||
stashName.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,16 +1,222 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref, useId } from 'vue';
|
||||
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
import Icon from '#/components/icon/icon.vue';
|
||||
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import StashMenu from '#/components/stashes/menu.vue';
|
||||
import { del, get, post } from '#/src/api.js';
|
||||
import ellipsis from '#/utils/ellipsis.js';
|
||||
|
||||
const props = defineProps({
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showSecondary: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAlert: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stashed', 'unstashed']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const user = pageContext.user;
|
||||
|
||||
const stashes = ref(pageContext.assets?.stashes);
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
const itemStashes = ref(props.item.stashes);
|
||||
const itemAlerts = ref(props.item.alerts);
|
||||
const itemAlerted = ref(props.item.alerts?.only.length > 0);
|
||||
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
|
||||
|
||||
const done = ref(true);
|
||||
const stashMenuDropdownId = useId();
|
||||
const stashTogglesDropdownId = useId();
|
||||
const showStashes = ref(false);
|
||||
const showStashDialog = ref(false);
|
||||
const showAlertDialog = ref(false);
|
||||
const feedbackCutoff = 20;
|
||||
|
||||
const itemKeys = {
|
||||
scenes: 'sceneId',
|
||||
actors: 'actorId',
|
||||
movies: 'movieId',
|
||||
};
|
||||
|
||||
async function stashItem(stash) {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashState = itemStashes.value;
|
||||
|
||||
done.value = false;
|
||||
itemStashes.value = itemStashes.value.concat(stash);
|
||||
|
||||
try {
|
||||
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
|
||||
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
|
||||
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
|
||||
});
|
||||
|
||||
emit('stashed', stash);
|
||||
}
|
||||
catch (error) {
|
||||
itemStashes.value = stashState;
|
||||
}
|
||||
finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function unstashItem(stash) {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashState = itemStashes.value;
|
||||
|
||||
done.value = false;
|
||||
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
|
||||
|
||||
try {
|
||||
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
|
||||
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
|
||||
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
|
||||
});
|
||||
|
||||
emit('unstashed', stash);
|
||||
}
|
||||
catch (error) {
|
||||
itemStashes.value = stashState;
|
||||
}
|
||||
finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function alertItem() {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertLabel = props.item.title || props.item.name;
|
||||
|
||||
done.value = false;
|
||||
itemAlerted.value = true;
|
||||
|
||||
try {
|
||||
const newAlert = await post('/alerts', {
|
||||
...(props.domain === 'actors' && { actors: [props.item.id] }),
|
||||
...(props.domain === 'tags' && { tags: [props.item.id] }),
|
||||
...(props.domain === 'entities' && { entities: [props.item.id] }),
|
||||
...(props.domain === 'scenes' && { scenes: [props.item.id] }),
|
||||
notify: true,
|
||||
email: false,
|
||||
preset: true,
|
||||
}, {
|
||||
successFeedback: `Alert set for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to set alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
|
||||
}
|
||||
catch (error) {
|
||||
itemAlerted.value = false;
|
||||
}
|
||||
finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAlert() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertLabel = props.item.title || props.item.name;
|
||||
const alertIds = itemAlerts.value.only;
|
||||
|
||||
done.value = false;
|
||||
|
||||
itemAlerted.value = false;
|
||||
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
|
||||
|
||||
try {
|
||||
await del(`/alerts/${alertIds.join(',')}`, {
|
||||
undoFeedback: `Removed alert for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
itemAlerted.value = true;
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
|
||||
}
|
||||
finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setAlerted(alert) {
|
||||
// only set alerted status if the current item is the only one in the new stash
|
||||
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
|
||||
|
||||
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
|
||||
itemAlerted.value = true;
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
|
||||
}
|
||||
else {
|
||||
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowStashes(state) {
|
||||
if (props.showSecondary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state) {
|
||||
showStashes.value = state;
|
||||
return;
|
||||
}
|
||||
|
||||
showStashes.value = !showStashes.value;
|
||||
}
|
||||
|
||||
async function reloadStashes(newStash) {
|
||||
stashes.value = await get(`/users/${user.id}/stashes`);
|
||||
|
||||
await stashItem(newStash);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="user"
|
||||
class="bookmarks"
|
||||
>
|
||||
<div
|
||||
v-if="showSecondary && !!itemAlerts"
|
||||
v-if="showSecondary && showAlert && !!itemAlerts"
|
||||
class="alert-trigger"
|
||||
>
|
||||
<Icon
|
||||
v-if="itemAlerted"
|
||||
v-tooltip="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
|
||||
icon="bell2"
|
||||
:title="`Remove ${itemAlerts.only.length} uncombined alert${itemAlerts.only.length > 1 ? 's' : ''} for '${item.title || item.name}'`"
|
||||
class="alert active noselect"
|
||||
@click="removeAlert"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
@@ -18,8 +224,8 @@
|
||||
|
||||
<Icon
|
||||
v-else-if="itemAlerts.multi.length > 0"
|
||||
v-tooltip="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
|
||||
icon="bell-plus"
|
||||
:title="`Set alert for '${item.title || item.name}', ${itemAlerts.multi.length} combined alert${itemAlerts.multi.length > 1 ? 's' : ''} active`"
|
||||
class="alert partial noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
@@ -27,8 +233,8 @@
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip="domain === 'scenes' ? `Set alert for the release of '${item.title}'` : `Set alert for '${item.title || item.name}' releases`"
|
||||
icon="bell-plus"
|
||||
:title="`Set alert for '${item.title || item.name}' releases`"
|
||||
class="alert noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
@@ -37,6 +243,7 @@
|
||||
|
||||
<VDropdown
|
||||
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
||||
:aria-id="stashMenuDropdownId"
|
||||
:shown="showStashes"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
@@ -60,6 +267,7 @@
|
||||
|
||||
<VDropdown
|
||||
v-if="itemStashes && (showSecondary || !hasSecondaryStash || pageStash?.user.id === user.id)"
|
||||
:aria-id="stashTogglesDropdownId"
|
||||
:triggers="[]"
|
||||
:shown="showStashes"
|
||||
:disabled="showSecondary"
|
||||
@@ -70,7 +278,7 @@
|
||||
v-if="itemStashes.some((itemStash) => itemStash.id === pageStash.id)"
|
||||
:icon="pageStash.isPrimary ? 'heart7' : 'folder-heart'"
|
||||
class="heart favorited noselect"
|
||||
@click.native.stop="unstashItem(pageStash)"
|
||||
@click.stop="unstashItem(pageStash)"
|
||||
@contextmenu.prevent="toggleShowStashes(true)"
|
||||
/>
|
||||
|
||||
@@ -78,7 +286,7 @@
|
||||
v-else
|
||||
:icon="pageStash.isPrimary ? 'heart8' : 'folder-heart'"
|
||||
class="heart noselect"
|
||||
@click.native.stop="stashItem(pageStash)"
|
||||
@click.stop="stashItem(pageStash)"
|
||||
@contextmenu.prevent="toggleShowStashes(true)"
|
||||
/>
|
||||
</template>
|
||||
@@ -88,7 +296,7 @@
|
||||
v-if="itemStashes.some((itemStash) => itemStash.id === primaryStash.id)"
|
||||
icon="heart7"
|
||||
class="heart favorited noselect"
|
||||
@click.native.stop="unstashItem(primaryStash)"
|
||||
@click.stop="unstashItem(primaryStash)"
|
||||
@contextmenu.prevent="toggleShowStashes(true)"
|
||||
/>
|
||||
|
||||
@@ -96,7 +304,7 @@
|
||||
v-else
|
||||
icon="heart8"
|
||||
class="heart noselect"
|
||||
@click.native.stop="stashItem(primaryStash)"
|
||||
@click.stop="stashItem(primaryStash)"
|
||||
@contextmenu.prevent="toggleShowStashes(true)"
|
||||
/>
|
||||
</template>
|
||||
@@ -127,196 +335,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
|
||||
import { get, post, del } from '#/src/api.js';
|
||||
import ellipsis from '#/utils/ellipsis.js';
|
||||
|
||||
import Icon from '#/components/icon/icon.vue';
|
||||
import StashMenu from '#/components/stashes/menu.vue';
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import AlertDialog from '#/components/alerts/create.vue';
|
||||
|
||||
const props = defineProps({
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
showSecondary: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stashed', 'unstashed']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const pageStash = pageContext.pageProps.stash;
|
||||
const user = pageContext.user;
|
||||
|
||||
const stashes = ref(pageContext.assets?.stashes);
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
const itemStashes = ref(props.item.stashes);
|
||||
const itemAlerts = ref(props.item.alerts);
|
||||
const itemAlerted = ref(props.item.alerts?.only.length > 0);
|
||||
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
|
||||
|
||||
const done = ref(true);
|
||||
const showStashes = ref(false);
|
||||
const showStashDialog = ref(false);
|
||||
const showAlertDialog = ref(false);
|
||||
const feedbackCutoff = 20;
|
||||
|
||||
const itemKeys = {
|
||||
scenes: 'sceneId',
|
||||
actors: 'actorId',
|
||||
movies: 'movieId',
|
||||
};
|
||||
|
||||
async function stashItem(stash) {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashState = itemStashes.value;
|
||||
|
||||
done.value = false;
|
||||
itemStashes.value = itemStashes.value.concat(stash);
|
||||
|
||||
try {
|
||||
await post(`/stashes/${stash.id}/${props.domain}`, { [itemKeys[props.domain]]: props.item.id }, {
|
||||
successFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" stashed to ${stash.name}`,
|
||||
errorFeedback: `Failed to stash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" to ${stash.name}`,
|
||||
});
|
||||
|
||||
emit('stashed', stash);
|
||||
} catch (error) {
|
||||
itemStashes.value = stashState;
|
||||
} finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function unstashItem(stash) {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashState = itemStashes.value;
|
||||
|
||||
done.value = false;
|
||||
itemStashes.value = itemStashes.value.filter((itemStash) => itemStash.id !== stash.id);
|
||||
|
||||
try {
|
||||
await del(`/stashes/${stash.id}/${props.domain}/${props.item.id}`, {
|
||||
undoFeedback: `"${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" unstashed from ${stash.name}`,
|
||||
errorFeedback: `Failed to unstash "${ellipsis(props.item.title || props.item.name, feedbackCutoff)}" from ${stash.name}`,
|
||||
});
|
||||
|
||||
emit('unstashed', stash);
|
||||
} catch (error) {
|
||||
itemStashes.value = stashState;
|
||||
} finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function alertItem() {
|
||||
if (!done.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertLabel = props.item.title || props.item.name;
|
||||
|
||||
done.value = false;
|
||||
itemAlerted.value = true;
|
||||
|
||||
try {
|
||||
const newAlert = await post('/alerts', {
|
||||
...(props.domain === 'actors' && { actors: [props.item.id] }),
|
||||
...(props.domain === 'tags' && { tags: [props.item.id] }),
|
||||
...(props.domain === 'entities' && { entities: [props.item.id] }),
|
||||
notify: true,
|
||||
email: false,
|
||||
preset: true,
|
||||
}, {
|
||||
successFeedback: `Alert set for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to set alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(newAlert.id);
|
||||
} catch (error) {
|
||||
itemAlerted.value = false;
|
||||
} finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAlert() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertLabel = props.item.title || props.item.name;
|
||||
const alertIds = itemAlerts.value.only;
|
||||
|
||||
done.value = false;
|
||||
|
||||
itemAlerted.value = false;
|
||||
itemAlerts.value.only = itemAlerts.value.only.filter((alertId) => !alertIds.includes(alertId));
|
||||
|
||||
try {
|
||||
await del(`/alerts/${alertIds.join(',')}`, {
|
||||
undoFeedback: `Removed alert for '${alertLabel}'`,
|
||||
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
} catch (error) {
|
||||
itemAlerted.value = true;
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(alertIds);
|
||||
} finally {
|
||||
done.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setAlerted(alert) {
|
||||
// only set alerted status if the current item is the only one in the new stash
|
||||
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
|
||||
|
||||
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
|
||||
itemAlerted.value = true;
|
||||
itemAlerts.value.only = itemAlerts.value.only.concat(alert.id);
|
||||
} else {
|
||||
itemAlerts.value.multi = itemAlerts.value.multi.concat(alert.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowStashes(state) {
|
||||
if (props.showSecondary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state) {
|
||||
showStashes.value = state;
|
||||
return;
|
||||
}
|
||||
|
||||
showStashes.value = !showStashes.value;
|
||||
}
|
||||
|
||||
async function reloadStashes(newStash) {
|
||||
stashes.value = await get(`/users/${user.id}/stashes`);
|
||||
|
||||
await stashItem(newStash);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bookmarks {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
<script setup>
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
defineProps({
|
||||
stashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
itemStashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="stash-menu nolist noselect">
|
||||
<li
|
||||
@@ -26,23 +43,6 @@
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
defineProps({
|
||||
stashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
itemStashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-item {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Domains from '#/components/domains/domains.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { routeParams } = pageContext;
|
||||
|
||||
const domain = routeParams.domain;
|
||||
const stash = pageContext.pageProps.stash;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stash">
|
||||
<div class="header">
|
||||
@@ -44,18 +56,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Domains from '#/components/domains/domains.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { routeParams } = pageContext;
|
||||
|
||||
const domain = routeParams.domain;
|
||||
const stash = pageContext.pageProps.stash;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stash {
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import StashTile from '#/components/stashes/tile.vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const user = pageContext.user;
|
||||
const stashes = ref(pageContext.pageProps.stashes);
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
|
||||
const showStashDialog = ref(false);
|
||||
|
||||
async function reloadStashes() {
|
||||
// profile.value = await get(`/users/${profile.value.id}`);
|
||||
stashes.value = await get(`/users/${profile.value.id}/stashes`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Stashes</h3>
|
||||
|
||||
<button
|
||||
v-if="profile.id === user?.id"
|
||||
class="button"
|
||||
@@ -34,29 +54,13 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import StashTile from '#/components/stashes/tile.vue';
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const user = pageContext.user;
|
||||
const stashes = ref(pageContext.pageProps.stashes);
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
|
||||
const showStashDialog = ref(false);
|
||||
|
||||
async function reloadStashes() {
|
||||
// profile.value = await get(`/users/${profile.value.id}`);
|
||||
stashes.value = await get(`/users/${profile.value.id}/stashes`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.stashes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
|
||||
@@ -1,3 +1,98 @@
|
||||
<script setup>
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import { del, patch } from '#/src/api.js';
|
||||
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
const props = defineProps({
|
||||
stash: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
profile: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
|
||||
const stashName = ref(props.stash.name);
|
||||
const stashNameInput = ref(null);
|
||||
const showRenameDialog = ref(false);
|
||||
const done = ref(true);
|
||||
const privateTooltipId = useId();
|
||||
const menuDropdownId = useId();
|
||||
|
||||
const domainCounts = {
|
||||
scenes: props.stash.stashedScenes,
|
||||
actors: props.stash.stashedActors,
|
||||
movies: props.stash.stashedMovies,
|
||||
};
|
||||
|
||||
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
|
||||
|
||||
async function setPublic(isPublic) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
|
||||
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
|
||||
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
|
||||
errorFeedback: 'Failed to update stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-alert
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await del(`/stashes/${props.stash.id}`, {
|
||||
undoFeedback: `Stash '${props.stash.name}' removed`,
|
||||
errorFeedback: 'Failed to remove stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
|
||||
successFeedback: `Stash renamed to ${stashName.value}`,
|
||||
errorFeedback: 'Failed to rename stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stash">
|
||||
<div class="stash-header">
|
||||
@@ -16,12 +111,12 @@
|
||||
|
||||
<Icon
|
||||
v-if="!stash.isPublic"
|
||||
v-tooltip="'This stash is private'"
|
||||
v-tooltip="{ content: 'This stash is private', ariaId: privateTooltipId }"
|
||||
icon="eye-blocked"
|
||||
class="private noselect"
|
||||
/>
|
||||
|
||||
<VDropdown>
|
||||
<VDropdown :aria-id="menuDropdownId">
|
||||
<Icon
|
||||
v-if="profile.id === user?.id"
|
||||
icon="more2"
|
||||
@@ -122,99 +217,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import { del, patch } from '#/src/api.js';
|
||||
import abbreviateNumber from '#/src/utils/abbreviate-number.js';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
stash: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
profile: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
|
||||
const stashName = ref(props.stash.name);
|
||||
const stashNameInput = ref(null);
|
||||
const showRenameDialog = ref(false);
|
||||
const done = ref(true);
|
||||
|
||||
const domainCounts = {
|
||||
scenes: props.stash.stashedScenes,
|
||||
actors: props.stash.stashedActors,
|
||||
movies: props.stash.stashedMovies,
|
||||
};
|
||||
|
||||
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
|
||||
|
||||
async function setPublic(isPublic) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { isPublic }, {
|
||||
undoFeedback: !isPublic && `Stash '${props.stash.name}' set to private`,
|
||||
successFeedback: isPublic && `Stash '${props.stash.name}' set to public`,
|
||||
errorFeedback: 'Failed to update stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to remove the stash '${props.stash.name}' and all its scenes, movies and actors? This is permament, it cannot be undone by you or staff!`)) { // eslint-disable-line no-restricted-globals, no-alert
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await del(`/stashes/${props.stash.id}`, {
|
||||
undoFeedback: `Stash '${props.stash.name}' removed`,
|
||||
errorFeedback: 'Failed to remove stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { name: stashName.value }, {
|
||||
successFeedback: `Stash renamed to ${stashName.value}`,
|
||||
errorFeedback: 'Failed to rename stash',
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stash {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
<script setup>
|
||||
import { useId } from 'vue';
|
||||
|
||||
defineProps({
|
||||
photo: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
favicon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const entityTooltipId = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="photo?.entity"
|
||||
v-tooltip="photo.entity.name"
|
||||
v-tooltip="{ content: photo.entity.name, ariaId: entityTooltipId }"
|
||||
:to="`/${photo.entity.type}/${photo.entity.slug}`"
|
||||
>
|
||||
<img
|
||||
@@ -30,19 +47,6 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
photo: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
favicon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.album-logo {
|
||||
max-height: .75rem;
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
import Logo from '#/components/tags/logo.vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['load', 'campaign']);
|
||||
|
||||
const { campaigns } = inject('pageContext');
|
||||
|
||||
const photos = computed(() => {
|
||||
/* sfw not currently implemented
|
||||
if (props.tag.poster && this.$store.state.ui.sfw) {
|
||||
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
|
||||
}
|
||||
|
||||
if (this.$store.state.ui.sfw) {
|
||||
return props.tag.photos.map((photo) => photo.sfw);
|
||||
}
|
||||
*/
|
||||
|
||||
if (props.tag.poster) {
|
||||
return [props.tag.poster].concat(props.tag.photos);
|
||||
}
|
||||
|
||||
return props.tag.photos;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="photos nobar">
|
||||
<Campaign
|
||||
@@ -40,44 +78,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import Logo from '#/components/tags/logo.vue';
|
||||
import Campaign from '#/components/campaigns/campaign.vue';
|
||||
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['load', 'campaign']);
|
||||
|
||||
const { campaigns } = inject('pageContext');
|
||||
|
||||
const photos = computed(() => {
|
||||
/* sfw not currently implemented
|
||||
if (props.tag.poster && this.$store.state.ui.sfw) {
|
||||
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
|
||||
}
|
||||
|
||||
if (this.$store.state.ui.sfw) {
|
||||
return props.tag.photos.map((photo) => photo.sfw);
|
||||
}
|
||||
*/
|
||||
|
||||
if (props.tag.poster) {
|
||||
return [props.tag.poster].concat(props.tag.photos);
|
||||
}
|
||||
|
||||
return props.tag.photos;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.photos {
|
||||
display: flex;
|
||||
|
||||