Compare commits
98 Commits
cea58d12ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e6919a4283 | |||
| f7993a9108 | |||
| 134664095a | |||
| 7b2495cef5 | |||
| 27ce8b0ceb | |||
| 0e5724533f |
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
||||||
|
|
||||||
|
<!-- RTA restricted to adults label -->
|
||||||
|
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
|
||||||
|
|
||||||
<title>traxxx - Consent</title>
|
<title>traxxx - Consent</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -156,6 +159,12 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rta {
|
||||||
|
position: fixed;
|
||||||
|
bottom: .5rem;
|
||||||
|
right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width: 800px) {
|
@media(max-width: 800px) {
|
||||||
.heading {
|
.heading {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -219,5 +228,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="/img/rta.gif"
|
||||||
|
alt="RTA Restricted To Adults"
|
||||||
|
title="RTA Restricted To Adults"
|
||||||
|
class="rta"
|
||||||
|
>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: stretch;
|
/* align-items: stretch; */
|
||||||
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
fill: var(--glass);
|
fill: var(--glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
@@ -55,6 +56,10 @@
|
|||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--glass-weak-30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-label {
|
.button-label {
|
||||||
@@ -119,12 +124,22 @@
|
|||||||
|
|
||||||
.button-cancel {
|
.button-cancel {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--glass);
|
color: var(--error);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: var(--error);
|
color: var(--text-light);
|
||||||
|
background: var(--error);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--text-light);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
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 |
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/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
175
components/actors/add.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import Dialog from '#/components/dialog/dialog.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
import Checkbox from '#/components/form/checkbox.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>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -20,6 +20,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="bio nolist">
|
<ul class="bio nolist">
|
||||||
|
<li
|
||||||
|
v-if="aliases.length > 0"
|
||||||
|
class="bio-item"
|
||||||
|
>
|
||||||
|
<dfn class="bio-label"><Icon icon="at-sign" />Aliases</dfn>
|
||||||
|
|
||||||
|
<span class="orientation">{{ aliases.join(', ') }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
v-if="actor.dateOfBirth"
|
v-if="actor.dateOfBirth"
|
||||||
class="bio-item"
|
class="bio-item"
|
||||||
@@ -80,10 +89,17 @@
|
|||||||
<span
|
<span
|
||||||
v-if="actor.origin.city"
|
v-if="actor.origin.city"
|
||||||
class="city"
|
class="city"
|
||||||
>{{ actor.origin.city }}</span><span
|
>{{ actor.origin.city }}</span>
|
||||||
|
|
||||||
|
<span
|
||||||
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
|
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
|
||||||
class="state"
|
class="state"
|
||||||
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span>
|
>
|
||||||
|
{{ actor.origin.city
|
||||||
|
? [',', actor.origin.state].join(' ')
|
||||||
|
: actor.origin.state
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="actor.origin.country"
|
v-if="actor.origin.country"
|
||||||
@@ -105,7 +121,7 @@
|
|||||||
class="bio-item residence"
|
class="bio-item residence"
|
||||||
:class="{ hideable: !!actor.origin }"
|
:class="{ hideable: !!actor.origin }"
|
||||||
>
|
>
|
||||||
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
|
<dfn class="bio-label"><Icon icon="location" />{{ actor.dateOfDeath ? 'Lived' : 'Lives' }} in</dfn>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<span
|
<span
|
||||||
@@ -312,10 +328,10 @@
|
|||||||
<a
|
<a
|
||||||
v-for="social in socials"
|
v-for="social in socials"
|
||||||
:key="`social-${social.id}`"
|
:key="`social-${social.id}`"
|
||||||
|
v-tooltip="social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url"
|
||||||
:href="getSocialUrl(social)"
|
:href="getSocialUrl(social)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
:title="social.platform || social.url"
|
|
||||||
class="social ellipsis"
|
class="social ellipsis"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -339,7 +355,9 @@
|
|||||||
:class="`icon-social icon-${social.platform} icon-generic`"
|
:class="`icon-social icon-${social.platform} icon-generic`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
<template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
|
<template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
|
||||||
|
-->
|
||||||
</a>
|
</a>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,10 +381,22 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
class="link"
|
class="link"
|
||||||
>Revisions</a>
|
>Revisions</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="user && user.role !== 'user'"
|
||||||
|
class="link"
|
||||||
|
@click="showMergeDialog = true"
|
||||||
|
>Merge</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<Merge
|
||||||
|
v-if="showMergeDialog"
|
||||||
|
:actors="[actor]"
|
||||||
|
@close="showMergeDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="descriptions-container">
|
<div class="descriptions-container">
|
||||||
<div
|
<div
|
||||||
v-if="descriptions.length > 0"
|
v-if="descriptions.length > 0"
|
||||||
@@ -427,6 +457,8 @@ import formatTemplate from 'template-format';
|
|||||||
import getPath from '#/src/get-path.js';
|
import getPath from '#/src/get-path.js';
|
||||||
import { formatDate } from '#/utils/format.js';
|
import { formatDate } from '#/utils/format.js';
|
||||||
|
|
||||||
|
import Merge from '#/components/actors/merge.vue';
|
||||||
|
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
@@ -498,12 +530,23 @@ function getSocialUrl(social) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const socials = props.actor.socials.map((social) => ({
|
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||||
...social,
|
...social,
|
||||||
handle: social.url
|
handle: social.url
|
||||||
? new URL(social.url).hostname
|
? new URL(social.url).hostname
|
||||||
: social.handle,
|
: 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);
|
||||||
|
|
||||||
|
const showMergeDialog = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -735,19 +778,23 @@ const socials = props.actor.socials.map((social) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.socials {
|
.socials {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
/*
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
grid-gap: 0 0;
|
grid-gap: 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
*/
|
||||||
gap: .25rem;
|
gap: .25rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social {
|
.social {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
height: 2rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .1rem .5rem;
|
justify-content: center;
|
||||||
|
padding: .75rem .75rem;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -755,10 +802,6 @@ const socials = props.actor.socials.map((social) => ({
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
background: var(--highlight-weak-40);
|
background: var(--highlight-weak-40);
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-right: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-generic {
|
.icon-generic {
|
||||||
fill: var(--highlight);
|
fill: var(--highlight);
|
||||||
}
|
}
|
||||||
@@ -783,6 +826,7 @@ const socials = props.actor.socials.map((social) => ({
|
|||||||
.link {
|
.link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
285
components/actors/merge.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<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
|
||||||
|
:triggers="[]"
|
||||||
|
:shown="actorResults.length > 0"
|
||||||
|
:auto-hide="false"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="actorInput"
|
||||||
|
v-model="actorQuery"
|
||||||
|
class="input"
|
||||||
|
placeholder="Search target actor"
|
||||||
|
@input="searchActors"
|
||||||
|
>
|
||||||
|
|
||||||
|
<template #popper>
|
||||||
|
<ul
|
||||||
|
class="results nolist"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="actorResult in actorResults"
|
||||||
|
:key="`actor-result-${actorResult.id}`"
|
||||||
|
v-close-popper
|
||||||
|
class="result-item"
|
||||||
|
@click="selectActor(actorResult)"
|
||||||
|
>
|
||||||
|
<div class="result-label">
|
||||||
|
<span class="result-id">#{{ actorResult.id }}</span> {{ actorResult.name }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
v-tooltip="sourceTargetConflict && 'Cannot merge actor profile into itself'"
|
||||||
|
type="submit"
|
||||||
|
class="button button-primary"
|
||||||
|
:disabled="!targetActor || sourceTargetConflict"
|
||||||
|
@click="merge"
|
||||||
|
>Merge</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import Dialog from '#/components/dialog/dialog.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
|
import { get, post } from '#/src/api.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
actors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'merged']);
|
||||||
|
|
||||||
|
const targetActor = ref(null);
|
||||||
|
const actorInput = ref(null);
|
||||||
|
const actorQuery = ref('');
|
||||||
|
const actorResults = ref([]);
|
||||||
|
const submitted = ref(false);
|
||||||
|
const merged = ref(false);
|
||||||
|
|
||||||
|
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
|
||||||
|
|
||||||
|
async function searchActors() {
|
||||||
|
const res = await get('/actors', {
|
||||||
|
q: actorQuery.value, // return partial matches
|
||||||
|
limit: 10,
|
||||||
|
global: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
actorResults.value = res.actors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActorNames() {
|
||||||
|
if (props.actors.length > 1) {
|
||||||
|
return `${props.actors.length} actors`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.actors[0].entity) {
|
||||||
|
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.actors[0].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function merge() {
|
||||||
|
submitted.value = true;
|
||||||
|
|
||||||
|
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, null, {
|
||||||
|
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
|
||||||
|
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
submitted.value = false;
|
||||||
|
merged.value = true;
|
||||||
|
|
||||||
|
emit('merged');
|
||||||
|
|
||||||
|
// emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectActor(actor) {
|
||||||
|
targetActor.value = actor;
|
||||||
|
actorQuery.value = '';
|
||||||
|
actorResults.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
actorInput.value.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-body {
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actors {
|
||||||
|
max-height: 15rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
color: var(--glass-strong-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding: .25rem .75rem;
|
||||||
|
fill: var(--glass);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-id,
|
||||||
|
.target-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
padding: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="actor.ageAtDeath"
|
v-if="actor.ageAtDeath"
|
||||||
:title="`Passed ${formatDate(actor.ageAtDeath, 'MMMM d, yyyy')}`"
|
:title="`Passed ${formatDate(actor.dateOfDeath, 'MMMM d, yyyy')}`"
|
||||||
class="age age-death"
|
class="age age-death"
|
||||||
>{{ actor.ageAtDeath }}</span>
|
>{{ actor.ageAtDeath }}</span>
|
||||||
|
|
||||||
@@ -74,7 +74,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="name ellipsis">{{ actor.name }}</span>
|
<span
|
||||||
|
class="name ellipsis"
|
||||||
|
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
|
||||||
|
>{{ actor.name }}</span>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
v-if="actor.entity"
|
v-if="actor.entity"
|
||||||
@@ -82,6 +85,13 @@
|
|||||||
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
|
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
|
||||||
class="favicon"
|
class="favicon"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-if="actor.alias && actor.alias.name !== actor.name"
|
||||||
|
v-tooltip="`Credited as '${actor.alias.name}'`"
|
||||||
|
icon="at-sign"
|
||||||
|
class="alias"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -128,6 +138,10 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
|||||||
:deep(.bookmarks) .icon:not(.favorited):not(:hover) {
|
:deep(.bookmarks) .icon:not(.favorited):not(:hover) {
|
||||||
fill: var(--text-light);
|
fill: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
fill: var(--text-light);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unstashed {
|
&.unstashed {
|
||||||
@@ -135,7 +149,16 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
fill: var(--highlight-strong-20);
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
height: 1.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -149,7 +172,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
padding: .35rem .25rem .35rem .5rem;
|
padding: 0 .25rem 0 .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favicon {
|
.favicon {
|
||||||
@@ -246,4 +269,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
|
|||||||
height: .75rem;
|
height: .75rem;
|
||||||
margin-left: .25rem;
|
margin-left: .25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alias {
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--glass-weak-20);
|
||||||
|
padding: 0 .25rem;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<ul class="nav-items nolist">
|
<ul class="nav-items nolist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
href="/admin/actors"
|
||||||
|
class="nav-link nolink"
|
||||||
|
:class="{ active: pageContext.routeParams.section === 'actors' }"
|
||||||
|
>Actors</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a
|
<a
|
||||||
href="/admin/revisions/scenes"
|
href="/admin/revisions/scenes"
|
||||||
|
|||||||
@@ -19,10 +19,16 @@
|
|||||||
>{{ avatar.sharpness.toFixed(2) }}</span>
|
>{{ avatar.sharpness.toFixed(2) }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
:title="`Added ${format(avatar.createdAt, 'yyyy-MM-dd')}, may not reflect photo age`"
|
||||||
|
class="avatar-date"
|
||||||
|
>{{ format(avatar.createdAt, '\'\'yy-MM') }}</span>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
:href="getPath(avatar)"
|
:href="getPath(avatar)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="avatar-zoom"
|
class="avatar-zoom"
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="search"
|
icon="search"
|
||||||
@@ -32,6 +38,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { format } from 'date-fns';
|
||||||
import getPath from '#/src/get-path.js';
|
import getPath from '#/src/get-path.js';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
@@ -89,7 +96,8 @@ defineProps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar-meta,
|
.avatar-meta,
|
||||||
.avatar-credit {
|
.avatar-credit,
|
||||||
|
.avatar-date {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -112,4 +120,10 @@ defineProps({
|
|||||||
bottom: .75rem;
|
bottom: .75rem;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ const expanded = ref(new Set());
|
|||||||
|
|
||||||
const mappedKeys = {
|
const mappedKeys = {
|
||||||
actors: actorsById,
|
actors: actorsById,
|
||||||
tags: tagsById,
|
// tags: tagsById,
|
||||||
movies: moviesById,
|
movies: moviesById,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,6 +292,16 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
|||||||
}))];
|
}))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
if (key === 'socials') {
|
||||||
// new socials don't have IDs yet, so we need to compare the values
|
// new socials don't have IDs yet, so we need to compare the values
|
||||||
return [key, value.map((item) => ({
|
return [key, value.map((item) => ({
|
||||||
@@ -323,6 +333,19 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
if (delta.key === 'socials') {
|
||||||
// new socials don't have IDs yet, so we need to compare the values
|
// new socials don't have IDs yet, so we need to compare the values
|
||||||
return {
|
return {
|
||||||
@@ -378,6 +401,8 @@ async function reviewRevision(revision, isApproved) {
|
|||||||
await post(`/revisions/${domain}/${revision.id}/reviews`, {
|
await post(`/revisions/${domain}/${revision.id}/reviews`, {
|
||||||
isApproved,
|
isApproved,
|
||||||
feedback: feedbacks.value[revision.id],
|
feedback: feedbacks.value[revision.id],
|
||||||
|
}, {
|
||||||
|
appendErrorMessage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
|
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
|
||||||
|
|||||||
@@ -12,25 +12,25 @@
|
|||||||
<Icon icon="search" />
|
<Icon icon="search" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!--
|
<template v-if="isActorTagsAvailable">
|
||||||
<div
|
<div
|
||||||
v-show="showActorTags"
|
v-show="showActorTags"
|
||||||
v-tooltip="'Tags relevant to the selected actors'"
|
v-tooltip="'Tags relevant to the selected actors'"
|
||||||
class="filter-sort order noselect"
|
class="filter-sort order noselect"
|
||||||
@click="showActorTags = false"
|
@click="showActorTags = false"
|
||||||
>
|
>
|
||||||
<Icon icon="user-tags" />
|
<Icon icon="user-tags" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="!showActorTags"
|
v-show="!showActorTags"
|
||||||
v-tooltip="'All tags'"
|
v-tooltip="'All tags'"
|
||||||
class="filter-sort order noselect"
|
class="filter-sort order noselect"
|
||||||
@click="showActorTags = true"
|
@click="showActorTags = true"
|
||||||
>
|
>
|
||||||
<Icon icon="price-tags" />
|
<Icon icon="price-tags" />
|
||||||
</div>
|
</div>
|
||||||
-->
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="order === 'priority'"
|
v-show="order === 'priority'"
|
||||||
@@ -142,42 +142,47 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update']);
|
const emit = defineEmits(['update']);
|
||||||
|
|
||||||
const { pageProps } = inject('pageContext');
|
const { pageProps } = inject('pageContext');
|
||||||
// const { tag: pageTag, actor: pageActor } = pageProps;
|
|
||||||
const { tag: pageTag } = pageProps;
|
const {
|
||||||
|
tag: pageTag,
|
||||||
|
actor: pageActor,
|
||||||
|
} = pageProps;
|
||||||
|
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
|
||||||
const order = ref('priority');
|
const order = ref('priority');
|
||||||
// const showActorTags = ref(true);
|
const showActorTags = ref(!!pageActor);
|
||||||
|
|
||||||
const priorityTags = [
|
const priorityTags = [
|
||||||
'anal',
|
'anal',
|
||||||
|
'dp',
|
||||||
|
'threesome',
|
||||||
'gangbang',
|
'gangbang',
|
||||||
'blowbang',
|
'blowbang',
|
||||||
'transsexual',
|
'orgy',
|
||||||
'airtight',
|
'airtight',
|
||||||
'dp',
|
|
||||||
'dap',
|
'dap',
|
||||||
'dvp',
|
'dvp',
|
||||||
'triple-penetration',
|
'triple-penetration',
|
||||||
'tap',
|
'tap',
|
||||||
'tvp',
|
'tvp',
|
||||||
|
'transsexual',
|
||||||
|
'spitroast',
|
||||||
'mfm',
|
'mfm',
|
||||||
'fmf',
|
'fmf',
|
||||||
'threesome',
|
|
||||||
'bdsm',
|
'bdsm',
|
||||||
'deepthroat',
|
'deepthroat',
|
||||||
'blowjob',
|
'blowjob',
|
||||||
'lesbian',
|
'lesbian',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor));
|
||||||
|
|
||||||
const groupedTags = computed(() => {
|
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 && props.actorTags && (props.filters.actors.length > 0 || pageActor)
|
const tags = showActorTags.value && isActorTagsAvailable.value
|
||||||
? props.actorTags
|
? props.actorTags
|
||||||
: props.tags;
|
: props.tags;
|
||||||
*/
|
|
||||||
const tags = props.tags;
|
|
||||||
|
|
||||||
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
|
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
|
||||||
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
|
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
|
||||||
@@ -219,6 +224,8 @@ const groupedTags = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function toggleTag(tag, combine) {
|
function toggleTag(tag, combine) {
|
||||||
|
emit('update', 'onlyActorTags', showActorTags.value, false);
|
||||||
|
|
||||||
if (props.filters.tags.includes(tag.slug)) {
|
if (props.filters.tags.includes(tag.slug)) {
|
||||||
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
|
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ const filters = ref({
|
|||||||
search: urlParsed.search.q,
|
search: urlParsed.search.q,
|
||||||
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
|
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
|
||||||
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
|
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
|
||||||
|
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
|
||||||
entity: queryEntity,
|
entity: queryEntity,
|
||||||
actors: queryActors,
|
actors: queryActors,
|
||||||
});
|
});
|
||||||
@@ -346,6 +347,7 @@ async function search(options = {}) {
|
|||||||
years: filters.value.years.join(',') || undefined,
|
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
|
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,
|
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?.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,
|
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
|
||||||
}, { redirect: false });
|
}, { redirect: false });
|
||||||
@@ -355,6 +357,7 @@ async function search(options = {}) {
|
|||||||
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
|
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
|
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(','),
|
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
|
||||||
|
at: !!filters.value.onlyActorTags,
|
||||||
stashId: pageStash?.id,
|
stashId: pageStash?.id,
|
||||||
e: entitySlug,
|
e: entitySlug,
|
||||||
scope: scope.value,
|
scope: scope.value,
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ module.exports = {
|
|||||||
stashes: {
|
stashes: {
|
||||||
nameLength: [2, 24],
|
nameLength: [2, 24],
|
||||||
namePattern: /^[a-zA-Z0-9!?$&\s_-]+$/,
|
namePattern: /^[a-zA-Z0-9!?$&\s_-]+$/,
|
||||||
viewRefreshCron: '* * * * *', // every minute
|
viewRefreshCrontab: '* * * * *', // every minute
|
||||||
viewRefreshCooldowns: {
|
viewRefreshCooldowns: {
|
||||||
actors: 60, // minutes
|
actors: 60, // minutes
|
||||||
stashes: 1,
|
stashes: 1,
|
||||||
@@ -192,4 +192,8 @@ module.exports = {
|
|||||||
s3Path: 'https://s3.wasabisys.com',
|
s3Path: 'https://s3.wasabisys.com',
|
||||||
videoRestrictions: [], // entity slugs, _ prefix for networks, hides trailer and teaser videos
|
videoRestrictions: [], // entity slugs, _ prefix for networks, hides trailer and teaser videos
|
||||||
},
|
},
|
||||||
|
sync: {
|
||||||
|
enabled: true,
|
||||||
|
crontab: '*/10 * * * *', // every 10 minutes
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "traxxx-web",
|
"name": "traxxx-web",
|
||||||
"version": "0.47.11",
|
"version": "0.51.4",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"version": "0.47.11",
|
"version": "0.51.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brillout/json-serializer": "^0.5.8",
|
"@brillout/json-serializer": "^0.5.8",
|
||||||
"@dicebear/collection": "^7.0.5",
|
"@dicebear/collection": "^7.0.5",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "$vite"
|
"vite": "$vite"
|
||||||
},
|
},
|
||||||
"version": "0.47.11",
|
"version": "0.51.4",
|
||||||
"imports": {
|
"imports": {
|
||||||
"#/*": "./*.js"
|
"#/*": "./*.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,6 @@
|
|||||||
/>
|
/>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<li
|
|
||||||
v-if="actor.aliases?.length"
|
|
||||||
class="bio-item"
|
|
||||||
>
|
|
||||||
<dfn class="bio-label">Also known as</dfn>
|
|
||||||
<span class="bio-value">
|
|
||||||
<span
|
|
||||||
v-for="alias in actor.aliases"
|
|
||||||
:key="`alias-${alias.id}`"
|
|
||||||
class="alias"
|
|
||||||
>{{ alias.name }}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<Heart
|
<Heart
|
||||||
domain="actors"
|
domain="actors"
|
||||||
:item="actor"
|
:item="actor"
|
||||||
@@ -108,7 +94,7 @@ const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits
|
|||||||
.actor-header {
|
.actor-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export default '/actor/edit/@actorId/*';
|
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
<template v-if="apply">Your revision has been submitted. Thank you for your contribution!</template>
|
<template v-if="apply">Your revision has been submitted. Thank you for your contribution!</template>
|
||||||
<template v-else>Your revision has been submitted for review. Thank you for your contribution!</template>
|
<template v-else>Your revision has been submitted for review. Thank you for your contribution!</template>
|
||||||
|
|
||||||
<ul>
|
<ul v-if="actor">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
:href="`/actor/${actor.id}/${actor.slug}`"
|
:href="`/actor/${actor.id}/${actor.slug}`"
|
||||||
class="link"
|
class="link"
|
||||||
>Return to actor</a>
|
>{{ creating ? 'Go' : 'Return' }} to actor</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
@@ -22,21 +22,21 @@
|
|||||||
>Make another edit</a>
|
>Make another edit</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li v-if="!creating">
|
||||||
<a
|
<a
|
||||||
:href="`/actor/revs/${actor.id}/${actor.slug}`"
|
:href="`/actor/revs/${actor.id}/${actor.slug}`"
|
||||||
class="link"
|
class="link"
|
||||||
>Go to actor revisions</a>
|
>Go to actor revisions</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li v-if="!creating">
|
||||||
<a
|
<a
|
||||||
:href="`/user/${user.username}/revisions/actors`"
|
:href="`/user/${user.username}/revisions/actors`"
|
||||||
class="link"
|
class="link"
|
||||||
>Go to user revisions</a>
|
>Go to user revisions</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li v-if="user.role !== 'user'">
|
<li v-if="user.role !== 'user' && !creating">
|
||||||
<a
|
<a
|
||||||
href="/admin/revisions/actors"
|
href="/admin/revisions/actors"
|
||||||
class="link"
|
class="link"
|
||||||
@@ -49,7 +49,10 @@
|
|||||||
v-else
|
v-else
|
||||||
@submit.prevent
|
@submit.prevent
|
||||||
>
|
>
|
||||||
<div class="editor-header">
|
<div
|
||||||
|
v-if="actor"
|
||||||
|
class="editor-header"
|
||||||
|
>
|
||||||
<h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2>
|
<h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -59,6 +62,13 @@
|
|||||||
>Go to actor</a>
|
>Go to actor</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="editor-header"
|
||||||
|
>
|
||||||
|
<h2 class="heading ellipsis">Add actor</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="nolist">
|
<ul class="nolist">
|
||||||
<li
|
<li
|
||||||
v-for="item in fields"
|
v-for="item in fields"
|
||||||
@@ -281,6 +291,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
v-if="item.hasDescription !== false"
|
||||||
v-model="edits[item.key].description"
|
v-model="edits[item.key].description"
|
||||||
class="description input"
|
class="description input"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
@@ -317,7 +328,7 @@
|
|||||||
|
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="user.role !== 'user'"
|
v-if="user.role !== 'user' && actor"
|
||||||
label="Approve and apply immediately"
|
label="Approve and apply immediately"
|
||||||
:checked="apply"
|
:checked="apply"
|
||||||
:disabled="editing.size === 0"
|
:disabled="editing.size === 0"
|
||||||
@@ -346,6 +357,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, inject } from 'vue';
|
import { ref, computed, inject } from 'vue';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import omit from 'object.omit';
|
||||||
|
|
||||||
import EditSocials from '#/components/edit/socials.vue';
|
import EditSocials from '#/components/edit/socials.vue';
|
||||||
import EditPlace from '#/components/edit/place.vue';
|
import EditPlace from '#/components/edit/place.vue';
|
||||||
@@ -364,10 +376,10 @@ import {
|
|||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
|
|
||||||
const user = pageContext.user;
|
const user = pageContext.user;
|
||||||
const actor = ref(pageContext.pageProps.actor);
|
const actor = ref(pageContext.pageProps.actor || null);
|
||||||
|
|
||||||
const fields = computed(() => [
|
const fields = computed(() => [
|
||||||
...(actor.value.photos.length > 0 ? [{
|
...(actor.value?.photos.length > 0 ? [{
|
||||||
key: 'avatar',
|
key: 'avatar',
|
||||||
type: 'avatar',
|
type: 'avatar',
|
||||||
value: actor.value.avatar?.id,
|
value: actor.value.avatar?.id,
|
||||||
@@ -377,13 +389,13 @@ const fields = computed(() => [
|
|||||||
? [{
|
? [{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
value: actor.value.name,
|
value: actor.value?.name,
|
||||||
}]
|
}]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
key: 'gender',
|
key: 'gender',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
value: actor.value.gender,
|
value: actor.value?.gender || null,
|
||||||
options: [null, 'female', 'male', 'transsexual', 'other'],
|
options: [null, 'female', 'male', 'transsexual', 'other'],
|
||||||
inline: true,
|
inline: true,
|
||||||
},
|
},
|
||||||
@@ -391,7 +403,7 @@ const fields = computed(() => [
|
|||||||
key: 'dateOfBirth',
|
key: 'dateOfBirth',
|
||||||
label: 'date of birth',
|
label: 'date of birth',
|
||||||
type: 'date',
|
type: 'date',
|
||||||
value: actor.value.dateOfBirth
|
value: actor.value?.dateOfBirth
|
||||||
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
||||||
: null,
|
: null,
|
||||||
inline: true,
|
inline: true,
|
||||||
@@ -399,28 +411,28 @@ const fields = computed(() => [
|
|||||||
{
|
{
|
||||||
key: 'socials',
|
key: 'socials',
|
||||||
type: 'socials',
|
type: 'socials',
|
||||||
value: actor.value.socials,
|
value: actor.value?.socials || [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'origin',
|
key: 'origin',
|
||||||
type: 'place',
|
type: 'place',
|
||||||
value: {
|
value: {
|
||||||
country: actor.value.origin?.country?.alpha2 || null,
|
country: actor.value?.origin?.country?.alpha2 || null,
|
||||||
place: [actor.value.origin?.city, actor.value.origin?.state].filter(Boolean).join(', '),
|
place: [actor.value?.origin?.city, actor.value?.origin?.state].filter(Boolean).join(', '),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'residence',
|
key: 'residence',
|
||||||
type: 'place',
|
type: 'place',
|
||||||
value: {
|
value: {
|
||||||
country: actor.value.residence?.country?.alpha2 || null,
|
country: actor.value?.residence?.country?.alpha2 || null,
|
||||||
place: [actor.value.residence?.city, actor.value.residence?.state].filter(Boolean).join(', '),
|
place: [actor.value?.residence?.city, actor.value?.residence?.state].filter(Boolean).join(', '),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ethnicity',
|
key: 'ethnicity',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
value: actor.value.ethnicity,
|
value: actor.value?.ethnicity || null,
|
||||||
suggestions: [
|
suggestions: [
|
||||||
'Asian',
|
'Asian',
|
||||||
'Black',
|
'Black',
|
||||||
@@ -433,20 +445,20 @@ const fields = computed(() => [
|
|||||||
key: 'size',
|
key: 'size',
|
||||||
type: 'size',
|
type: 'size',
|
||||||
value: {
|
value: {
|
||||||
metricHeight: actor.value.height?.metric,
|
metricHeight: actor.value?.height?.metric || null,
|
||||||
metricWeight: actor.value.weight?.metric,
|
metricWeight: actor.value?.weight?.metric || null,
|
||||||
imperialHeight: actor.value.height?.imperial || [],
|
imperialHeight: actor.value?.height?.imperial || [],
|
||||||
imperialWeight: actor.value.weight?.imperial,
|
imperialWeight: actor.value?.weight?.imperial || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'figure',
|
key: 'figure',
|
||||||
type: 'figure',
|
type: 'figure',
|
||||||
value: {
|
value: {
|
||||||
bust: actor.value.bust,
|
bust: actor.value?.bust || null,
|
||||||
cup: actor.value.cup,
|
cup: actor.value?.cup || null,
|
||||||
waist: actor.value.waist,
|
waist: actor.value?.waist || null,
|
||||||
hip: actor.value.hip,
|
hip: actor.value?.hip || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -454,25 +466,25 @@ const fields = computed(() => [
|
|||||||
type: 'augmentation',
|
type: 'augmentation',
|
||||||
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
|
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
|
||||||
value: {
|
value: {
|
||||||
naturalBoobs: actor.value.naturalBoobs,
|
naturalBoobs: actor.value?.naturalBoobs ?? null,
|
||||||
boobsVolume: actor.value.boobsVolume,
|
boobsVolume: actor.value?.boobsVolume || null,
|
||||||
boobsImplant: actor.value.boobsImplant,
|
boobsImplant: actor.value?.boobsImplant || null,
|
||||||
boobsPlacement: actor.value.boobsPlacement,
|
boobsPlacement: actor.value?.boobsPlacement || null,
|
||||||
boobsIncision: actor.value.boobsIncision,
|
boobsIncision: actor.value?.boobsIncision || null,
|
||||||
boobsSurgeon: actor.value.boobsSurgeon,
|
boobsSurgeon: actor.value?.boobsSurgeon || null,
|
||||||
naturalButt: actor.value.naturalButt,
|
naturalButt: actor.value?.naturalButt ?? null,
|
||||||
buttVolume: actor.value.buttVolume,
|
buttVolume: actor.value?.buttVolume || null,
|
||||||
buttImplant: actor.value.buttImplant,
|
buttImplant: actor.value?.buttImplant || null,
|
||||||
naturalLips: actor.value.naturalLips,
|
naturalLips: actor.value?.naturalLips ?? null,
|
||||||
lipsVolume: actor.value.lipsVolume,
|
lipsVolume: actor.value?.lipsVolume || null,
|
||||||
naturalLabia: actor.value.naturalLabia,
|
naturalLabia: actor.value?.naturalLabia ?? null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hairColor',
|
key: 'hairColor',
|
||||||
label: 'hair color',
|
label: 'hair color',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
value: actor.value.hairColor,
|
value: actor.value?.hairColor || null,
|
||||||
options: [
|
options: [
|
||||||
null,
|
null,
|
||||||
'black',
|
'black',
|
||||||
@@ -491,7 +503,7 @@ const fields = computed(() => [
|
|||||||
key: 'eyes',
|
key: 'eyes',
|
||||||
label: 'eye color',
|
label: 'eye color',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
value: actor.value.eyes,
|
value: actor.value?.eyes || null,
|
||||||
options: [
|
options: [
|
||||||
null,
|
null,
|
||||||
'blue',
|
'blue',
|
||||||
@@ -506,8 +518,8 @@ const fields = computed(() => [
|
|||||||
key: 'tattoos',
|
key: 'tattoos',
|
||||||
type: 'has',
|
type: 'has',
|
||||||
value: {
|
value: {
|
||||||
has: actor.value.hasTattoos,
|
has: actor.value?.hasTattoos ?? null,
|
||||||
description: actor.value.tattoos,
|
description: actor.value?.tattoos || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -515,14 +527,14 @@ const fields = computed(() => [
|
|||||||
type: 'has',
|
type: 'has',
|
||||||
note: 'Excludes earrings',
|
note: 'Excludes earrings',
|
||||||
value: {
|
value: {
|
||||||
has: actor.value.hasPiercings,
|
has: actor.value?.hasPiercings ?? null,
|
||||||
description: actor.value.piercings,
|
description: actor.value?.piercings || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'agency',
|
key: 'agency',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
value: actor.value.agency,
|
value: actor.value?.agency || null,
|
||||||
suggestions: [
|
suggestions: [
|
||||||
'101 Modeling',
|
'101 Modeling',
|
||||||
'Adult Talent Managers (ATMLA)',
|
'Adult Talent Managers (ATMLA)',
|
||||||
@@ -540,25 +552,41 @@ const fields = computed(() => [
|
|||||||
key: 'penis',
|
key: 'penis',
|
||||||
type: 'penis',
|
type: 'penis',
|
||||||
value: {
|
value: {
|
||||||
metricLength: actor.value.penisLength?.metric,
|
metricLength: actor.value?.penisLength?.metric || null,
|
||||||
metricGirth: actor.value.penisGirth?.metric,
|
metricGirth: actor.value?.penisGirth?.metric || null,
|
||||||
imperialLength: actor.value.penisLength?.imperial,
|
imperialLength: actor.value?.penisLength?.imperial || null,
|
||||||
imperialGirth: actor.value.penisGirth?.imperial,
|
imperialGirth: actor.value?.penisGirth?.imperial || null,
|
||||||
isCircumcised: actor.value.isCircumcised,
|
isCircumcised: actor.value?.isCircumcised || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dateOfDeath',
|
key: 'dateOfDeath',
|
||||||
label: 'date of death',
|
label: 'date of death',
|
||||||
type: 'date',
|
type: 'date',
|
||||||
value: actor.value.dateOfDeath
|
value: actor.value?.dateOfDeath
|
||||||
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
]);
|
{
|
||||||
|
key: 'allowGlobalMatch',
|
||||||
|
label: 'global match',
|
||||||
|
type: 'has',
|
||||||
|
note: 'Allow this actor to be assigned to scenes automatically, overriding single-name protections.',
|
||||||
|
hasDescription: false,
|
||||||
|
value: {
|
||||||
|
has: actor.value?.allowGlobalMatch ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter((field) => (actor.value ? true : ['name', 'gender'].includes(field.key)))
|
||||||
|
.map((field) => ({
|
||||||
|
...field,
|
||||||
|
forced: actor.value ? field.forced : true,
|
||||||
|
})));
|
||||||
|
|
||||||
const editing = ref(new Set());
|
const editing = ref(new Set(actor.value ? [] : ['name', 'gender']));
|
||||||
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value])));
|
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value])));
|
||||||
|
const creating = !actor.value;
|
||||||
const comment = ref(null);
|
const comment = ref(null);
|
||||||
const apply = ref(user.role !== 'user');
|
const apply = ref(user.role !== 'user');
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -600,6 +628,9 @@ const keyMap = {
|
|||||||
has: 'hasPiercings',
|
has: 'hasPiercings',
|
||||||
description: 'piercings',
|
description: 'piercings',
|
||||||
},
|
},
|
||||||
|
allowGlobalMatch: {
|
||||||
|
has: 'allowGlobalMatch',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupMap = {
|
const groupMap = {
|
||||||
@@ -615,7 +646,7 @@ async function submit() {
|
|||||||
|
|
||||||
await post('/revisions/actors', {
|
await post('/revisions/actors', {
|
||||||
actorId: actor.value.id,
|
actorId: actor.value.id,
|
||||||
edits: {
|
edits: omit({
|
||||||
...Object.fromEntries(Array.from(editing.value).flatMap((key) => {
|
...Object.fromEntries(Array.from(editing.value).flatMap((key) => {
|
||||||
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
|
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
|
||||||
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
|
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
|
||||||
@@ -629,15 +660,16 @@ async function submit() {
|
|||||||
penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength,
|
penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength,
|
||||||
penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth,
|
penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth,
|
||||||
}).filter(([key]) => editing.value.has(groupMap[key] || key))),
|
}).filter(([key]) => editing.value.has(groupMap[key] || key))),
|
||||||
metricHeight: undefined,
|
}, [
|
||||||
metricWeight: undefined,
|
'metricHeight',
|
||||||
imperialHeight: undefined,
|
'metricWeight',
|
||||||
imperialWeight: undefined,
|
'imperialHeight',
|
||||||
metricLength: undefined,
|
'imperialWeight',
|
||||||
metricGirth: undefined,
|
'metricLength',
|
||||||
imperialLength: undefined,
|
'metricGirth',
|
||||||
imperialGirth: undefined,
|
'imperialLength',
|
||||||
},
|
'imperialGirth',
|
||||||
|
]),
|
||||||
sizeUnits: sizeUnits.value,
|
sizeUnits: sizeUnits.value,
|
||||||
figureUnits: figureUnits.value,
|
figureUnits: figureUnits.value,
|
||||||
penisUnits: penisUnits.value,
|
penisUnits: penisUnits.value,
|
||||||
34
pages/actors/edit/+onBeforeRender.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||||
|
|
||||||
|
import { fetchActorsById } from '#/src/actors.js';
|
||||||
|
import { fetchCountries } from '#/src/countries.js';
|
||||||
|
|
||||||
|
export async function onBeforeRender(pageContext) {
|
||||||
|
if (!pageContext.user) {
|
||||||
|
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [actor] = pageContext.routeParams.actorId
|
||||||
|
? await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const countries = await fetchCountries();
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (!actor) {
|
||||||
|
throw render(404, `Cannot find actor '${pageContext.routeParams.actorId}'.`);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageContext: {
|
||||||
|
title: actor
|
||||||
|
? `Editing '${actor.name}'`
|
||||||
|
: 'Adding actor',
|
||||||
|
pageProps: {
|
||||||
|
actor,
|
||||||
|
countries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
20
pages/actors/edit/+route.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// export default '/actor/edit/@actorId/*';
|
||||||
|
// import { redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||||
|
import { match } from 'path-to-regexp';
|
||||||
|
|
||||||
|
const path = '/actor/edit/:actorId/:actorSlug?';
|
||||||
|
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||||
|
|
||||||
|
export default (pageContext) => {
|
||||||
|
const matched = urlMatch(pageContext.urlPathname);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return {
|
||||||
|
routeParams: matched.params.actorId ? {
|
||||||
|
actorId: matched.params.actorId,
|
||||||
|
} : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
<h2 class="heading">Admin Panel</h2>
|
<h2 class="heading">Admin Panel</h2>
|
||||||
|
|
||||||
<ul class="menu">
|
<ul class="menu">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/admin/actors"
|
||||||
|
class="link"
|
||||||
|
>Actors</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/admin/revisions/scenes"
|
href="/admin/revisions/scenes"
|
||||||
|
|||||||
337
pages/admin/actors/+Page.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<template>
|
||||||
|
<Admin class="page">
|
||||||
|
<div class="header">
|
||||||
|
<input
|
||||||
|
v-model="actorQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search actors"
|
||||||
|
class="input search"
|
||||||
|
@search="searchActors"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:disabled="selectedActors.size === 0"
|
||||||
|
@click="selectedActors = new Set()"
|
||||||
|
><Icon icon="cancel-square" />Deselect</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:disabled="selectedActors.size === 0"
|
||||||
|
@click="showMergeDialog = true"
|
||||||
|
><Icon icon="make-group" />Merge</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
@click="showAddDialog = true"
|
||||||
|
><Icon icon="plus3" />Add actor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="actors">
|
||||||
|
<thead class="actors-header">
|
||||||
|
<tr>
|
||||||
|
<th class="actor-id">ID</th>
|
||||||
|
<th class="actor-name">Entity</th>
|
||||||
|
<th class="actor-avatar">Avatar</th>
|
||||||
|
<th class="actor-name">Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="actors-body">
|
||||||
|
<tr
|
||||||
|
v-for="(actor, index) in actors"
|
||||||
|
:key="`actor-${actor.id}`"
|
||||||
|
class="actor"
|
||||||
|
>
|
||||||
|
<td class="actor-id ellipsis">
|
||||||
|
<a
|
||||||
|
:href="`/actor/${actor.id}/${actor.slug}`"
|
||||||
|
target="_blank"
|
||||||
|
class="nolink"
|
||||||
|
>{{ actor.id }}</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
v-tooltip="actor.entity?.name || 'Global'"
|
||||||
|
class="actor-entity ellipsis"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="actor.entity"
|
||||||
|
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
|
||||||
|
class="actor-favicon"
|
||||||
|
>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
icon="device_hub"
|
||||||
|
class="actor-global"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="actor-avatar">
|
||||||
|
<img
|
||||||
|
v-if="actor.avatar"
|
||||||
|
:src="getPath(actor.avatar, 'lazy')"
|
||||||
|
loading="lazy"
|
||||||
|
class="avatar"
|
||||||
|
>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="actor.avatar"
|
||||||
|
:src="getPath(actor.avatar)"
|
||||||
|
loading="lazy"
|
||||||
|
class="avatar-zoom"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="actor-name ellipsis">{{ actor.name }}</td>
|
||||||
|
|
||||||
|
<td class="actor-actions">
|
||||||
|
<div class="actions">
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedActors.has(actor.id)"
|
||||||
|
@change="(isChecked) => selectActors(actor, isChecked, index)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a
|
||||||
|
:href="`/actor/edit/${actor.id}/${actor.slug}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-tooltip="'Edit bio'"
|
||||||
|
icon="pencil5"
|
||||||
|
class="actor-action action-merge"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-tooltip="'Merge'"
|
||||||
|
icon="make-group"
|
||||||
|
class="actor-action action-merge"
|
||||||
|
@click="activeActor = actor; showMergeDialog = true;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<Icon
|
||||||
|
v-tooltip="'Delete'"
|
||||||
|
icon="bin"
|
||||||
|
class="actor-action action-delete"
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<MergeActors
|
||||||
|
v-if="showMergeDialog"
|
||||||
|
:actors="activeActor ? [activeActor] : actors.filter((actor) => selectedActors.has(actor.id))"
|
||||||
|
@close="showMergeDialog = false; activeActor = null;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddActor
|
||||||
|
v-if="showAddDialog"
|
||||||
|
@close="showAddDialog = false"
|
||||||
|
/>
|
||||||
|
</Admin>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import Admin from '#/components/admin/admin.vue';
|
||||||
|
import Checkbox from '#/components/form/checkbox.vue';
|
||||||
|
import MergeActors from '#/components/actors/merge.vue';
|
||||||
|
import AddActor from '#/components/actors/add.vue';
|
||||||
|
|
||||||
|
import getPath from '#/src/get-path.js';
|
||||||
|
import navigate from '#/src/navigate.js';
|
||||||
|
// import { get } from '#/src/api.js';
|
||||||
|
|
||||||
|
const { pageProps, urlParsed } = inject('pageContext');
|
||||||
|
|
||||||
|
const actors = ref(pageProps.actors);
|
||||||
|
const selectedActors = ref(new Set([]));
|
||||||
|
const activeActor = ref(null);
|
||||||
|
const actorQuery = ref(urlParsed.search.q || null);
|
||||||
|
|
||||||
|
const lastSelectedIndex = ref(null);
|
||||||
|
const holdingShift = ref(false);
|
||||||
|
const showMergeDialog = ref(false);
|
||||||
|
const showAddDialog = ref(false);
|
||||||
|
|
||||||
|
function selectActors(selectedActor, isChecked, index) {
|
||||||
|
const [start, end] = holdingShift.value
|
||||||
|
? [index, lastSelectedIndex.value].toSorted((indexA, indexB) => indexA - indexB)
|
||||||
|
: [index, index];
|
||||||
|
|
||||||
|
const actorIds = actors.value
|
||||||
|
.slice(start, end + 1)
|
||||||
|
.map((actor) => actor.id);
|
||||||
|
|
||||||
|
actorIds.forEach((actorId) => {
|
||||||
|
if (isChecked) {
|
||||||
|
selectedActors.value.add(actorId);
|
||||||
|
} else {
|
||||||
|
selectedActors.value.delete(actorId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSelectedIndex.value = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchActors() {
|
||||||
|
navigate('/admin/actors', { q: actorQuery.value || undefined }, { redirect: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
holdingShift.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keyup', (event) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
holdingShift.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
holdingShift.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search {
|
||||||
|
max-width: 20rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actors-header tr {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actors, .actors th, .actors td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
padding: .5rem;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor {
|
||||||
|
&:nth-child(2n) {
|
||||||
|
background: var(--glass-weak-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--glass-weak-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-id {
|
||||||
|
width: 6rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-entity {
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-favicon {
|
||||||
|
width: 1rem;
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-global {
|
||||||
|
background: var(--primary);
|
||||||
|
fill: var(--text-light);
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.actor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
.button:first-child {
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-actions {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-action {
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
fill: var(--glass);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
fill: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover.action-delete {
|
||||||
|
fill: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-avatar {
|
||||||
|
width: 5rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
&:hover .avatar-zoom,
|
||||||
|
&:active .avatar-zoom {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
height: 3.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-zoom {
|
||||||
|
display: none;
|
||||||
|
height: 50vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-container {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
pages/admin/actors/+onBeforeRender.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||||
|
|
||||||
|
import { fetchActors } from '#/src/actors.js';
|
||||||
|
import verifyAbility from '#/utils/verify-ability.js';
|
||||||
|
|
||||||
|
export default async function onBeforeRender(pageContext) {
|
||||||
|
if (!pageContext.user || !verifyAbility(pageContext.user, 'actor', 'merge')) {
|
||||||
|
throw render(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actors } = await fetchActors({
|
||||||
|
query: pageContext.urlParsed.search.q,
|
||||||
|
}, {
|
||||||
|
limit: 100,
|
||||||
|
order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'],
|
||||||
|
}, pageContext.user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageContext: {
|
||||||
|
title: 'Actors',
|
||||||
|
pageProps: {
|
||||||
|
actors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||||
|
|
||||||
import { fetchActorRevisions } from '#/src/actors.js';
|
import { fetchActorRevisions } from '#/src/actors.js';
|
||||||
|
import verifyAbility from '#/utils/verify-ability.js';
|
||||||
|
|
||||||
export async function onBeforeRender(pageContext) {
|
export async function onBeforeRender(pageContext) {
|
||||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
if (!pageContext.user || !verifyAbility(pageContext.user, 'actor', 'update')) {
|
||||||
throw render(404);
|
throw render(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||||
|
|
||||||
import { fetchSceneRevisions } from '#/src/scenes.js';
|
import { fetchSceneRevisions } from '#/src/scenes.js';
|
||||||
|
import verifyAbility from '#/utils/verify-ability.js';
|
||||||
|
|
||||||
export async function onBeforeRender(pageContext) {
|
export async function onBeforeRender(pageContext) {
|
||||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
if (!pageContext.user || !verifyAbility(pageContext.user, 'scene', 'update')) {
|
||||||
throw render(404);
|
throw render(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="user?.abilities?.some((ability) => ability.plainUrls)"
|
v-if="user?.abilities?.some((ability) => ability.subject === 'plainUrls')"
|
||||||
:href="entity.url"
|
:href="entity.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
|
|||||||
@@ -57,19 +57,23 @@ export async function onBeforeRender(pageContext) {
|
|||||||
fetchReleases(pageContext, entityId),
|
fetchReleases(pageContext, entityId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const entityIds = entity.isIndependent || !entity.parent
|
||||||
|
? [entity.id]
|
||||||
|
: [entity.id, entity.parent.id];
|
||||||
|
|
||||||
const campaigns = await getRandomCampaigns([
|
const campaigns = await getRandomCampaigns([
|
||||||
{
|
{
|
||||||
entityIds: [entity.id, entity.parent?.id].filter(Boolean),
|
entityIds,
|
||||||
minRatio: 3,
|
minRatio: 3,
|
||||||
allowRandomFallback: false,
|
allowRandomFallback: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entityIds: [entity.id, entity.parent?.id].filter(Boolean),
|
entityIds,
|
||||||
minRatio: 3,
|
minRatio: 3,
|
||||||
allowRandomFallback: false,
|
allowRandomFallback: false,
|
||||||
},
|
},
|
||||||
pageContext.routeParams.domain === 'scenes' ? {
|
pageContext.routeParams.domain === 'scenes' ? {
|
||||||
entityIds: [entity.id, entity.parent?.id].filter(Boolean),
|
entityIds,
|
||||||
minRatio: 0.75,
|
minRatio: 0.75,
|
||||||
maxRatio: 1.25,
|
maxRatio: 1.25,
|
||||||
allowRandomFallback: false,
|
allowRandomFallback: false,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
:href="user?.abilities?.some((ability) => ability.plainUrls) ? scene.url : scene.watchUrl"
|
:href="user?.abilities?.some((ability) => ability.subject === 'plainUrls') ? scene.url : scene.watchUrl"
|
||||||
:title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`"
|
:title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="date nolink"
|
class="date nolink"
|
||||||
@@ -140,30 +140,41 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tags">
|
<ul class="tags nolist">
|
||||||
<div
|
<li
|
||||||
v-for="actorTags in tags"
|
v-for="tag in tags"
|
||||||
:key="`tags-${actorTags.actor?.slug || 'scene'}`"
|
:key="`tag-${tag.id}`"
|
||||||
class="tags-section"
|
class="tag"
|
||||||
|
:class="{ 'has-actors': tag.actors.length > 0 }"
|
||||||
>
|
>
|
||||||
<ul class="nolist">
|
<Link
|
||||||
<li
|
:href="`/tag/${tag.slug}`"
|
||||||
v-if="actorTags.actor"
|
class="tag-name nolink"
|
||||||
class="tags-actor"
|
>{{ tag.name }}</Link>
|
||||||
>{{ actorTags.actor.name }}:</li>
|
|
||||||
|
|
||||||
<li
|
<span
|
||||||
v-for="tag in actorTags.tags"
|
v-for="tagActor in tag.actors"
|
||||||
:key="`tag-${tag.id}`"
|
:key="`tagactor-${tagActor.id}`"
|
||||||
|
v-tooltip="{
|
||||||
|
content: `Performed by ${tagActor.name}`,
|
||||||
|
triggers: ['hover', 'click'],
|
||||||
|
}"
|
||||||
|
class="tag-frame"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="tagActor.avatar"
|
||||||
|
class="tag-avatar"
|
||||||
|
:src="getPath(tagActor.avatar, 'thumbnail')"
|
||||||
>
|
>
|
||||||
<Link
|
|
||||||
:href="`/tag/${tag.slug}`"
|
<Icon
|
||||||
class="tag nolink"
|
v-else
|
||||||
>{{ tag.name }}</Link>
|
icon="star-full"
|
||||||
</li>
|
class="tag-star"
|
||||||
</ul>
|
/>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="scene.movies.length > 0 || scene.series.length > 0"
|
v-if="scene.movies.length > 0 || scene.series.length > 0"
|
||||||
@@ -424,6 +435,7 @@ import Cookies from 'js-cookie';
|
|||||||
import { formatDate, formatDuration } from '#/utils/format.js';
|
import { formatDate, formatDuration } from '#/utils/format.js';
|
||||||
import events from '#/src/events.js';
|
import events from '#/src/events.js';
|
||||||
import processSummaryTemplate from '#/utils/process-summary-template.js';
|
import processSummaryTemplate from '#/utils/process-summary-template.js';
|
||||||
|
import getPath from '#/src/get-path.js';
|
||||||
|
|
||||||
import Banner from '#/components/media/banner.vue';
|
import Banner from '#/components/media/banner.vue';
|
||||||
import ActorTile from '#/components/actors/tile.vue';
|
import ActorTile from '#/components/actors/tile.vue';
|
||||||
@@ -450,16 +462,44 @@ const {
|
|||||||
|
|
||||||
const { scene } = pageProps;
|
const { scene } = pageProps;
|
||||||
|
|
||||||
const tags = [
|
/*
|
||||||
{
|
const tags = scene.tags.map((tag) => ({
|
||||||
tags: scene.tags.filter((tag) => tag.actorId === null),
|
...tag,
|
||||||
actor: null,
|
actor: scene.actors.find((actor) => actor.id === tag.actorId) || null,
|
||||||
},
|
}));
|
||||||
...scene.actors.map((actor) => ({
|
*/
|
||||||
actor,
|
|
||||||
tags: scene.tags.filter((tag) => tag.actorId === actor.id),
|
const actorsById = Object.fromEntries(scene.actors.map((actor) => [actor.id, actor]));
|
||||||
})),
|
|
||||||
].filter((actorTags) => actorTags.tags.length > 0);
|
const tags = Array.from(scene.tags
|
||||||
|
.reduce((acc, tag) => {
|
||||||
|
const accTag = acc.get(tag.id);
|
||||||
|
|
||||||
|
if (accTag && tag.actorId) {
|
||||||
|
return acc.set(tag.id, {
|
||||||
|
...tag,
|
||||||
|
actors: [...accTag.actors, actorsById[tag.actorId]].toSorted((actorA, actorB) => actorA.name.localeCompare(actorB.name)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accTag) {
|
||||||
|
// shouldn't happen, but account for it
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag.actorId) {
|
||||||
|
return acc.set(tag.id, {
|
||||||
|
...tag,
|
||||||
|
actors: [actorsById[tag.actorId]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc.set(tag.id, {
|
||||||
|
...tag,
|
||||||
|
actors: [],
|
||||||
|
});
|
||||||
|
}, new Map())
|
||||||
|
.values());
|
||||||
|
|
||||||
const showSummaryDialog = ref(false);
|
const showSummaryDialog = ref(false);
|
||||||
|
|
||||||
@@ -664,7 +704,7 @@ function copySummary() {
|
|||||||
.tags {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: .25rem 1rem;
|
gap: .35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-section {
|
.tags-section {
|
||||||
@@ -684,25 +724,60 @@ function copySummary() {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
.actor {
|
.actor {
|
||||||
width: 10rem;
|
width: 9rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
padding: .5rem;
|
display: flex;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
margin: 0 .25rem .25rem 0;
|
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-frame {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
box-shadow: inset 0 0 3px var(--shadow-weak-20);
|
||||||
|
pointer-events: none; /* so it doesn't block hover/click on the image */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-avatar {
|
||||||
|
height: 350%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-star {
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.movies,
|
.movies,
|
||||||
.series {
|
.series {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export async function onBeforeRender(pageContext) {
|
|||||||
restriction: pageContext.restriction,
|
restriction: pageContext.restriction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!scene) {
|
||||||
|
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
const [campaigns, tagIds] = await Promise.all([
|
const [campaigns, tagIds] = await Promise.all([
|
||||||
getRandomCampaigns([
|
getRandomCampaigns([
|
||||||
{
|
{
|
||||||
@@ -44,10 +48,6 @@ export async function onBeforeRender(pageContext) {
|
|||||||
], 'tags', true),
|
], 'tags', true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!scene) {
|
|
||||||
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageContext: {
|
pageContext: {
|
||||||
title: getTitle(scene),
|
title: getTitle(scene),
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
v-if="item.note"
|
v-if="item.note"
|
||||||
v-tooltip="item.note"
|
v-tooltip="item.note"
|
||||||
icon="info2"
|
icon="info2"
|
||||||
class="item-note"
|
class="item-note noselect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
<Icon
|
<Icon
|
||||||
v-if="!item.forced"
|
v-if="!item.forced"
|
||||||
icon="pencil5"
|
icon="pencil5"
|
||||||
|
class="noselect"
|
||||||
:class="{ active: editing.has(item.key) }"
|
:class="{ active: editing.has(item.key) }"
|
||||||
@click="toggleField(item)"
|
@click="toggleField(item)"
|
||||||
/>
|
/>
|
||||||
@@ -134,6 +135,15 @@
|
|||||||
:disabled="!editing.has(item.key)"
|
:disabled="!editing.has(item.key)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
v-if="item.type === 'checkbox'"
|
||||||
|
:label="item.checkboxLabel"
|
||||||
|
:checked="edits[item.key]"
|
||||||
|
:disabled="!editing.has(item.key)"
|
||||||
|
class="checkbox delete"
|
||||||
|
@change="(checked) => setDelete(checked)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="item.type === 'date'"
|
v-if="item.type === 'date'"
|
||||||
class="date"
|
class="date"
|
||||||
@@ -210,9 +220,10 @@
|
|||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="user.role !== 'user'"
|
v-if="user.role !== 'user'"
|
||||||
|
v-tooltip="isApplyDisabled && editing.has('delete') ? 'Delete must be approved by an admin' : null"
|
||||||
label="Approve and apply immediately"
|
label="Approve and apply immediately"
|
||||||
:checked="apply"
|
:checked="apply"
|
||||||
:disabled="editing.size === 0"
|
:disabled="isApplyDisabled"
|
||||||
@change="(checked) => apply = checked"
|
@change="(checked) => apply = checked"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -241,10 +252,7 @@ import EditTags from '#/components/edit/tags.vue';
|
|||||||
import EditMovies from '#/components/edit/movies.vue';
|
import EditMovies from '#/components/edit/movies.vue';
|
||||||
import Checkbox from '#/components/form/checkbox.vue';
|
import Checkbox from '#/components/form/checkbox.vue';
|
||||||
|
|
||||||
import {
|
import { post } from '#/src/api.js';
|
||||||
// get,
|
|
||||||
post,
|
|
||||||
} from '#/src/api.js';
|
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
|
|
||||||
@@ -310,12 +318,20 @@ const fields = computed(() => [
|
|||||||
},
|
},
|
||||||
...(user.role === 'user'
|
...(user.role === 'user'
|
||||||
? []
|
? []
|
||||||
: [{
|
: [
|
||||||
key: 'comment',
|
{
|
||||||
type: 'text',
|
key: 'comment',
|
||||||
placeholder: 'Do NOT use this field to summarize and clarify your revision. This field is for permanent notes and comments regarding the scene or database entry itself.',
|
type: 'text',
|
||||||
value: scene.value.comment,
|
placeholder: 'Do NOT use this field to summarize and clarify your revision. This field is for permanent notes and comments regarding the scene or database entry itself.',
|
||||||
}]),
|
value: scene.value.comment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
type: 'checkbox',
|
||||||
|
checkboxLabel: 'Remove this scene from the database',
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function simplifyArray(field) {
|
function simplifyArray(field) {
|
||||||
@@ -332,6 +348,9 @@ const comment = ref(null);
|
|||||||
const apply = ref(user.role !== 'user');
|
const apply = ref(user.role !== 'user');
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
|
|
||||||
|
const userCanDelete = user.abilities.some((ability) => ability.subject === 'scene' && ability.action === 'delete');
|
||||||
|
const isApplyDisabled = computed(() => editing.value.size === 0 || (edits.value.delete && !userCanDelete));
|
||||||
|
|
||||||
const keyMap = {
|
const keyMap = {
|
||||||
date: {
|
date: {
|
||||||
date: 'date',
|
date: 'date',
|
||||||
@@ -359,6 +378,14 @@ function setDuration(unit, event) {
|
|||||||
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value);
|
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDelete(checked) {
|
||||||
|
edits.value.delete = checked;
|
||||||
|
|
||||||
|
if (!userCanDelete) {
|
||||||
|
apply.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
try {
|
try {
|
||||||
await post('/revisions/scenes', {
|
await post('/revisions/scenes', {
|
||||||
@@ -417,6 +444,10 @@ async function submit() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .25rem 1rem;
|
padding: .25rem 1rem;
|
||||||
|
|
||||||
|
.value.disabled {
|
||||||
|
color: var(--glass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.key {
|
.key {
|
||||||
@@ -488,7 +519,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-note{
|
.item-note {
|
||||||
fill: var(--glass);
|
fill: var(--glass);
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
@@ -518,6 +549,25 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox.delete {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.disabled .delete {
|
||||||
|
:deep(.check-checkbox) + .check {
|
||||||
|
background: var(--glass-weak-30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value:not(.disabled) .delete {
|
||||||
|
:deep(.check-checkbox:checked) + .check {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -540,6 +590,27 @@ async function submit() {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delete-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: .5rem;
|
||||||
|
max-width: 25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media(--small) {
|
@media(--small) {
|
||||||
.row {
|
.row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2 class="title">{{ tag.name }}</h2>
|
<h2
|
||||||
|
:title="`${tag.name} (#${tag.id})`"
|
||||||
|
class="title"
|
||||||
|
>{{ tag.name }}</h2>
|
||||||
|
|
||||||
<Heart
|
<Heart
|
||||||
domain="tags"
|
domain="tags"
|
||||||
|
|||||||
BIN
public/img/rta.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
@@ -70,6 +70,9 @@ async function onRenderHtml(pageContext) {
|
|||||||
|
|
||||||
<meta name="description" content="Keep track of new porn releases and re-discover classics from your favorite porn stars and sites" />
|
<meta name="description" content="Keep track of new porn releases and re-discover classics from your favorite porn stars and sites" />
|
||||||
|
|
||||||
|
<!-- RTA restricted to adults label -->
|
||||||
|
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
|
||||||
|
|
||||||
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" data-exclude-hash="true" async></script>`) : ''}
|
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" data-exclude-hash="true" async></script>`) : ''}
|
||||||
|
|
||||||
<title>${title}</title>
|
<title>${title}</title>
|
||||||
|
|||||||
245
src/actors.js
@@ -3,6 +3,7 @@ import { differenceInYears } from 'date-fns';
|
|||||||
import { unit } from 'mathjs';
|
import { unit } from 'mathjs';
|
||||||
import { MerkleJson } from 'merkle-json';
|
import { MerkleJson } from 'merkle-json';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import omit from 'object.omit';
|
import omit from 'object.omit';
|
||||||
import convert from 'convert';
|
import convert from 'convert';
|
||||||
import unprint from 'unprint';
|
import unprint from 'unprint';
|
||||||
@@ -21,6 +22,8 @@ import slugify from '../utils/slugify.js';
|
|||||||
import { curateRevision } from './revisions.js';
|
import { curateRevision } from './revisions.js';
|
||||||
import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs'; // eslint-disable-line import/namespace
|
import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs'; // eslint-disable-line import/namespace
|
||||||
import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace
|
import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace
|
||||||
|
import { syncScenes, syncActors, syncStashes } from './sync.js';
|
||||||
|
import verifyAbility from '../utils/verify-ability.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
const logger = initLogger();
|
||||||
const mj = new MerkleJson();
|
const mj = new MerkleJson();
|
||||||
@@ -53,15 +56,28 @@ const keyMap = {
|
|||||||
hasTattoos: 'has_tattoos',
|
hasTattoos: 'has_tattoos',
|
||||||
hasPiercings: 'has_piercings',
|
hasPiercings: 'has_piercings',
|
||||||
isCircumcised: 'circumcised',
|
isCircumcised: 'circumcised',
|
||||||
|
allowGlobalMatch: 'allow_global_match',
|
||||||
};
|
};
|
||||||
|
|
||||||
const socialsOrder = ['onlyfans', 'twitter', 'fansly', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
|
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
|
||||||
|
|
||||||
export function curateActor(actor, context = {}) {
|
export function curateActor(actor, context = {}) {
|
||||||
|
if (!actor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: actor.id,
|
id: actor.id,
|
||||||
slug: actor.slug,
|
slug: actor.slug,
|
||||||
name: actor.name,
|
name: actor.name,
|
||||||
|
aliases: actor.aliases || [], // used for profile pages
|
||||||
|
alias: actor.alias
|
||||||
|
? {
|
||||||
|
id: actor.alias.id,
|
||||||
|
slug: actor.alias.slug,
|
||||||
|
name: actor.alias.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
gender: actor.gender,
|
gender: actor.gender,
|
||||||
age: actor.age,
|
age: actor.age,
|
||||||
ethnicity: actor.ethnicity,
|
ethnicity: actor.ethnicity,
|
||||||
@@ -80,6 +96,8 @@ export function curateActor(actor, context = {}) {
|
|||||||
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
||||||
? differenceInYears(context.sceneDate, actor.date_of_birth)
|
? differenceInYears(context.sceneDate, actor.date_of_birth)
|
||||||
: null,
|
: null,
|
||||||
|
dateOfBirth: actor.date_of_birth,
|
||||||
|
dateOfDeath: actor.date_of_death,
|
||||||
bust: actor.bust,
|
bust: actor.bust,
|
||||||
cup: actor.cup,
|
cup: actor.cup,
|
||||||
waist: actor.waist,
|
waist: actor.waist,
|
||||||
@@ -126,6 +144,7 @@ export function curateActor(actor, context = {}) {
|
|||||||
...actor.avatar,
|
...actor.avatar,
|
||||||
sfw_media: actor.sfw_avatar,
|
sfw_media: actor.sfw_avatar,
|
||||||
}),
|
}),
|
||||||
|
allowGlobalMatch: actor.allow_global_match,
|
||||||
socials: context.socials?.map((social) => ({
|
socials: context.socials?.map((social) => ({
|
||||||
id: social.id,
|
id: social.id,
|
||||||
url: social.url,
|
url: social.url,
|
||||||
@@ -226,8 +245,10 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
|||||||
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
|
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
|
||||||
knex.raw('row_to_json(entities) as entity'),
|
knex.raw('row_to_json(entities) as entity'),
|
||||||
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
||||||
|
knex.raw('json_agg(aliases) filter (where aliases.id is not null) as aliases'),
|
||||||
)
|
)
|
||||||
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
|
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
|
||||||
|
.leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id')
|
||||||
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
|
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
|
||||||
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
|
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
|
||||||
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
||||||
@@ -253,14 +274,17 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
|||||||
knex('actors_avatars')
|
knex('actors_avatars')
|
||||||
.select(
|
.select(
|
||||||
'media.*',
|
'media.*',
|
||||||
'actors_avatars.actor_id',
|
// 'actors_avatars.actor_id',
|
||||||
|
'actors_profiles.actor_id',
|
||||||
knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'),
|
knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'),
|
||||||
knex.raw('row_to_json(sfw_media) as sfw_media'),
|
knex.raw('row_to_json(sfw_media) as sfw_media'),
|
||||||
)
|
)
|
||||||
.whereIn('actor_id', actorIds)
|
.whereIn('actors_profiles.actor_id', actorIds)
|
||||||
.leftJoin('media', 'media.id', 'actors_avatars.media_id')
|
.leftJoin('media', 'media.id', 'actors_avatars.media_id')
|
||||||
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
|
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
|
||||||
.groupBy('media.id', 'sfw_media.id', 'actors_avatars.actor_id')
|
.leftJoin('actors_profiles', 'actors_profiles.id', 'actors_avatars.profile_id')
|
||||||
|
// .groupBy('media.id', 'sfw_media.id', 'actors_avatars.actor_id')
|
||||||
|
.groupBy('media.id', 'sfw_media.id', 'actors_profiles.actor_id')
|
||||||
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
||||||
knex('actors_socials')
|
knex('actors_socials')
|
||||||
.whereIn('actor_id', actorIds),
|
.whereIn('actor_id', actorIds),
|
||||||
@@ -359,17 +383,23 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
.innerJoin('actors', 'actors.id', 'actors_stashed.actor_id')
|
.innerJoin('actors', 'actors.id', 'actors_stashed.actor_id')
|
||||||
.where('stash_id', filters.stashId);
|
.where('stash_id', filters.stashId);
|
||||||
} else {
|
} else {
|
||||||
builder.select(knex.raw('*, weight() as _score'));
|
builder
|
||||||
|
.select(knex.raw('*, weight() as _score'))
|
||||||
|
.where('alias_for', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.query) {
|
if (filters.query) {
|
||||||
if (filters.query.charAt(0) === '#') {
|
if (filters.query.charAt(0) === '#') {
|
||||||
builder.where('id', Number(escape(filters.query.slice(1))));
|
builder.where('id', Number(escape(filters.query.slice(1))));
|
||||||
} else {
|
} else {
|
||||||
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
|
builder.whereRaw(`match('@(name,aliases) :query:${filters.query.charAt(0) === '=' ? '' : '*'}', actors)`, { query: escape(filters.query) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.isGlobal) {
|
||||||
|
builder.where('entity_id', 0);
|
||||||
|
}
|
||||||
|
|
||||||
// attribute filters
|
// attribute filters
|
||||||
['country'].forEach((attribute) => {
|
['country'].forEach((attribute) => {
|
||||||
if (filters[attribute]) {
|
if (filters[attribute]) {
|
||||||
@@ -432,7 +462,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.order?.[0] === 'name') {
|
if (options.order?.[0] === 'name') {
|
||||||
builder.orderBy('actors.slug', options.order[1]);
|
builder.orderBy([
|
||||||
|
{ column: 'actors.slug', order: options.order[1] },
|
||||||
|
{ column: 'actors.entity_id', order: 'asc' },
|
||||||
|
]);
|
||||||
} else if (options.order?.[0] === 'likes') {
|
} else if (options.order?.[0] === 'likes') {
|
||||||
builder.orderBy([
|
builder.orderBy([
|
||||||
{ column: 'actors.stashed', order: options.order[1] },
|
{ column: 'actors.stashed', order: options.order[1] },
|
||||||
@@ -461,6 +494,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
builder.orderBy('actors.slug', 'asc');
|
builder.orderBy('actors.slug', 'asc');
|
||||||
|
builder.orderBy([
|
||||||
|
{ column: 'actors.slug', order: 'asc' },
|
||||||
|
{ column: 'actors.entity_id', order: 'asc' },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.limit(options.limit)
|
.limit(options.limit)
|
||||||
@@ -519,6 +556,196 @@ export async function fetchActors(filters, rawOptions, reqUser) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function curateActorEntry(actor, context) {
|
||||||
|
return {
|
||||||
|
name: actor.name,
|
||||||
|
slug: slugify(actor.name),
|
||||||
|
entry_id: nanoid(), // allows for manual creation of multiple actors with the same name
|
||||||
|
gender: actor.gender,
|
||||||
|
allow_global_match: actor.allowGlobalMatch,
|
||||||
|
comment: context?.comment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActor(newActor, context, reqUser) {
|
||||||
|
if (!reqUser || reqUser.role === 'user') {
|
||||||
|
throw new HttpError('You are not permitted to create actors', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const curatedActorEntry = curateActorEntry(newActor, context);
|
||||||
|
const [actorEntry] = await knex('actors').insert(curatedActorEntry).returning('*');
|
||||||
|
|
||||||
|
await syncActors([actorEntry.id]);
|
||||||
|
|
||||||
|
return curateActor(actorEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
|
||||||
|
if (!verifyAbility(reqUser, 'actor', 'merge')) {
|
||||||
|
throw new HttpError('You are not permitted to merge actors', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceActorIds.includes(targetActorId)) {
|
||||||
|
throw new HttpError('Cannot merge actor profile into itself', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [targetActor, sourceActors] = await Promise.all([
|
||||||
|
knex('actors')
|
||||||
|
.where('id', targetActorId)
|
||||||
|
.whereNull('entity_id')
|
||||||
|
.whereNull('alias_for')
|
||||||
|
.first(),
|
||||||
|
knex('actors')
|
||||||
|
.whereIn('id', sourceActorIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!targetActor) {
|
||||||
|
throw new HttpError('Target actor not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceActors.length < sourceActorIds.length) {
|
||||||
|
throw new HttpError('Source actor not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trx = await knex.transaction();
|
||||||
|
|
||||||
|
let mergedProfiles = [];
|
||||||
|
let mergedSceneActors = [];
|
||||||
|
let existingSceneActors = [];
|
||||||
|
let duplicateSourceActors = [];
|
||||||
|
let mergedActorStashes = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [existingProfiles, sourceProfiles] = await Promise.all([
|
||||||
|
trx('actors_profiles')
|
||||||
|
.where('actor_id', targetActorId),
|
||||||
|
trx('actors_profiles')
|
||||||
|
.whereIn('actor_id', sourceActorIds),
|
||||||
|
trx('actors')
|
||||||
|
.update('alias_for', targetActorId)
|
||||||
|
.whereIn('id', sourceActorIds)
|
||||||
|
.returning(['id', 'alias_for']),
|
||||||
|
// some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field
|
||||||
|
trx('actors_avatars')
|
||||||
|
.update('actor_id', targetActorId)
|
||||||
|
.whereIn('actor_id', sourceActorIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// multiple source actors may provide profiles for the same entity, but we can only assign one to the target actor; prefer the newest
|
||||||
|
const newestSourceProfileMap = Object.fromEntries(sourceProfiles
|
||||||
|
.toSorted((profileA, profileB) => profileA.updated_at - profileB.updated_at)
|
||||||
|
.map((profile) => [profile.entity_id, profile.id]));
|
||||||
|
|
||||||
|
const duplicateSourceProfiles = sourceProfiles.filter((profile) => newestSourceProfileMap[profile.entity_id] && newestSourceProfileMap[profile.entity_id] !== profile.id);
|
||||||
|
|
||||||
|
// assign source actor profiles to target actor, unless a profile for that entity is already present
|
||||||
|
mergedProfiles = await trx('actors_profiles')
|
||||||
|
.update('actor_id', targetActorId)
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id))
|
||||||
|
.whereNotIn('id', duplicateSourceProfiles.map((profile) => profile.id))
|
||||||
|
.returning('id');
|
||||||
|
|
||||||
|
// find releases that have more than one source actor assigned
|
||||||
|
duplicateSourceActors = await trx('releases_actors')
|
||||||
|
.select('release_id', knex.raw('array_agg(actor_id) as actor_ids'))
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.groupBy('release_id')
|
||||||
|
.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));
|
||||||
|
|
||||||
|
if (duplicateSourceActors.length > 0) {
|
||||||
|
// some scenes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
|
||||||
|
await trx('releases_actors')
|
||||||
|
.whereIn('release_id', duplicateSourceActors.map((sceneActor) => sceneActor.release_id))
|
||||||
|
.whereIn('actor_id', duplicateSourceActors.flatMap((sceneActor) => sceneActor.actor_ids.slice(1)))
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// find scenes that already have target actor assigned
|
||||||
|
existingSceneActors = await trx('releases_actors')
|
||||||
|
.where('actor_id', targetActorId);
|
||||||
|
|
||||||
|
// delete release source actors for scenes that already have the target actor assigned
|
||||||
|
await trx('releases_actors')
|
||||||
|
.whereIn('release_id', existingSceneActors.map((sceneActor) => sceneActor.release_id))
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// alias release source actors to target actors
|
||||||
|
mergedSceneActors = await trx('releases_actors')
|
||||||
|
.update({
|
||||||
|
actor_id: targetActorId,
|
||||||
|
alias_id: knex.raw('actor_id'),
|
||||||
|
})
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.returning('release_id');
|
||||||
|
|
||||||
|
const [targetActorStashes, sourceActorStashes] = await Promise.all([
|
||||||
|
trx('stashes_actors')
|
||||||
|
.where('actor_id', targetActorId),
|
||||||
|
trx('stashes_actors')
|
||||||
|
.whereIn('actor_id', sourceActorIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// remove source actors from stashes that already contain target actor
|
||||||
|
await trx('stashes_actors')
|
||||||
|
.whereIn('stash_id', targetActorStashes.map((stash) => stash.stash_id))
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// find stashes that have more than one source actor assigned
|
||||||
|
const duplicateStashActors = await trx('stashes_actors')
|
||||||
|
.select('stash_id', knex.raw('array_agg(actor_id order by created_at) as actor_ids'))
|
||||||
|
.whereIn('actor_id', sourceActorStashes.map((actorStash) => actorStash.actor_id))
|
||||||
|
.groupBy('stash_id')
|
||||||
|
.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));
|
||||||
|
|
||||||
|
if (duplicateStashActors.length > 0) {
|
||||||
|
// some stashes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
|
||||||
|
await trx('stashes_actors')
|
||||||
|
.whereIn('stash_id', duplicateStashActors.map((actorStash) => actorStash.stash_id))
|
||||||
|
.whereIn('actor_id', duplicateStashActors.flatMap((actorStash) => actorStash.actor_ids.slice(1)))
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// we update an existing entry instead of creating a new one, so the original stash date is preserved
|
||||||
|
mergedActorStashes = await trx('stashes_actors')
|
||||||
|
.update('actor_id', targetActorId)
|
||||||
|
.whereIn('actor_id', sourceActorIds)
|
||||||
|
.returning('stash_id');
|
||||||
|
|
||||||
|
await trx.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interpolateProfiles([targetActorId, ...sourceActorIds], {
|
||||||
|
knex,
|
||||||
|
logger,
|
||||||
|
moment,
|
||||||
|
slugify,
|
||||||
|
omit,
|
||||||
|
}, { refreshView: false });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
syncScenes([
|
||||||
|
...mergedSceneActors.map((sceneActor) => sceneActor.release_id),
|
||||||
|
...existingSceneActors.map((sceneActor) => sceneActor.release_id),
|
||||||
|
...duplicateSourceActors.map((sceneActor) => sceneActor.release_id),
|
||||||
|
]),
|
||||||
|
syncActors([targetActorId, ...sourceActorIds]),
|
||||||
|
syncStashes('actor', [targetActorId, ...sourceActorIds]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenes: mergedSceneActors.length,
|
||||||
|
profiles: mergedProfiles.length,
|
||||||
|
stashes: mergedActorStashes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
||||||
const limit = filters.limit || 50;
|
const limit = filters.limit || 50;
|
||||||
const page = filters.page || 1;
|
const page = filters.page || 1;
|
||||||
@@ -696,7 +923,7 @@ async function applyActorRevision(revisionIds, reqUser) {
|
|||||||
return applyActorSocialsDelta(revision.actor_id, delta, trx);
|
return applyActorSocialsDelta(revision.actor_id, delta, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta.key === 'name' && reqUser.role === 'admin') {
|
if (['name', 'allowGlobalMatch'].includes(delta.key) && verifyAbility(reqUser, 'actor', 'update')) {
|
||||||
return applyActorDirectDelta(revision.actor_id, delta, trx);
|
return applyActorDirectDelta(revision.actor_id, delta, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,6 +950,8 @@ async function applyActorRevision(revisionIds, reqUser) {
|
|||||||
slugify,
|
slugify,
|
||||||
omit,
|
omit,
|
||||||
}, { refreshView: false });
|
}, { refreshView: false });
|
||||||
|
|
||||||
|
await syncActors(actorIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
|
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function getWatchUrl(scene) {
|
|||||||
return new URL(scene.url).href;
|
return new URL(scene.url).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
|
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network' || !scene.network)) {
|
||||||
return new URL(scene.channel.url).href;
|
return new URL(scene.channel.url).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import initServer from './web/server.js';
|
import initServer from './web/server.js';
|
||||||
import { initCaches } from './cache.js';
|
import { initCaches } from './cache.js';
|
||||||
|
import { initSyncCron } from './sync.js';
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await initCaches();
|
await initCaches();
|
||||||
|
|
||||||
initServer();
|
initServer();
|
||||||
|
initSyncCron();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export async function verifyKey(userId, key, req) {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
// no need to wait for this
|
// no need to wait for this
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return fetchUser(storedKey.user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createKey(reqUser) {
|
export async function createKey(reqUser) {
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ export function curateMedia(media, context = {}) {
|
|||||||
type: context.type || null,
|
type: context.type || null,
|
||||||
sfw: curateMedia(media.sfw_media),
|
sfw: curateMedia(media.sfw_media),
|
||||||
isRestricted: context.isRestricted,
|
isRestricted: context.isRestricted,
|
||||||
|
createdAt: media.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/scenes.js
@@ -17,6 +17,7 @@ import initLogger from './logger.js';
|
|||||||
import { curateRevision } from './revisions.js';
|
import { curateRevision } from './revisions.js';
|
||||||
import { getAffiliateSceneUrl } from './affiliates.js';
|
import { getAffiliateSceneUrl } from './affiliates.js';
|
||||||
import { censor } from './censor.js';
|
import { censor } from './censor.js';
|
||||||
|
import { syncScenes } from './sync.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
const logger = initLogger();
|
||||||
const mj = new MerkleJson();
|
const mj = new MerkleJson();
|
||||||
@@ -45,6 +46,7 @@ function curateScene(rawScene, assets, reqUser, context) {
|
|||||||
slug: assets.channel.slug,
|
slug: assets.channel.slug,
|
||||||
name: censor(assets.channel.name, context.restriction),
|
name: censor(assets.channel.name, context.restriction),
|
||||||
type: assets.channel.type,
|
type: assets.channel.type,
|
||||||
|
url: assets.channel.url,
|
||||||
isIndependent: assets.channel.independent,
|
isIndependent: assets.channel.independent,
|
||||||
hasLogo: assets.channel.has_logo,
|
hasLogo: assets.channel.has_logo,
|
||||||
},
|
},
|
||||||
@@ -52,6 +54,7 @@ function curateScene(rawScene, assets, reqUser, context) {
|
|||||||
id: assets.channel.network_id,
|
id: assets.channel.network_id,
|
||||||
slug: assets.channel.network_slug,
|
slug: assets.channel.network_slug,
|
||||||
name: censor(assets.channel.network_name, context.restriction),
|
name: censor(assets.channel.network_name, context.restriction),
|
||||||
|
url: assets.network_url,
|
||||||
type: assets.channel.network_type,
|
type: assets.channel.network_type,
|
||||||
hasLogo: assets.channel.network_has_logo,
|
hasLogo: assets.channel.network_has_logo,
|
||||||
} : null,
|
} : null,
|
||||||
@@ -179,6 +182,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
'networks.slug as network_slug',
|
'networks.slug as network_slug',
|
||||||
'networks.name as network_name',
|
'networks.name as network_name',
|
||||||
'networks.type as network_type',
|
'networks.type as network_type',
|
||||||
|
'networks.url as network_url',
|
||||||
'networks.has_logo as network_has_logo',
|
'networks.has_logo as network_has_logo',
|
||||||
knex.raw('row_to_json(affiliates) as affiliate'),
|
knex.raw('row_to_json(affiliates) as affiliate'),
|
||||||
)
|
)
|
||||||
@@ -205,16 +209,19 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
'actors.*',
|
'actors.*',
|
||||||
knex.raw('row_to_json(avatars) as avatar'),
|
knex.raw('row_to_json(avatars) as avatar'),
|
||||||
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
|
||||||
|
knex.raw('row_to_json(aliases) as alias'),
|
||||||
|
knex.raw('case when aliases.id is not null then json_build_object(\'id\', aliases.id, \'name\', aliases.name, \'slug\', aliases.slug) end as alias'),
|
||||||
'countries.name as birth_country_name',
|
'countries.name as birth_country_name',
|
||||||
'countries.alias as birth_country_alias',
|
'countries.alias as birth_country_alias',
|
||||||
'releases_actors.release_id',
|
'releases_actors.release_id',
|
||||||
)
|
)
|
||||||
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
|
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
|
||||||
|
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
|
||||||
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
|
||||||
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
|
||||||
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.groupBy('actors.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
.groupBy('actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
||||||
directors: knex('releases_directors')
|
directors: knex('releases_directors')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
||||||
@@ -402,14 +409,29 @@ function curateOptions(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function curateFacet(results, field, count = 'count(distinct id)') {
|
// function curateFacet(results, field, count = 'count(distinct id)') {
|
||||||
|
function curateFacet(results, field) {
|
||||||
return results
|
return results
|
||||||
.find((result) => result.columns[0][field] && result.columns[1][count])
|
.find((result) => result.columns[0][field] && (result.columns[1]['count(distinct id)'] || result.columns[1]['count(*)']))
|
||||||
?.data.map((row) => ({ key: row[field], doc_count: row[count] }))
|
?.data.map((row) => ({ key: row[field], doc_count: row['count(distinct id)'] || row['count(*)'] }))
|
||||||
.filter((row) => !!row.key)
|
.filter((row) => !!row.key)
|
||||||
|| [];
|
|| [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packN = 100_000;
|
||||||
|
|
||||||
|
function mergePackedTags(tags) {
|
||||||
|
const mergedCounts = tags.reduce((merged, tag) => {
|
||||||
|
const tagId = tag.key % packN;
|
||||||
|
|
||||||
|
merged.set(tagId, (merged.get(tagId) ?? 0) + tag.doc_count);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
return Array.from(mergedCounts.entries(), ([key, count]) => ({ key, doc_count: count }));
|
||||||
|
}
|
||||||
|
|
||||||
async function queryManticoreSql(filters, options, _reqUser) {
|
async function queryManticoreSql(filters, options, _reqUser) {
|
||||||
const aggSize = config.database.manticore.maxAggregateSize;
|
const aggSize = config.database.manticore.maxAggregateSize;
|
||||||
|
|
||||||
@@ -464,16 +486,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
year(scenes.effective_date) as effective_year,
|
year(scenes.effective_date) as effective_year,
|
||||||
weight() as _score
|
weight() as _score
|
||||||
`));
|
`));
|
||||||
|
|
||||||
/*
|
|
||||||
// manticore only supports one joined table, so we can't use it inside stashes; probably not needed anyway (stashes only need global tags?)
|
|
||||||
builder
|
|
||||||
.leftJoin('scenes_tags', 'scenes_tags.scene_id', 'scenes_.id')
|
|
||||||
.groupBy('scenes.id');
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.query) {
|
if (filters.query) {
|
||||||
|
// we exclude title because we have a curated title_filtered field for more effective results
|
||||||
builder.whereRaw('match(\'@!title :query:\', scenes)', { query: escape(filters.query) });
|
builder.whereRaw('match(\'@!title :query:\', scenes)', { query: escape(filters.query) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +498,17 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filters.tagIds?.forEach((tagId) => {
|
filters.tagIds?.forEach((tagId) => {
|
||||||
builder.where('any(tag_ids)', tagId);
|
if (filters.onlyActorTags) {
|
||||||
|
builder.where((whereBuilder) => {
|
||||||
|
whereBuilder.where('any(assigned_tag_ids)', tagId);
|
||||||
|
|
||||||
|
filters.actorIds?.forEach((actorId) => {
|
||||||
|
whereBuilder.orWhere('any(assigned_tag_ids)', actorId * 1_000_00 + tagId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
builder.where('any(tag_ids)', tagId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filters.notTagIds) {
|
if (filters.notTagIds) {
|
||||||
@@ -525,12 +551,6 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
builder.where('scenes.is_showcased', filters.isShowcased);
|
builder.where('scenes.is_showcased', filters.isShowcased);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
if (filters.isShowcased) {
|
|
||||||
builder.where('scenes.date', '>', 0);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (options.dedupe) {
|
if (options.dedupe) {
|
||||||
builder.where('scenes.dupe_index', '<', 2);
|
builder.where('scenes.dupe_index', '<', 2);
|
||||||
}
|
}
|
||||||
@@ -574,16 +594,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
.offset((options.page - 1) * options.limit),
|
.offset((options.page - 1) * options.limit),
|
||||||
// option threads=1 fixes actors, but drastically slows down performance, wait for fix
|
// option threads=1 fixes actors, but drastically slows down performance, wait for fix
|
||||||
yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years_facet order by effective_year desc limit ?', [aggSize]) : null,
|
yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years_facet order by effective_year desc limit ?', [aggSize]) : null,
|
||||||
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
|
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
// don't facet tags associated to other actors, actor ID 0 means global
|
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.assigned_tag_ids as tags_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids as tags_facet order by count(distinct id) desc limit ?', [aggSize]) : null,
|
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
/*
|
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
actorTagsFacet: options.aggregateTags && !filters.stashId // eslint-disable-line no-nested-ternary
|
|
||||||
? knex.raw(`facet IF(IN(scenes_tags.actor_id, ${[0, ...filters?.actorIds || []]}), scenes_tags.tag_id, 0) as actor_tags_facet distinct id order by count(distinct id) desc limit ?`, [aggSize])
|
|
||||||
: null,
|
|
||||||
*/
|
|
||||||
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
|
|
||||||
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
|
|
||||||
maxMatches: config.database.manticore.maxMatches,
|
maxMatches: config.database.manticore.maxMatches,
|
||||||
maxQueryTime: config.database.manticore.maxQueryTime,
|
maxQueryTime: config.database.manticore.maxQueryTime,
|
||||||
}).toString();
|
}).toString();
|
||||||
@@ -601,15 +615,29 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
|
|
||||||
const results = await utilsApi.sql(curatedSqlQuery);
|
const results = await utilsApi.sql(curatedSqlQuery);
|
||||||
|
|
||||||
// console.log(util.inspect(results, null, Infinity));
|
const years = curateFacet(results, 'years_facet');
|
||||||
|
|
||||||
const years = curateFacet(results, 'years_facet', 'count(*)');
|
|
||||||
const actorIds = curateFacet(results, 'actors_facet');
|
const actorIds = curateFacet(results, 'actors_facet');
|
||||||
const tagIds = curateFacet(results, 'tags_facet');
|
const tagIds = curateFacet(results, 'tags_facet');
|
||||||
const actorTagIds = curateFacet(results, 'actor_tags_facet');
|
|
||||||
const channelIds = curateFacet(results, 'channels_facet');
|
const channelIds = curateFacet(results, 'channels_facet');
|
||||||
const studioIds = curateFacet(results, 'studios_facet');
|
const studioIds = curateFacet(results, 'studios_facet');
|
||||||
|
|
||||||
|
const allTagIds = mergePackedTags(tagIds);
|
||||||
|
|
||||||
|
const actorTagIds = mergePackedTags(tagIds.filter((tag) => {
|
||||||
|
if (tag.key < packN || !filters?.actorIds.length) {
|
||||||
|
// global
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagActorId = Math.floor(tag.key / packN);
|
||||||
|
|
||||||
|
if (filters.actorIds.includes(tagActorId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
|
||||||
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
|
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -618,7 +646,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
aggregations: {
|
aggregations: {
|
||||||
years,
|
years,
|
||||||
actorIds,
|
actorIds,
|
||||||
tagIds,
|
tagIds: allTagIds,
|
||||||
actorTagIds,
|
actorTagIds,
|
||||||
channelIds,
|
channelIds,
|
||||||
studioIds,
|
studioIds,
|
||||||
@@ -729,8 +757,14 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset((page - 1) * limit);
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.actors, ...(revision.deltas.find((delta) => delta.key === 'actors')?.value || [])])));
|
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [
|
||||||
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])])));
|
...revision.base.actors,
|
||||||
|
...(revision.deltas.find((delta) => delta.key === 'actors')?.value || []),
|
||||||
|
...revision.base.tags.map((tag) => tag.actorId),
|
||||||
|
...revision.deltas.find((delta) => delta.key === 'tags')?.value.map((tag) => tag.actorId) || [],
|
||||||
|
].filter(Boolean))));
|
||||||
|
|
||||||
|
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])].map((tag) => tag.id))));
|
||||||
const movieIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.movies, ...(revision.deltas.find((delta) => delta.key === 'movies')?.value || [])])));
|
const movieIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.movies, ...(revision.deltas.find((delta) => delta.key === 'movies')?.value || [])])));
|
||||||
|
|
||||||
const [actors, tags, movies] = await Promise.all([
|
const [actors, tags, movies] = await Promise.all([
|
||||||
@@ -817,7 +851,18 @@ async function applySceneMoviesDelta(sceneId, delta, trx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySceneRevision(revisionIds) {
|
async function applySceneDeleteDelta(sceneId, _delta, trx, reqUser) {
|
||||||
|
if (!reqUser.abilities.some((ability) => ability.subject === 'scene' && ability.action === 'delete')) {
|
||||||
|
throw new HttpError('You are not privileged to delete scenes', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await knexOwner('releases')
|
||||||
|
.where('id', sceneId)
|
||||||
|
.delete()
|
||||||
|
.transacting(trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySceneRevision(revisionIds, reqUser) {
|
||||||
const revisions = await knexOwner('scenes_revisions')
|
const revisions = await knexOwner('scenes_revisions')
|
||||||
.whereIn('id', revisionIds)
|
.whereIn('id', revisionIds)
|
||||||
.whereNull('applied_at'); // should not re-apply revision that was already applied
|
.whereNull('applied_at'); // should not re-apply revision that was already applied
|
||||||
@@ -827,6 +872,10 @@ async function applySceneRevision(revisionIds) {
|
|||||||
|
|
||||||
await knexOwner.transaction(async (trx) => {
|
await knexOwner.transaction(async (trx) => {
|
||||||
await Promise.all(revision.deltas.map(async (delta) => {
|
await Promise.all(revision.deltas.map(async (delta) => {
|
||||||
|
if (delta.key === 'delete') {
|
||||||
|
return applySceneDeleteDelta(revision.scene_id, delta, trx, reqUser);
|
||||||
|
}
|
||||||
|
|
||||||
if ([
|
if ([
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
@@ -858,13 +907,19 @@ async function applySceneRevision(revisionIds) {
|
|||||||
|
|
||||||
await knexOwner('scenes_revisions')
|
await knexOwner('scenes_revisions')
|
||||||
.where('id', revision.id)
|
.where('id', revision.id)
|
||||||
.update('applied_at', knex.fn.now());
|
.update('applied_at', knexOwner.fn.now())
|
||||||
|
.transacting(trx);
|
||||||
|
|
||||||
// await trx.commit();
|
// await trx.commit();
|
||||||
}).catch(async (error) => {
|
}).catch(async (error) => {
|
||||||
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
|
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
}, Promise.resolve());
|
}, Promise.resolve());
|
||||||
|
|
||||||
|
const sceneIds = Array.from(new Set(revisions.map((revision) => revision.scene_id)));
|
||||||
|
|
||||||
|
await syncScenes(sceneIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
|
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
|
||||||
@@ -892,11 +947,27 @@ export async function reviewSceneRevision(revisionId, isApproved, { feedback },
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isApproved) {
|
if (isApproved) {
|
||||||
await applySceneRevision([revisionId]);
|
try {
|
||||||
|
await applySceneRevision([revisionId], reqUser);
|
||||||
|
} catch (error) {
|
||||||
|
await knexOwner('scenes_revisions')
|
||||||
|
.where('id', revisionId)
|
||||||
|
.update({
|
||||||
|
approved: null,
|
||||||
|
reviewed_at: null,
|
||||||
|
reviewed_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
|
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
|
||||||
|
if (!reqUser) {
|
||||||
|
throw new HttpError('Must be authenticated to create scene revision', 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
[scene],
|
[scene],
|
||||||
openRevisions,
|
openRevisions,
|
||||||
@@ -938,6 +1009,13 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
|
|||||||
return [key, values.id];
|
return [key, values.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'tags') {
|
||||||
|
return [key, values.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
actorId: tag.actorId,
|
||||||
|
}))];
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(values)) {
|
if (Array.isArray(values)) {
|
||||||
return [key, values.map((value) => value?.hash || value?.id || value)];
|
return [key, values.map((value) => value?.hash || value?.id || value)];
|
||||||
}
|
}
|
||||||
@@ -946,10 +1024,24 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
|
|||||||
}).filter(Boolean));
|
}).filter(Boolean));
|
||||||
|
|
||||||
const deltas = Object.entries(edits).map(([key, value]) => {
|
const deltas = Object.entries(edits).map(([key, value]) => {
|
||||||
|
if (key === 'delete') {
|
||||||
|
return { key: 'delete' };
|
||||||
|
}
|
||||||
|
|
||||||
if (baseScene[key] === value || typeof value === 'undefined') {
|
if (baseScene[key] === value || typeof value === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'tags') {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: value.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
actorId: tag.actorId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const valueSet = new Set(value);
|
const valueSet = new Set(value);
|
||||||
const baseSet = new Set(baseScene[key]);
|
const baseSet = new Set(baseScene[key]);
|
||||||
@@ -984,6 +1076,6 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
|
|||||||
|
|
||||||
if (['admin', 'editor'].includes(reqUser.role) && apply) {
|
if (['admin', 'editor'].includes(reqUser.role) && apply) {
|
||||||
// don't keep the editor waiting for the revision to apply
|
// don't keep the editor waiting for the revision to apply
|
||||||
reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
|
reviewSceneRevision(revisionEntry.id, true, {}, reqUser).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ export async function createStash(newStash, sessionUser) {
|
|||||||
throw new HttpError('You are not authenthicated', 401);
|
throw new HttpError('You are not authenthicated', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newStash) {
|
||||||
|
throw new HttpError('Missing new stash', 400);
|
||||||
|
}
|
||||||
|
|
||||||
verifyStashName(newStash);
|
verifyStashName(newStash);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -224,6 +228,14 @@ export async function updateStash(stashIdOrSlug, updatedStash, sessionUser) {
|
|||||||
throw new HttpError('You are not authenthicated', 401);
|
throw new HttpError('You are not authenthicated', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stashIdOrSlug) {
|
||||||
|
throw new HttpError('Missing stash ID or slug', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedStash) {
|
||||||
|
throw new HttpError('Missing updated stash', 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedStash.name) {
|
if (updatedStash.name) {
|
||||||
verifyStashName(updatedStash);
|
verifyStashName(updatedStash);
|
||||||
}
|
}
|
||||||
@@ -260,6 +272,10 @@ export async function removeStash(stashId, sessionUser) {
|
|||||||
throw new HttpError('You are not authenthicated', 401);
|
throw new HttpError('You are not authenthicated', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -283,6 +299,14 @@ export async function removeStash(stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stashActor(actorId, stashId, sessionUser) {
|
export async function stashActor(actorId, stashId, sessionUser) {
|
||||||
|
if (!actorId) {
|
||||||
|
throw new HttpError('Missing actor ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -324,6 +348,14 @@ export async function stashActor(actorId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function unstashActor(actorId, stashId, sessionUser) {
|
export async function unstashActor(actorId, stashId, sessionUser) {
|
||||||
|
if (!actorId) {
|
||||||
|
throw new HttpError('Missing actor ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -367,6 +399,14 @@ export async function unstashActor(actorId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stashScene(sceneId, stashId, sessionUser) {
|
export async function stashScene(sceneId, stashId, sessionUser) {
|
||||||
|
if (!sceneId) {
|
||||||
|
throw new HttpError('Missing scene ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -409,6 +449,14 @@ export async function stashScene(sceneId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function unstashScene(sceneId, stashId, sessionUser) {
|
export async function unstashScene(sceneId, stashId, sessionUser) {
|
||||||
|
if (!sceneId) {
|
||||||
|
throw new HttpError('Missing scene ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -448,6 +496,14 @@ export async function unstashScene(sceneId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stashMovie(movieId, stashId, sessionUser) {
|
export async function stashMovie(movieId, stashId, sessionUser) {
|
||||||
|
if (!movieId) {
|
||||||
|
throw new HttpError('Missing movie ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -489,6 +545,14 @@ export async function stashMovie(movieId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function unstashMovie(movieId, stashId, sessionUser) {
|
export async function unstashMovie(movieId, stashId, sessionUser) {
|
||||||
|
if (!movieId) {
|
||||||
|
throw new HttpError('Missing movie ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stashId) {
|
||||||
|
throw new HttpError('Missing stash ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const stash = await fetchStashById(stashId, sessionUser);
|
const stash = await fetchStashById(stashId, sessionUser);
|
||||||
|
|
||||||
if (!stash) {
|
if (!stash) {
|
||||||
@@ -528,7 +592,7 @@ export async function unstashMovie(movieId, stashId, sessionUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CronJob.from({
|
CronJob.from({
|
||||||
cronTime: config.stashes.viewRefreshCron,
|
cronTime: config.stashes.viewRefreshCrontab,
|
||||||
async onTick() {
|
async onTick() {
|
||||||
logger.verbose('Updating stash views');
|
logger.verbose('Updating stash views');
|
||||||
|
|
||||||
|
|||||||
497
src/sync.js
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import config from 'config';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { CronJob } from 'cron';
|
||||||
|
|
||||||
|
import initLogger from './logger.js';
|
||||||
|
import { knexOwner as knex } from './knex.js';
|
||||||
|
import { searchApi, indexApi } from './manticore.js';
|
||||||
|
import chunk from '../utils/chunk.js';
|
||||||
|
import filterTitle from '../utils/filter-title.js';
|
||||||
|
|
||||||
|
const logger = initLogger();
|
||||||
|
|
||||||
|
export async function syncStashes(domain = 'scene', ids) {
|
||||||
|
const stashes = await knex(`stashes_${domain}s`)
|
||||||
|
.select(
|
||||||
|
`stashes_${domain}s.id as stashed_id`,
|
||||||
|
`stashes_${domain}s.${domain}_id`,
|
||||||
|
'stashes.id as stash_id',
|
||||||
|
'stashes.user_id as user_id',
|
||||||
|
`stashes_${domain}s.created_at as created_at`,
|
||||||
|
)
|
||||||
|
.modify((builder) => {
|
||||||
|
if (ids) {
|
||||||
|
builder.whereRaw(`stashes_${domain}s.${domain}_id = ANY(?)`, [ids]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`);
|
||||||
|
|
||||||
|
await chunk(stashes, 1000).reduce(async (chain, stashChunk, index) => {
|
||||||
|
await chain;
|
||||||
|
|
||||||
|
const stashDocs = stashChunk.map((stash) => ({
|
||||||
|
replace: {
|
||||||
|
index: `${domain}s_stashed`,
|
||||||
|
id: stash.stashed_id,
|
||||||
|
doc: {
|
||||||
|
[`${domain}_id`]: stash[`${domain}_id`],
|
||||||
|
stash_id: stash.stash_id,
|
||||||
|
user_id: stash.user_id,
|
||||||
|
created_at: Math.round(stash.created_at.getTime() / 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||||
|
|
||||||
|
logger.verbose(`Seeded ${index * 1000 + stashChunk.length}/${stashes.length} ${domain} stashes`);
|
||||||
|
}, Promise.resolve());
|
||||||
|
|
||||||
|
// purge orphaned docs
|
||||||
|
const itemIds = ids ?? [...new Set(stashes.map((s) => s[`${domain}_id`]))];
|
||||||
|
|
||||||
|
if (itemIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStashedIds = new Set(stashes.map((stash) => stash.stashed_id));
|
||||||
|
|
||||||
|
await chunk(itemIds, 1000).reduce(async (chain, itemIdChunk) => {
|
||||||
|
await chain;
|
||||||
|
|
||||||
|
const searchResponse = await searchApi.search({
|
||||||
|
index: `${domain}s_stashed`,
|
||||||
|
query: {
|
||||||
|
in: {
|
||||||
|
[`${domain}_id`]: itemIdChunk,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = searchResponse?.hits?.hits ?? [];
|
||||||
|
|
||||||
|
const orphanedIds = docs
|
||||||
|
.map((hit) => hit._id)
|
||||||
|
.filter((manticoreId) => !validStashedIds.has(manticoreId));
|
||||||
|
|
||||||
|
if (orphanedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDocs = orphanedIds.map((orphanId) => ({
|
||||||
|
delete: {
|
||||||
|
index: `${domain}s_stashed`,
|
||||||
|
id: orphanId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await indexApi.bulk(deleteDocs.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||||
|
|
||||||
|
logger.verbose(`Purged ${orphanedIds.length} orphaned ${domain} stash documents`);
|
||||||
|
}, Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncManticoreScenes(sceneIds) {
|
||||||
|
logger.info(`Updating Manticore search documents for ${sceneIds ? sceneIds.length : 'all' } scenes`);
|
||||||
|
|
||||||
|
const scenes = await knex.raw(`
|
||||||
|
SELECT
|
||||||
|
releases.id AS id,
|
||||||
|
releases.title,
|
||||||
|
releases.created_at,
|
||||||
|
releases.date,
|
||||||
|
releases.shoot_id,
|
||||||
|
scenes_meta.stashed,
|
||||||
|
entities.id as channel_id,
|
||||||
|
entities.slug as channel_slug,
|
||||||
|
entities.name as channel_name,
|
||||||
|
parents.id as network_id,
|
||||||
|
parents.slug as network_slug,
|
||||||
|
parents.name as network_name,
|
||||||
|
studios.id as studio_id,
|
||||||
|
studios.slug as studio_slug,
|
||||||
|
studios.name as studio_name,
|
||||||
|
grandparents.id as parent_network_id,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (actors_aliases.id, actors_aliases.name)) FILTER (WHERE actors_aliases.id IS NOT NULL), '[]') as actors_aliases,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name, local_tags.actor_id)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (movies.id, movies.title)) FILTER (WHERE movies.id IS NOT NULL), '[]') as movies,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series,
|
||||||
|
studios.showcased IS NOT false
|
||||||
|
AND (entities.showcased IS NOT false OR COALESCE(studios.showcased, false) = true)
|
||||||
|
AND (parents.showcased IS NOT false OR COALESCE(entities.showcased, false) = true OR COALESCE(studios.showcased, false) = true)
|
||||||
|
AND (releases_summaries.batch_showcased IS NOT false)
|
||||||
|
AS showcased,
|
||||||
|
row_number() OVER (PARTITION BY releases.entry_id, parents.id ORDER BY releases.effective_date DESC) as dupe_index
|
||||||
|
FROM releases
|
||||||
|
LEFT JOIN releases_summaries ON releases_summaries.release_id = releases.id
|
||||||
|
LEFT JOIN scenes_meta ON scenes_meta.scene_id = releases.id
|
||||||
|
LEFT JOIN entities ON releases.entity_id = entities.id
|
||||||
|
LEFT JOIN entities AS parents ON parents.id = entities.parent_id
|
||||||
|
LEFT JOIN entities AS grandparents ON grandparents.id = parents.parent_id
|
||||||
|
LEFT JOIN entities AS studios ON studios.id = releases.studio_id
|
||||||
|
LEFT JOIN releases_actors AS local_actors ON local_actors.release_id = releases.id
|
||||||
|
LEFT JOIN releases_directors AS local_directors ON local_directors.release_id = releases.id
|
||||||
|
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = releases.id
|
||||||
|
LEFT JOIN actors ON local_actors.actor_id = actors.id
|
||||||
|
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
|
||||||
|
LEFT JOIN actors AS actors_aliases ON actors_aliases.alias_for = actors.id
|
||||||
|
LEFT JOIN tags ON local_tags.tag_id = tags.id
|
||||||
|
LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true
|
||||||
|
LEFT JOIN movies_scenes ON movies_scenes.scene_id = releases.id
|
||||||
|
LEFT JOIN movies ON movies.id = movies_scenes.movie_id
|
||||||
|
LEFT JOIN series_scenes ON series_scenes.scene_id = releases.id
|
||||||
|
LEFT JOIN series ON series.id = series_scenes.serie_id
|
||||||
|
${sceneIds ? 'WHERE releases.id = ANY(?)' : ''}
|
||||||
|
GROUP BY
|
||||||
|
releases.id,
|
||||||
|
releases.title,
|
||||||
|
releases.created_at,
|
||||||
|
releases.date,
|
||||||
|
releases.shoot_id,
|
||||||
|
scenes_meta.stashed,
|
||||||
|
releases_summaries.batch_showcased,
|
||||||
|
entities.id,
|
||||||
|
entities.name,
|
||||||
|
entities.slug,
|
||||||
|
entities.alias,
|
||||||
|
entities.showcased,
|
||||||
|
parents.id,
|
||||||
|
parents.name,
|
||||||
|
parents.slug,
|
||||||
|
parents.alias,
|
||||||
|
grandparents.id,
|
||||||
|
studios.id,
|
||||||
|
studios.name,
|
||||||
|
studios.slug,
|
||||||
|
parents.showcased,
|
||||||
|
studios.showcased
|
||||||
|
`, sceneIds && [sceneIds]);
|
||||||
|
|
||||||
|
const scenesById = Object.fromEntries(scenes.rows.map((scene) => [scene.id, scene]));
|
||||||
|
|
||||||
|
const docs = (sceneIds || Object.keys(scenesById)).map((sceneId) => {
|
||||||
|
const scene = scenesById[sceneId];
|
||||||
|
|
||||||
|
if (!scene) {
|
||||||
|
return {
|
||||||
|
delete: {
|
||||||
|
index: 'scenes',
|
||||||
|
id: sceneId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatActors = scene.actors.flatMap((actor) => actor.f2.split(' '));
|
||||||
|
const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => [tag.f2].concat(tag.f4)).filter(Boolean); // only make top tags searchable to minimize cluttered results
|
||||||
|
const filteredTitle = filterTitle(scene.title, [...flatActors, ...flatTags]);
|
||||||
|
|
||||||
|
// use decimal packing with 5-decimal pad to allow for actor-specific tags, i.e. actor 135 tag 5 = 13500005
|
||||||
|
// all global tags are necessarily < 10,000, all tags for actor 135 are >= 13500000 and <= 13599999
|
||||||
|
// f1 = tag ID, f5 = actor ID
|
||||||
|
const assignedTagIds = scene.tags.map((tag) => (tag.f5 === null ? tag.f1 : tag.f5 * 1_000_00 + tag.f1));
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (sceneId === '187734') {
|
||||||
|
console.log(scene, assignedTagIds);
|
||||||
|
throw new Error('ABORT');
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
return {
|
||||||
|
replace: {
|
||||||
|
index: 'scenes',
|
||||||
|
id: scene.id,
|
||||||
|
doc: {
|
||||||
|
title: scene.title || undefined,
|
||||||
|
title_filtered: filteredTitle || undefined,
|
||||||
|
date: scene.date ? Math.round(scene.date.getTime() / 1000) : undefined,
|
||||||
|
created_at: Math.round(scene.created_at.getTime() / 1000),
|
||||||
|
effective_date: Math.round((scene.date || scene.created_at).getTime() / 1000),
|
||||||
|
is_showcased: scene.showcased,
|
||||||
|
shoot_id: scene.shoot_id || undefined,
|
||||||
|
channel_id: scene.channel_id,
|
||||||
|
channel_slug: scene.channel_slug,
|
||||||
|
channel_name: scene.channel_name,
|
||||||
|
network_id: scene.network_id || undefined,
|
||||||
|
network_slug: scene.network_slug || undefined,
|
||||||
|
network_name: scene.network_name || undefined,
|
||||||
|
studio_id: scene.studio_id || undefined,
|
||||||
|
studio_slug: scene.studio_slug || undefined,
|
||||||
|
studio_name: scene.studio_name || undefined,
|
||||||
|
entity_ids: [scene.channel_id, scene.network_id, scene.parent_network_id, scene.studio_id].filter(Boolean), // manticore does not support OR, this allows IN
|
||||||
|
actor_ids: scene.actors.map((actor) => actor.f1), // don't include aliases in ID or they would show up in filters
|
||||||
|
actors: Array.from(new Set([...scene.actors.map((actor) => actor.f2), ...scene.actors_aliases.map((actor) => actor.f2)])).join(),
|
||||||
|
tag_ids: Array.from(new Set(scene.tags.map((tag) => tag.f1))),
|
||||||
|
assigned_tag_ids: assignedTagIds,
|
||||||
|
tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results
|
||||||
|
movie_ids: scene.movies.map((movie) => movie.f1),
|
||||||
|
movies: scene.movies.map((movie) => movie.f2).join(' '),
|
||||||
|
serie_ids: scene.series.map((serie) => serie.f1),
|
||||||
|
series: scene.series.map((serie) => serie.f2).join(' '),
|
||||||
|
meta: scene.date ? format(scene.date, 'y yy M MMM MMMM d') : undefined,
|
||||||
|
stashed: scene.stashed || 0,
|
||||||
|
dupe_index: scene.dupe_index || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [manticoreResult] = await Promise.all([
|
||||||
|
chunk(docs, 10000).reduce(async (chain, docsChunk, index, array) => {
|
||||||
|
const acc = await chain;
|
||||||
|
const data = await indexApi.bulk(docsChunk.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||||
|
|
||||||
|
logger.verbose(`Seeded ${index + 1}/${array.length}, errors: ${data.errors} ${data.error}`);
|
||||||
|
|
||||||
|
return acc.concat(data.items);
|
||||||
|
}, Promise.resolve([])),
|
||||||
|
syncStashes('scene', sceneIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return manticoreResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncScenes(releaseIds) {
|
||||||
|
await knex.raw('REFRESH MATERIALIZED VIEW scenes_meta;');
|
||||||
|
|
||||||
|
await syncManticoreScenes(releaseIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncManticoreMovies(movieIds) {
|
||||||
|
logger.info(`Updating Manticore search documents for ${movieIds ? movieIds.length : 'all' } movies`);
|
||||||
|
|
||||||
|
const movies = await knex.raw(`
|
||||||
|
SELECT
|
||||||
|
movies.id AS id,
|
||||||
|
movies.title,
|
||||||
|
movies.created_at,
|
||||||
|
movies.date,
|
||||||
|
movies_meta.stashed,
|
||||||
|
entities.id as channel_id,
|
||||||
|
entities.slug as channel_slug,
|
||||||
|
entities.name as channel_name,
|
||||||
|
parents.id as network_id,
|
||||||
|
parents.slug as network_slug,
|
||||||
|
parents.name as network_name,
|
||||||
|
movies_covers IS NOT NULL as has_cover,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags,
|
||||||
|
COALESCE(JSON_AGG(DISTINCT (movie_tags.id, movie_tags.name, movie_tags.priority, movie_tags_aliases.name)) FILTER (WHERE movie_tags.id IS NOT NULL), '[]') as movie_tags,
|
||||||
|
row_number() OVER (PARTITION BY movies.entry_id, parents.id ORDER BY movies.effective_date DESC) as dupe_index
|
||||||
|
FROM movies
|
||||||
|
LEFT JOIN movies_meta ON movies_meta.movie_id = movies.id
|
||||||
|
LEFT JOIN movies_scenes ON movies_scenes.movie_id = movies.id
|
||||||
|
LEFT JOIN movies_tags ON movies_tags.movie_id = movies.id
|
||||||
|
LEFT JOIN entities ON movies.entity_id = entities.id
|
||||||
|
LEFT JOIN entities AS parents ON parents.id = entities.parent_id
|
||||||
|
LEFT JOIN releases_actors AS local_actors ON local_actors.release_id = movies_scenes.scene_id
|
||||||
|
LEFT JOIN releases_directors AS local_directors ON local_directors.release_id = movies_scenes.scene_id
|
||||||
|
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = movies_scenes.scene_id
|
||||||
|
LEFT JOIN actors ON local_actors.actor_id = actors.id
|
||||||
|
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
|
||||||
|
LEFT JOIN tags ON local_tags.tag_id = tags.id
|
||||||
|
LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true
|
||||||
|
LEFT JOIN tags as movie_tags ON movies_tags.tag_id = movie_tags.id
|
||||||
|
LEFT JOIN tags as movie_tags_aliases ON movies_tags.tag_id = movie_tags_aliases.alias_for AND movie_tags_aliases.secondary = true
|
||||||
|
LEFT JOIN movies_covers ON movies_covers.movie_id = movies.id
|
||||||
|
${movieIds ? 'WHERE movies.id = ANY(?)' : ''}
|
||||||
|
GROUP BY
|
||||||
|
movies.id,
|
||||||
|
movies.title,
|
||||||
|
movies.created_at,
|
||||||
|
movies.date,
|
||||||
|
movies_meta.stashed,
|
||||||
|
movies_meta.stashed_scenes,
|
||||||
|
movies_meta.stashed_total,
|
||||||
|
entities.id,
|
||||||
|
entities.name,
|
||||||
|
entities.slug,
|
||||||
|
entities.alias,
|
||||||
|
parents.id,
|
||||||
|
parents.name,
|
||||||
|
parents.slug,
|
||||||
|
parents.alias,
|
||||||
|
movies_covers.*
|
||||||
|
`, movieIds && [movieIds]);
|
||||||
|
|
||||||
|
const moviesById = Object.fromEntries(movies.rows.map((movie) => [movie.id, movie]));
|
||||||
|
|
||||||
|
const docs = (movieIds || Object.keys(moviesById)).map((movieId) => {
|
||||||
|
const movie = moviesById[movieId];
|
||||||
|
|
||||||
|
if (!movie) {
|
||||||
|
return {
|
||||||
|
delete: {
|
||||||
|
index: 'movies',
|
||||||
|
id: movieId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedTags = Object.values(Object.fromEntries(movie.tags.concat(movie.movie_tags).map((tag) => [tag.f1, {
|
||||||
|
id: tag.f1,
|
||||||
|
name: tag.f2,
|
||||||
|
priority: tag.f3,
|
||||||
|
alias: tag.f4,
|
||||||
|
}])));
|
||||||
|
|
||||||
|
const flatActors = movie.actors.flatMap((actor) => actor.f2.match(/[\w']+/g)); // match word characters to filter out brackets etc.
|
||||||
|
const flatTags = combinedTags.filter((tag) => tag.priority > 6).flatMap((tag) => (tag.alias ? `${tag.name} ${tag.alias}` : tag.name).match(/[\w']+/g)); // only make top tags searchable to minimize cluttered results
|
||||||
|
const filteredTitle = movie.title && [...flatActors, ...flatTags].reduce((accTitle, tag) => accTitle.replace(new RegExp(tag.replace(/[^\w\s]+/g, ''), 'gi'), ''), movie.title).trim().replace(/\s{2,}/g, ' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
replace: {
|
||||||
|
index: 'movies',
|
||||||
|
id: movie.id,
|
||||||
|
doc: {
|
||||||
|
title: movie.title || undefined,
|
||||||
|
title_filtered: filteredTitle || undefined,
|
||||||
|
date: movie.date ? Math.round(movie.date.getTime() / 1000) : undefined,
|
||||||
|
created_at: Math.round(movie.created_at.getTime() / 1000),
|
||||||
|
effective_date: Math.round((movie.date || movie.created_at).getTime() / 1000),
|
||||||
|
channel_id: movie.channel_id,
|
||||||
|
channel_slug: movie.channel_slug,
|
||||||
|
channel_name: movie.channel_name,
|
||||||
|
network_id: movie.network_id || undefined,
|
||||||
|
network_slug: movie.network_slug || undefined,
|
||||||
|
network_name: movie.network_name || undefined,
|
||||||
|
entity_ids: [movie.channel_id, movie.network_id].filter(Boolean), // manticore does not support OR, this allows IN
|
||||||
|
actor_ids: movie.actors.map((actor) => actor.f1),
|
||||||
|
actors: movie.actors.map((actor) => actor.f2).join(),
|
||||||
|
tag_ids: combinedTags.map((tag) => tag.id),
|
||||||
|
tags: flatTags.join(' '),
|
||||||
|
has_cover: movie.has_cover,
|
||||||
|
meta: movie.date ? format(movie.date, 'y yy M MMM MMMM d') : undefined,
|
||||||
|
stashed: movie.stashed || 0,
|
||||||
|
stashed_scenes: movie.stashed_scenes || 0,
|
||||||
|
stashed_total: movie.stashed_total || 0,
|
||||||
|
dupe_index: movie.dupe_index || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncMovies(releaseIds) {
|
||||||
|
await knex.raw('REFRESH MATERIALIZED VIEW movies_meta;');
|
||||||
|
|
||||||
|
await syncManticoreMovies(releaseIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncManticoreActors(actorIds) {
|
||||||
|
logger.info(`Updating Manticore search documents for ${actorIds ? actorIds.length : 'all' } actors`);
|
||||||
|
|
||||||
|
// manually select date of birth, otherwise it is retrieved in local timezone but interpreted as UTC...
|
||||||
|
const actors = await knex.raw(`
|
||||||
|
SELECT
|
||||||
|
actors.*,
|
||||||
|
actors_meta.stashed, actors_meta.scenes,
|
||||||
|
STRING_AGG(DISTINCT aliases.name, ',') FILTER (WHERE LOWER(aliases.name) != LOWER(actors.name)) as alias,
|
||||||
|
actors.date_of_birth AT TIME ZONE 'Europe/Amsterdam' AT TIME ZONE 'UTC' as dob
|
||||||
|
FROM actors
|
||||||
|
LEFT JOIN actors_meta ON actors_meta.actor_id = actors.id
|
||||||
|
LEFT JOIN actors AS aliases ON aliases.alias_for = actors.id
|
||||||
|
${actorIds ? 'WHERE actors.id = ANY(?)' : ''}
|
||||||
|
GROUP BY actors.id, actors_meta.stashed, actors_meta.scenes
|
||||||
|
`, actorIds && [actorIds]);
|
||||||
|
|
||||||
|
const actorsById = Object.fromEntries(actors.rows.map((actor) => [actor.id, actor]));
|
||||||
|
|
||||||
|
const docs = (actorIds || Object.keys(actorsById)).map((actorId) => {
|
||||||
|
const actor = actorsById[actorId];
|
||||||
|
|
||||||
|
if (!actor) {
|
||||||
|
return {
|
||||||
|
delete: {
|
||||||
|
index: 'actors',
|
||||||
|
id: actorId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replace: {
|
||||||
|
index: 'actors',
|
||||||
|
id: actor.id,
|
||||||
|
doc: {
|
||||||
|
entity_id: actor.entity_id || undefined,
|
||||||
|
alias_for: actor.alias_for || undefined,
|
||||||
|
aliases: actor.alias,
|
||||||
|
name: actor.name,
|
||||||
|
slug: actor.slug,
|
||||||
|
gender: actor.gender || undefined,
|
||||||
|
date_of_birth: actor.dob ? Math.round(actor.dob.getTime() / 1000) : undefined,
|
||||||
|
has_avatar: !!actor.avatar_media_id,
|
||||||
|
country: actor.birth_country_alpha2 || undefined,
|
||||||
|
height: actor.height || undefined,
|
||||||
|
mass: actor.weight || undefined, // weight is a reserved keyword in manticore
|
||||||
|
cup: actor.cup || undefined,
|
||||||
|
natural_boobs: actor.natural_boobs === null ? 0 : Number(actor.natural_boobs) + 1, // manticore bool does not seem to support null, and we need three states for natural_boobs: yes, no and unknown
|
||||||
|
penis_length: actor.penis_length || undefined,
|
||||||
|
penis_girth: actor.penis_girth || undefined,
|
||||||
|
stashed: actor.stashed || 0,
|
||||||
|
scenes: actor.scenes || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncActors(actorIds) {
|
||||||
|
await knex.raw('REFRESH MATERIALIZED VIEW actors_meta;');
|
||||||
|
|
||||||
|
await syncManticoreActors(actorIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueueItemIds(tasks, domain) {
|
||||||
|
return Array.from(new Set(tasks.filter((task) => task.domain === domain).flatMap((task) => task.item_ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncQueue() {
|
||||||
|
const tasks = await knex('sync');
|
||||||
|
|
||||||
|
const sceneIds = getQueueItemIds(tasks, 'scene');
|
||||||
|
const movieIds = getQueueItemIds(tasks, 'movie');
|
||||||
|
const actorIds = getQueueItemIds(tasks, 'actor');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
syncScenes(sceneIds),
|
||||||
|
syncMovies(movieIds),
|
||||||
|
syncActors(actorIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await knex('sync')
|
||||||
|
.whereIn('id', tasks.map((task) => task.id))
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
logger[process.tasks > 0 ? 'info' : 'verbose'](`Processed ${tasks.length} sync items`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSyncCron() {
|
||||||
|
CronJob.from({
|
||||||
|
cronTime: config.sync.crontab,
|
||||||
|
async onTick() {
|
||||||
|
syncQueue();
|
||||||
|
},
|
||||||
|
start: config.sync.enabled,
|
||||||
|
runOnInit: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { indexApi, utilsApi } from '../manticore.js';
|
|
||||||
import rawvideos from './movies.json' with { type: 'json' };
|
|
||||||
|
|
||||||
async function fetchvideos() {
|
|
||||||
const videos = rawvideos
|
|
||||||
.filter((video) => video.cast.length > 0
|
|
||||||
&& video.genres.length > 0
|
|
||||||
&& video.cast.every((actor) => actor.charCodeAt(0) >= 65)) // throw out videos with non-alphanumerical actor names
|
|
||||||
.map((video, index) => ({ id: index + 1, ...video }));
|
|
||||||
|
|
||||||
const actors = Array.from(new Set(videos.flatMap((video) => video.cast))).sort();
|
|
||||||
const genres = Array.from(new Set(videos.flatMap((video) => video.genres)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
videos,
|
|
||||||
actors,
|
|
||||||
genres,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
await utilsApi.sql('drop table if exists videos');
|
|
||||||
await utilsApi.sql('drop table if exists videos_liked');
|
|
||||||
|
|
||||||
await utilsApi.sql(`create table videos (
|
|
||||||
id int,
|
|
||||||
title text,
|
|
||||||
actor_ids multi,
|
|
||||||
actors text,
|
|
||||||
genre_ids multi,
|
|
||||||
genres text
|
|
||||||
)`);
|
|
||||||
|
|
||||||
await utilsApi.sql(`create table videos_liked (
|
|
||||||
id int,
|
|
||||||
user_id int,
|
|
||||||
video_id int
|
|
||||||
)`);
|
|
||||||
|
|
||||||
const { videos, actors, genres } = await fetchvideos();
|
|
||||||
|
|
||||||
const likedvideoIds = Array.from(new Set(Array.from({ length: 10.000 }, () => videos[Math.round(Math.random() * videos.length)].id)));
|
|
||||||
|
|
||||||
const docs = videos
|
|
||||||
.map((video) => ({
|
|
||||||
replace: {
|
|
||||||
index: 'videos',
|
|
||||||
id: video.id,
|
|
||||||
doc: {
|
|
||||||
title: video.title,
|
|
||||||
actor_ids: video.cast.map((actor) => actors.indexOf(actor)),
|
|
||||||
actors: video.cast.join(','),
|
|
||||||
genre_ids: video.genres.map((genre) => genres.indexOf(genre)),
|
|
||||||
genres: video.genres.join(','),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
.concat(likedvideoIds.map((videoId, index) => ({
|
|
||||||
replace: {
|
|
||||||
index: 'videos_liked',
|
|
||||||
id: index + 1,
|
|
||||||
doc: {
|
|
||||||
user_id: Math.floor(Math.random() * 51),
|
|
||||||
video_id: videoId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})));
|
|
||||||
|
|
||||||
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
|
|
||||||
|
|
||||||
console.log('data', data);
|
|
||||||
|
|
||||||
const result = await utilsApi.sql(`
|
|
||||||
select * from videos_liked
|
|
||||||
limit 10
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(result[0].data);
|
|
||||||
console.log(result[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
// import config from 'config';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { faker } from '@faker-js/faker';
|
|
||||||
|
|
||||||
import { indexApi } from '../manticore.js';
|
|
||||||
|
|
||||||
import { knexOwner as knex } from '../knex.js';
|
|
||||||
import slugify from '../utils/slugify.js';
|
|
||||||
import chunk from '../utils/chunk.js';
|
|
||||||
|
|
||||||
async function fetchScenes() {
|
|
||||||
const scenes = await knex.raw(`
|
|
||||||
SELECT
|
|
||||||
releases.id AS id,
|
|
||||||
releases.title,
|
|
||||||
releases.created_at,
|
|
||||||
releases.date,
|
|
||||||
releases.shoot_id,
|
|
||||||
scenes_meta.stashed,
|
|
||||||
entities.id as channel_id,
|
|
||||||
entities.slug as channel_slug,
|
|
||||||
entities.name as channel_name,
|
|
||||||
parents.id as network_id,
|
|
||||||
parents.slug as network_slug,
|
|
||||||
parents.name as network_name,
|
|
||||||
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
|
|
||||||
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags
|
|
||||||
FROM releases
|
|
||||||
LEFT JOIN scenes_meta ON scenes_meta.scene_id = releases.id
|
|
||||||
LEFT JOIN entities ON releases.entity_id = entities.id
|
|
||||||
LEFT JOIN entities AS parents ON parents.id = entities.parent_id
|
|
||||||
LEFT JOIN releases_actors AS local_actors ON local_actors.release_id = releases.id
|
|
||||||
LEFT JOIN releases_directors AS local_directors ON local_directors.release_id = releases.id
|
|
||||||
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = releases.id
|
|
||||||
LEFT JOIN actors ON local_actors.actor_id = actors.id
|
|
||||||
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
|
|
||||||
LEFT JOIN tags ON local_tags.tag_id = tags.id
|
|
||||||
LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true
|
|
||||||
GROUP BY
|
|
||||||
releases.id,
|
|
||||||
releases.title,
|
|
||||||
releases.created_at,
|
|
||||||
releases.date,
|
|
||||||
releases.shoot_id,
|
|
||||||
scenes_meta.stashed,
|
|
||||||
entities.id,
|
|
||||||
entities.name,
|
|
||||||
entities.slug,
|
|
||||||
entities.alias,
|
|
||||||
parents.id,
|
|
||||||
parents.name,
|
|
||||||
parents.slug,
|
|
||||||
parents.alias;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const actors = Object.fromEntries(scenes.rows.flatMap((row) => row.actors.map((actor) => [actor.f1, faker.person.fullName()])));
|
|
||||||
const tags = Object.fromEntries(scenes.rows.flatMap((row) => row.tags.map((tag) => [tag.f1, faker.word.adjective()])));
|
|
||||||
|
|
||||||
return scenes.rows.map((row) => {
|
|
||||||
const title = faker.lorem.lines(1);
|
|
||||||
|
|
||||||
const channelName = faker.company.name();
|
|
||||||
const channelSlug = slugify(channelName, '');
|
|
||||||
|
|
||||||
const networkName = faker.company.name();
|
|
||||||
const networkSlug = slugify(networkName, '');
|
|
||||||
|
|
||||||
const rowActors = row.actors.map((actor) => ({ f1: actor.f1, f2: actors[actor.f1] }));
|
|
||||||
const rowTags = row.tags.map((tag) => ({ f1: tag.f1, f2: tags[tag.f1], f3: tag.f3 }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
title,
|
|
||||||
actors: rowActors,
|
|
||||||
tags: rowTags,
|
|
||||||
channel_name: channelName,
|
|
||||||
channel_slug: channelSlug,
|
|
||||||
network_name: networkName,
|
|
||||||
network_slug: networkSlug,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStashed(docs) {
|
|
||||||
await chunk(docs, 1000).reduce(async (chain, docsChunk) => {
|
|
||||||
await chain;
|
|
||||||
|
|
||||||
const sceneIds = docsChunk.map((doc) => doc.replace.id);
|
|
||||||
|
|
||||||
const stashes = await knex('stashes_scenes')
|
|
||||||
.select('stashes_scenes.id as stashed_id', 'stashes_scenes.scene_id', 'stashes.id as stash_id', 'stashes.user_id as user_id')
|
|
||||||
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
|
|
||||||
.whereIn('scene_id', sceneIds);
|
|
||||||
|
|
||||||
if (stashes.length > 0) {
|
|
||||||
console.log(stashes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stashDocs = docsChunk.flatMap((doc) => {
|
|
||||||
const sceneStashes = stashes.filter((stash) => stash.scene_id === doc.replace.id);
|
|
||||||
|
|
||||||
if (sceneStashes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const stashDoc = sceneStashes.map((stash) => ({
|
|
||||||
replace: {
|
|
||||||
index: 'scenes_stashed',
|
|
||||||
id: stash.stashed_id,
|
|
||||||
doc: {
|
|
||||||
// ...doc.replace.doc,
|
|
||||||
scene_id: doc.replace.id,
|
|
||||||
user_id: stash.user_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return stashDoc;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(stashDocs);
|
|
||||||
|
|
||||||
if (stashDocs.length > 0) {
|
|
||||||
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
|
|
||||||
}
|
|
||||||
}, Promise.resolve());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const scenes = await fetchScenes();
|
|
||||||
|
|
||||||
const docs = scenes.map((scene) => {
|
|
||||||
const flatActors = scene.actors.flatMap((actor) => actor.f2.match(/[\w']+/g)); // match word characters to filter out brackets etc.
|
|
||||||
const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => (tag.f4 ? `${tag.f2} ${tag.f4}` : tag.f2).match(/[\w']+/g)); // only make top tags searchable to minimize cluttered results
|
|
||||||
const filteredTitle = scene.title && [...flatActors, ...flatTags].reduce((accTitle, tag) => accTitle.replace(new RegExp(tag.replace(/[^\w\s]+/g, ''), 'i'), ''), scene.title).trim().replace(/\s{2,}/, ' ');
|
|
||||||
|
|
||||||
return {
|
|
||||||
replace: {
|
|
||||||
index: 'scenes',
|
|
||||||
id: scene.id,
|
|
||||||
doc: {
|
|
||||||
title: scene.title || undefined,
|
|
||||||
title_filtered: filteredTitle || undefined,
|
|
||||||
date: scene.date ? Math.round(scene.date.getTime() / 1000) : undefined,
|
|
||||||
created_at: Math.round(scene.created_at.getTime() / 1000),
|
|
||||||
effective_date: Math.round((scene.date || scene.created_at).getTime() / 1000),
|
|
||||||
// shoot_id: scene.shoot_id || undefined,
|
|
||||||
channel_id: scene.channel_id,
|
|
||||||
channel_slug: scene.channel_slug,
|
|
||||||
channel_name: scene.channel_name,
|
|
||||||
network_id: scene.network_id || undefined,
|
|
||||||
network_slug: scene.network_slug || undefined,
|
|
||||||
network_name: scene.network_name || undefined,
|
|
||||||
actor_ids: scene.actors.map((actor) => actor.f1),
|
|
||||||
actors: scene.actors.map((actor) => actor.f2).join(),
|
|
||||||
tag_ids: scene.tags.map((tag) => tag.f1),
|
|
||||||
tags: flatTags.join(' '),
|
|
||||||
meta: scene.date ? format(scene.date, 'y yy M MMM MMMM d') : undefined,
|
|
||||||
liked: scene.stashed || 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
|
|
||||||
|
|
||||||
await updateStashed(docs);
|
|
||||||
|
|
||||||
console.log('data', data);
|
|
||||||
|
|
||||||
knex.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
648028
src/tools/movies.json
@@ -1,77 +0,0 @@
|
|||||||
import { indexApi, utilsApi } from '../manticore.js';
|
|
||||||
import { knexOwner as knex } from '../knex.js';
|
|
||||||
import chunk from '../utils/chunk.js';
|
|
||||||
|
|
||||||
async function syncStashes(domain = 'scene') {
|
|
||||||
await utilsApi.sql(`truncate table ${domain}s_stashed`);
|
|
||||||
|
|
||||||
const stashes = await knex(`stashes_${domain}s`)
|
|
||||||
.select(
|
|
||||||
`stashes_${domain}s.id as stashed_id`,
|
|
||||||
`stashes_${domain}s.${domain}_id`,
|
|
||||||
'stashes.id as stash_id',
|
|
||||||
'stashes.user_id as user_id',
|
|
||||||
`stashes_${domain}s.created_at as created_at`,
|
|
||||||
)
|
|
||||||
.leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`);
|
|
||||||
|
|
||||||
await chunk(stashes, 1000).reduce(async (chain, stashChunk, index) => {
|
|
||||||
await chain;
|
|
||||||
|
|
||||||
const stashDocs = stashChunk.map((stash) => ({
|
|
||||||
replace: {
|
|
||||||
index: `${domain}s_stashed`,
|
|
||||||
id: stash.stashed_id,
|
|
||||||
doc: {
|
|
||||||
[`${domain}_id`]: stash[`${domain}_id`],
|
|
||||||
stash_id: stash.stash_id,
|
|
||||||
user_id: stash.user_id,
|
|
||||||
created_at: Math.round(stash.created_at.getTime() / 1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
|
|
||||||
|
|
||||||
console.log(`Synced ${index * 1000 + stashChunk.length}/${stashes.length} ${domain} stashes`);
|
|
||||||
}, Promise.resolve());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
await utilsApi.sql('drop table if exists scenes_stashed');
|
|
||||||
|
|
||||||
await utilsApi.sql(`create table if not exists scenes_stashed (
|
|
||||||
scene_id int,
|
|
||||||
stash_id int,
|
|
||||||
user_id int,
|
|
||||||
created_at timestamp
|
|
||||||
)`);
|
|
||||||
|
|
||||||
await utilsApi.sql('drop table if exists movies_stashed');
|
|
||||||
|
|
||||||
await utilsApi.sql(`create table if not exists movies_stashed (
|
|
||||||
movie_id int,
|
|
||||||
stash_id int,
|
|
||||||
user_id int,
|
|
||||||
created_at timestamp
|
|
||||||
)`);
|
|
||||||
|
|
||||||
await utilsApi.sql('drop table if exists actors_stashed');
|
|
||||||
|
|
||||||
await utilsApi.sql(`create table if not exists actors_stashed (
|
|
||||||
actor_id int,
|
|
||||||
stash_id int,
|
|
||||||
user_id int,
|
|
||||||
created_at timestamp
|
|
||||||
)`);
|
|
||||||
|
|
||||||
await syncStashes('scene');
|
|
||||||
await syncStashes('actor');
|
|
||||||
await syncStashes('movie');
|
|
||||||
|
|
||||||
console.log('Done!');
|
|
||||||
|
|
||||||
knex.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import Router from 'express-promise-router';
|
import Router from 'express-promise-router';
|
||||||
|
import omit from 'object.omit';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchActors,
|
fetchActors,
|
||||||
fetchActorsById,
|
fetchActorsById,
|
||||||
|
createActor,
|
||||||
|
mergeActors,
|
||||||
fetchActorRevisions,
|
fetchActorRevisions,
|
||||||
createActorRevision,
|
createActorRevision,
|
||||||
reviewActorRevision,
|
reviewActorRevision,
|
||||||
@@ -24,6 +27,7 @@ export function curateActorsQuery(query) {
|
|||||||
weight: query.weight?.split(',').map((weight) => Number(weight)),
|
weight: query.weight?.split(',').map((weight) => Number(weight)),
|
||||||
requireAvatar: query.avatar,
|
requireAvatar: query.avatar,
|
||||||
stashId: Number(query.stashId) || null,
|
stashId: Number(query.stashId) || null,
|
||||||
|
isGlobal: !!query.global,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +173,18 @@ export async function fetchActorsByIdGraphql(query, _req, _info) {
|
|||||||
return curatedActors[0];
|
return curatedActors[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createActorApi(req, res) {
|
||||||
|
const actor = await createActor(req.body.actor, omit(req.body, ['actor']), req.user);
|
||||||
|
|
||||||
|
res.send({ actor });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeActorsApi(req, res) {
|
||||||
|
const result = await mergeActors(Number(req.params.targetActorId), req.params.sourceActorIds.split(',').map((actorId) => Number(actorId)), req.user);
|
||||||
|
|
||||||
|
res.send(result);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchActorRevisionsApi(req, res) {
|
async function fetchActorRevisionsApi(req, res) {
|
||||||
const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user);
|
const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user);
|
||||||
|
|
||||||
@@ -190,6 +206,9 @@ async function reviewActorRevisionApi(req, res) {
|
|||||||
export const actorsRouter = Router();
|
export const actorsRouter = Router();
|
||||||
|
|
||||||
actorsRouter.get('/api/actors', fetchActorsApi);
|
actorsRouter.get('/api/actors', fetchActorsApi);
|
||||||
|
actorsRouter.post('/api/actors', createActorApi);
|
||||||
|
|
||||||
|
actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorIds', mergeActorsApi);
|
||||||
|
|
||||||
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
|
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
|
||||||
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);
|
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ export default function consentHandler(req, res, next) {
|
|||||||
const redirect = req.headers.referer && new URL(req.headers.referer).searchParams.get('redirect');
|
const redirect = req.headers.referer && new URL(req.headers.referer).searchParams.get('redirect');
|
||||||
|
|
||||||
if (Object.hasOwn(req.query, 'lgbt')) {
|
if (Object.hasOwn(req.query, 'lgbt')) {
|
||||||
const lgbtFilters = (req.tagFilter || []).filter((tag) => !['gay', 'bisexual', 'transsexual'].includes(tag));
|
const lgbtFilters = Array.from(new Set([...(req.tagFilter || []).filter((tag) => !['gay', 'bisexual', 'transsexual'].includes(tag)), 'extreme-insertion']));
|
||||||
|
|
||||||
req.tagFilter = lgbtFilters; // eslint-disable-line no-param-reassign
|
req.tagFilter = lgbtFilters; // eslint-disable-line no-param-reassign
|
||||||
res.cookie('tags', JSON.stringify(lgbtFilters));
|
res.cookie('tags', JSON.stringify(lgbtFilters));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.hasOwn(req.query, 'straight')) {
|
if (Object.hasOwn(req.query, 'straight')) {
|
||||||
const straightFilters = Array.from(new Set([...(req.tagFilter || []), 'gay', 'bisexual', 'transsexual']));
|
const straightFilters = Array.from(new Set([...(req.tagFilter || []), 'gay', 'bisexual', 'transsexual', 'extreme-insertion']));
|
||||||
|
|
||||||
req.tagFilter = straightFilters; // eslint-disable-line no-param-reassign
|
req.tagFilter = straightFilters; // eslint-disable-line no-param-reassign
|
||||||
res.cookie('tags', JSON.stringify(straightFilters));
|
res.cookie('tags', JSON.stringify(straightFilters));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export async function curateScenesQuery(query) {
|
|||||||
notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean),
|
notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean),
|
||||||
tagIds,
|
tagIds,
|
||||||
notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags
|
notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags
|
||||||
|
onlyActorTags: !!query.at,
|
||||||
entityId,
|
entityId,
|
||||||
notEntityIds,
|
notEntityIds,
|
||||||
movieId: Number(query.movieId) || null,
|
movieId: Number(query.movieId) || null,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import initRestrictionHandler from './restrictions.js';
|
|||||||
|
|
||||||
import { scenesRouter } from './scenes.js';
|
import { scenesRouter } from './scenes.js';
|
||||||
import { actorsRouter } from './actors.js';
|
import { actorsRouter } from './actors.js';
|
||||||
|
import { syncRouter } from './sync.js';
|
||||||
|
|
||||||
import { fetchMoviesApi } from './movies.js';
|
import { fetchMoviesApi } from './movies.js';
|
||||||
import { fetchEntitiesApi } from './entities.js';
|
import { fetchEntitiesApi } from './entities.js';
|
||||||
@@ -122,11 +123,7 @@ export default async function initServer() {
|
|||||||
|
|
||||||
router.use('/api/*', async (req, _res, next) => {
|
router.use('/api/*', async (req, _res, next) => {
|
||||||
if (req.headers['api-user']) {
|
if (req.headers['api-user']) {
|
||||||
await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
req.user = await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
||||||
|
|
||||||
req.user = { // eslint-disable-line no-param-reassign
|
|
||||||
id: Number(req.headers['api-user']),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -150,6 +147,7 @@ export default async function initServer() {
|
|||||||
router.use(alertsRouter);
|
router.use(alertsRouter);
|
||||||
router.use(scenesRouter);
|
router.use(scenesRouter);
|
||||||
router.use(actorsRouter);
|
router.use(actorsRouter);
|
||||||
|
router.use(syncRouter);
|
||||||
|
|
||||||
// MOVIES
|
// MOVIES
|
||||||
router.get('/api/movies', fetchMoviesApi);
|
router.get('/api/movies', fetchMoviesApi);
|
||||||
|
|||||||
59
src/web/sync.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Router from 'express-promise-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
syncScenes,
|
||||||
|
syncMovies,
|
||||||
|
syncActors,
|
||||||
|
syncStashes,
|
||||||
|
syncQueue,
|
||||||
|
} from '../sync.js';
|
||||||
|
|
||||||
|
import verifyAbility from '../../utils/verify-ability.js';
|
||||||
|
|
||||||
|
export const syncRouter = Router();
|
||||||
|
|
||||||
|
async function syncScenesApi(req, res) {
|
||||||
|
verifyAbility(req.user, 'sync', null, { throwError: true });
|
||||||
|
|
||||||
|
await syncScenes(req.body.sceneIds);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncMoviesApi(req, res) {
|
||||||
|
verifyAbility(req.user, 'sync', null, { throwError: true });
|
||||||
|
|
||||||
|
await syncMovies(req.body.movieIds);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncActorsApi(req, res) {
|
||||||
|
verifyAbility(req.user, 'sync', null, { throwError: true });
|
||||||
|
|
||||||
|
await syncActors(req.body.actorIds);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncStashesApi(req, res) {
|
||||||
|
verifyAbility(req.user, 'sync', null, { throwError: true });
|
||||||
|
|
||||||
|
await syncStashes(req.body.stashIds);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncQueueApi(req, res) {
|
||||||
|
verifyAbility(req.user, 'sync', null, { throwError: true });
|
||||||
|
|
||||||
|
await syncQueue();
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRouter.post('/api/sync/scenes', syncScenesApi);
|
||||||
|
syncRouter.post('/api/sync/movies', syncMoviesApi);
|
||||||
|
syncRouter.post('/api/sync/actors', syncActorsApi);
|
||||||
|
syncRouter.post('/api/sync/stashes', syncStashesApi);
|
||||||
|
syncRouter.post('/api/sync', syncQueueApi);
|
||||||
2
static
45
tools/manticore-actors.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import argv from '../src/argv.js';
|
||||||
|
|
||||||
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import { syncManticoreActors } from '../src/sync.js';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (argv.update) {
|
||||||
|
await utilsApi.sql('drop table if exists actors');
|
||||||
|
await utilsApi.sql(`create table actors(
|
||||||
|
id int,
|
||||||
|
name text,
|
||||||
|
aliases text,
|
||||||
|
slug string,
|
||||||
|
entity_id int,
|
||||||
|
alias_for int,
|
||||||
|
gender string,
|
||||||
|
date_of_birth timestamp,
|
||||||
|
country string,
|
||||||
|
has_avatar bool,
|
||||||
|
mass int,
|
||||||
|
height int,
|
||||||
|
cup string,
|
||||||
|
natural_boobs int,
|
||||||
|
penis_length int,
|
||||||
|
penis_girth int,
|
||||||
|
stashed int,
|
||||||
|
scenes int
|
||||||
|
) min_prefix_len = '3'`);
|
||||||
|
|
||||||
|
console.log('Recreated actors table, syncing actors...');
|
||||||
|
|
||||||
|
const data = await syncManticoreActors();
|
||||||
|
|
||||||
|
console.log('data', data);
|
||||||
|
|
||||||
|
knex.destroy();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
knex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
45
tools/manticore-movies.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import argv from '../src/argv.js';
|
||||||
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import { syncManticoreMovies } from '../src/sync.js';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (argv.update) {
|
||||||
|
await utilsApi.sql('drop table if exists movies');
|
||||||
|
await utilsApi.sql(`create table movies (
|
||||||
|
id int,
|
||||||
|
title text,
|
||||||
|
title_filtered text,
|
||||||
|
channel_id int,
|
||||||
|
channel_name text,
|
||||||
|
channel_slug text,
|
||||||
|
network_id int,
|
||||||
|
network_name text,
|
||||||
|
network_slug text,
|
||||||
|
entity_ids multi,
|
||||||
|
actor_ids multi,
|
||||||
|
actors text,
|
||||||
|
tag_ids multi,
|
||||||
|
tags text,
|
||||||
|
meta text,
|
||||||
|
date timestamp,
|
||||||
|
has_cover bool,
|
||||||
|
created_at timestamp,
|
||||||
|
effective_date timestamp,
|
||||||
|
stashed int,
|
||||||
|
stashed_scenes int,
|
||||||
|
stashed_total int,
|
||||||
|
dupe_index int
|
||||||
|
)`);
|
||||||
|
|
||||||
|
console.log('Recreated movies tables, syncing movies...');
|
||||||
|
|
||||||
|
const data = await syncManticoreMovies();
|
||||||
|
|
||||||
|
console.log('data', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
knex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
63
tools/manticore-scenes.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import argv from '../src/argv.js';
|
||||||
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import { syncManticoreScenes } from '../src/sync.js';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (argv.update) {
|
||||||
|
await utilsApi.sql('drop table if exists scenes');
|
||||||
|
await utilsApi.sql(`create table scenes (
|
||||||
|
id int,
|
||||||
|
title text,
|
||||||
|
title_filtered text,
|
||||||
|
entry_id text,
|
||||||
|
shoot_id text,
|
||||||
|
channel_id int,
|
||||||
|
channel_name text,
|
||||||
|
channel_slug text,
|
||||||
|
network_id int,
|
||||||
|
network_name text,
|
||||||
|
network_slug text,
|
||||||
|
studio_id int,
|
||||||
|
studio_name text,
|
||||||
|
studio_slug text,
|
||||||
|
entity_ids multi,
|
||||||
|
actor_ids multi,
|
||||||
|
actors text,
|
||||||
|
tag_ids multi,
|
||||||
|
assigned_tag_ids multi64,
|
||||||
|
tags text,
|
||||||
|
movie_ids multi,
|
||||||
|
movies text,
|
||||||
|
serie_ids multi,
|
||||||
|
series text,
|
||||||
|
meta text,
|
||||||
|
date timestamp,
|
||||||
|
fingerprints text,
|
||||||
|
is_showcased bool,
|
||||||
|
created_at timestamp,
|
||||||
|
effective_date timestamp,
|
||||||
|
stashed int,
|
||||||
|
dupe_index int
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await utilsApi.sql('drop table if exists scenes_tags');
|
||||||
|
|
||||||
|
/* legacy, using packed decimal keys now
|
||||||
|
await utilsApi.sql(`create table scenes_tags (
|
||||||
|
id int,
|
||||||
|
scene_id int,
|
||||||
|
tag_id int,
|
||||||
|
actor_id int
|
||||||
|
)`);
|
||||||
|
*/
|
||||||
|
|
||||||
|
console.log('Recreated scenes tables, syncing scenes...');
|
||||||
|
|
||||||
|
await syncManticoreScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
knex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
42
tools/manticore-stashes.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import { syncStashes } from '../src/sync.js';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await utilsApi.sql('drop table if exists scenes_stashed');
|
||||||
|
|
||||||
|
await utilsApi.sql(`create table if not exists scenes_stashed (
|
||||||
|
scene_id int,
|
||||||
|
stash_id int,
|
||||||
|
user_id int,
|
||||||
|
created_at timestamp
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await utilsApi.sql('drop table if exists movies_stashed');
|
||||||
|
|
||||||
|
await utilsApi.sql(`create table if not exists movies_stashed (
|
||||||
|
movie_id int,
|
||||||
|
stash_id int,
|
||||||
|
user_id int,
|
||||||
|
created_at timestamp
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await utilsApi.sql('drop table if exists actors_stashed');
|
||||||
|
|
||||||
|
await utilsApi.sql(`create table if not exists actors_stashed (
|
||||||
|
actor_id int,
|
||||||
|
stash_id int,
|
||||||
|
user_id int,
|
||||||
|
created_at timestamp
|
||||||
|
)`);
|
||||||
|
|
||||||
|
console.log('Recreated stash tables, syncing stashes...');
|
||||||
|
|
||||||
|
await syncStashes('scene');
|
||||||
|
await syncStashes('actor');
|
||||||
|
await syncStashes('movie');
|
||||||
|
|
||||||
|
knex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
80
tools/scene_tag_revision_fix.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { MerkleJson } from 'merkle-json';
|
||||||
|
|
||||||
|
import knex from '../knex.js';
|
||||||
|
|
||||||
|
const mj = new MerkleJson();
|
||||||
|
|
||||||
|
function curateTag(tag) {
|
||||||
|
if (Object.hasOwn(tag, 'actorId')) {
|
||||||
|
return {
|
||||||
|
id: tag.id,
|
||||||
|
actorId: tag.actorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tag === 'number') {
|
||||||
|
return {
|
||||||
|
id: tag,
|
||||||
|
// can't restore actorId, don't set to null to hint at missing data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unrecognized tag delta: ${JSON.stringify(tag)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const revisions = await knex('scenes_revisions');
|
||||||
|
|
||||||
|
// console.log(revisions);
|
||||||
|
|
||||||
|
const fixedRevisions = revisions.map((revision) => {
|
||||||
|
if (revision.base.tags.length === 0 && !revision.deltas.some((delta) => delta.key === 'tags')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDeltas = revision.deltas.map((delta) => {
|
||||||
|
if (delta.key !== 'tags') {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...delta,
|
||||||
|
value: delta.value.map((tag) => curateTag(tag)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const newBase = {
|
||||||
|
...revision.base,
|
||||||
|
tags: revision.base.tags.map((tag) => curateTag(tag)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...revision,
|
||||||
|
deltas: newDeltas,
|
||||||
|
base: newBase,
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
const entries = fixedRevisions.map((revision) => ({
|
||||||
|
id: revision.id,
|
||||||
|
base: JSON.stringify(revision.base),
|
||||||
|
deltas: JSON.stringify(revision.deltas),
|
||||||
|
hash: mj.hash({
|
||||||
|
base: revision.base,
|
||||||
|
deltas: revision.deltas,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(entries);
|
||||||
|
|
||||||
|
await knex('scenes_revisions')
|
||||||
|
.insert(entries)
|
||||||
|
.onConflict('id')
|
||||||
|
.merge(['base', 'deltas', 'hash']);
|
||||||
|
|
||||||
|
console.log(`Fixed ${entries.length} revisions`);
|
||||||
|
|
||||||
|
await knex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
7
utils/filter-title.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function filterTitle(title, keys) {
|
||||||
|
if (!title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.reduce((accTitle, tag) => accTitle.replace(new RegExp(`\\b${tag.replace(/[^\w\s]+/g, '')}\\b`, 'gi'), ''), title).trim().replace(/\s{2,}/, ' ');
|
||||||
|
}
|
||||||
27
utils/verify-ability.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { HttpError } from '../src/errors.js';
|
||||||
|
|
||||||
|
function checkAbility(user, subject, action) {
|
||||||
|
if (!user?.abilities) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject && action) {
|
||||||
|
return user.abilities.some((ability) => ability.subject === subject && ability.action === action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject) {
|
||||||
|
return user.abilities.some((ability) => ability[subject] === true || (ability.subject === subject && !ability.action));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function verifyAbility(user, subject, action, options = {}) {
|
||||||
|
const isAble = checkAbility(user, subject, action);
|
||||||
|
|
||||||
|
if (!isAble && options.throwError) {
|
||||||
|
throw new HttpError(`Insufficient privileges for ${[subject, action].filter(Boolean).join()}`, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAble;
|
||||||
|
}
|
||||||