Compare commits
50
Commits
4c48700086
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ac366e87 | ||
|
|
a5e06b51ea | ||
|
|
b3dcb65871 | ||
|
|
4527bf2495 | ||
|
|
6c0e4d5456 | ||
|
|
8dd136446d | ||
|
|
1cc94d665a | ||
|
|
a9d9b7623d | ||
|
|
83c0f1e4c6 | ||
|
|
64b92536d5 | ||
|
|
cea5b98bda | ||
|
|
411cde7cf9 | ||
|
|
73d65ae873 | ||
|
|
b5e9886b9b | ||
|
|
cd10f14285 | ||
|
|
514ed235a4 | ||
|
|
00c6d17aaa | ||
|
|
de0b1c2baf | ||
|
|
e9b7bb03d0 | ||
|
|
1e7207160c | ||
|
|
37a919770a | ||
|
|
4e59994806 | ||
|
|
699f7f0944 | ||
|
|
bf220f6b4e | ||
|
|
02ee99d52f | ||
|
|
e50dedca66 | ||
|
|
760025f414 | ||
|
|
07d9dfaaaa | ||
|
|
d3f549c92f | ||
|
|
bf64a3d30e | ||
|
|
b27c4811da | ||
|
|
c462c2b11a | ||
|
|
a774254e71 | ||
|
|
0a665ab553 | ||
|
|
c2d8cc3a40 | ||
|
|
eed07ba84b | ||
|
|
e8f1dca2a0 | ||
|
|
4037098927 | ||
|
|
e007dd2b83 | ||
|
|
b86cf8e30f | ||
|
|
e46f174a1e | ||
|
|
32b8f00308 | ||
|
|
c9e698c906 | ||
|
|
286248e403 | ||
|
|
f9e03f47ba | ||
|
|
b54174eb5e | ||
|
|
62d19d1a04 | ||
|
|
6b3866a99f | ||
|
|
18c30b6f98 | ||
|
|
77b27a56f7 |
@@ -8,3 +8,5 @@
|
||||
@custom-media --small (max-width: 900px);
|
||||
@custom-media --compact (max-width: 1200px);
|
||||
@custom-media --big (max-width: 1500px);
|
||||
@custom-media --big-10 (max-width: 1750px);
|
||||
@custom-media --big-20 (max-width: 2000px);
|
||||
|
||||
+15
-3
@@ -1,7 +1,7 @@
|
||||
/* Content */
|
||||
|
||||
.v-popper__popper {
|
||||
z-index: 10000;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
outline: none;
|
||||
@@ -45,6 +45,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-outer-scroll .v-popper__inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-popper__inner > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -82,6 +86,10 @@
|
||||
border-width: 6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-container {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
|
||||
left: -2px;
|
||||
@@ -105,7 +113,7 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
|
||||
top: 0;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner,
|
||||
@@ -151,7 +159,11 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-container {
|
||||
right: -10px;
|
||||
right: -7px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-container {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M15.89 10.188l-4-5c-0.172-0.216-0.487-0.251-0.703-0.078s-0.251 0.487-0.078 0.703l3.89 4.863v0.325h-3.5l-2 2h-3l-2-2h-3.5v-0.325l3.89-4.863c0.173-0.216 0.138-0.53-0.078-0.703s-0.53-0.138-0.703 0.078l-4 5c-0.071 0.089-0.11 0.199-0.11 0.312v4.5c0 0.552 0.448 1 1 1h14c0.552 0 1-0.448 1-1v-4.5c0-0.114-0.039-0.224-0.11-0.312z"></path>
|
||||
<path d="M8 0.5l-3.5 3.5h2.5v5h2v-5h2.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 7.5292969 L 11.556641 6.46875 A 0.14617623 0.14617623 0 0 0 11.556641 6.4667969 C 11.601003 6.4299705 11.650097 6.3991617 11.701172 6.375 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="M 16,5 C 16,2.243 13.757,0 11,0 8.243,0 6,2.243 6,5 V 5.5 C 6,5.776 6.224,6 6.5,6 6.776,6 7,5.776 7,5.5 V 5 c 0,-2.206 1.794,-4 4,-4 2.206,0 4,1.794 4,4 0,1.86 -1.277,3.428 -3,3.873 V 7 L 9,9.5 12,12 V 9.899 C 14.279,9.434 16,7.414 16,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 6.3671875 C 10.340222 6.3923929 10.394355 6.4261166 10.443359 6.4667969 A 0.14617623 0.14617623 0 0 1 10.443359 6.46875 L 11.701172 7.5175781 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="m 6,5 c 0,-2.757 2.243,-5 5,-5 2.757,0 5,2.243 5,5 V 5.5 C 16,5.776 15.776,6 15.5,6 15.224,6 15,5.776 15,5.5 V 5 C 15,2.794 13.206,1 11,1 8.794,1 7,2.794 7,5 7,6.86 8.277,8.428 10,8.873 V 7 L 13,9.5 10,12 V 9.899 C 7.721,9.434 6,7.414 6,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 14.334849,4.1517112 H 11.883382 V 1.785868 c 0,-0.1187227 -0.09983,-0.2150766 -0.222859,-0.2150766 h -1.337165 c -0.12302,0 -0.222861,0.096353 -0.222861,0.2150766 V 4.1517112 H 7.6490291 c -0.1230181,0 -0.2228598,0.096353 -0.2228598,0.2150768 v 1.2904597 c 0,0.1187229 0.099841,0.2150767 0.2228598,0.2150767 h 2.4514679 v 2.365843 c 0,0.1187227 0.09983,0.2150768 0.222861,0.2150768 h 1.337165 c 0.123019,0 0.222859,-0.096353 0.222859,-0.2150768 v -2.365843 h 2.451467 c 0.12302,0 0.22286,-0.096355 0.22286,-0.2150767 V 4.366788 c 0,-0.1187229 -0.09983,-0.2150768 -0.22286,-0.2150768 z"
|
||||
id="path1-3"
|
||||
style="stroke-width:0.437868" /></svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
sodipodi:docname="sort-heart.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="26.077075"
|
||||
inkscape:cx="4.0265253"
|
||||
inkscape:cy="4.640091"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1020"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3"
|
||||
showguides="true" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 13.78723,0 C 12.941692,0 12.207573,0.47431375 11.834786,1.1709458 11.461999,0.47431375 10.72788,0 9.8823418,0 c -1.2219697,0 -2.21277,0.99080032 -2.21277,2.2127699 0,2.9937477 4.1652142,5.0763547 4.1652142,5.0763547 0,0 4.165214,-2.082607 4.165214,-5.0763547 C 16,0.99080032 15.0092,0 13.78723,0 Z"
|
||||
id="path1-5"
|
||||
style="stroke-width:0.520651" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+1
-1
Submodule common updated: ec0812ad9d...7df69021ea
@@ -303,6 +303,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
display: grid;
|
||||
flex-grow: 1;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ async function addActor() {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
|
||||
+27
-13
@@ -77,12 +77,15 @@ function getSocialUrl(social) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
const socials = props.actor.socials
|
||||
.filter((social) => social.platform)
|
||||
.slice(0, 10)
|
||||
.map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
|
||||
const aliases = Object
|
||||
.entries(props.actor.aliases.reduce((acc, alias) => {
|
||||
@@ -132,13 +135,18 @@ const aliases = Object
|
||||
<dfn class="bio-label"><Icon icon="cake" />Date of birth</dfn>
|
||||
|
||||
<span class="birthdate">
|
||||
<span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span>
|
||||
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span>
|
||||
<template v-if="actor.dateOfBirthPrecision === 'day'">
|
||||
<span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span>
|
||||
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span>
|
||||
</template>
|
||||
|
||||
<span v-else-if="actor.dateOfBirthPrecision === 'month'">{{ formatDate(actor.dateOfBirth, 'MMMM, yyyy') }}</span>
|
||||
<span v-else-if="actor.dateOfBirth.getFullYear() > 1">{{ formatDate(actor.dateOfBirth, 'yyyy') }}</span>
|
||||
|
||||
<span
|
||||
v-if="!actor.dateOfDeath && actor.dateOfBirth.getFullYear() > 1"
|
||||
class="age"
|
||||
>{{ actor.ageFromBirth }}</span>
|
||||
><template v-if="actor.ageFromBirthIsApproximate">~</template>{{ actor.ageFromBirth }}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -160,10 +168,16 @@ const aliases = Object
|
||||
>
|
||||
<dfn class="bio-label"><Icon icon="tombstone" />Date of death</dfn>
|
||||
|
||||
<span class="birthdate">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}<span
|
||||
v-if="actor.ageAtDeath"
|
||||
class="age"
|
||||
>{{ actor.ageAtDeath }}</span></span>
|
||||
<span class="birthdate">
|
||||
<span v-if="actor.dateOfDeathPrecision === 'day'">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}</span>
|
||||
<span v-else-if="actor.dateOfDeathPrecision === 'month'">{{ formatDate(actor.dateOfDeath, 'MMMM, yyyy') }}</span>
|
||||
<span v-else>{{ formatDate(actor.dateOfDeath, 'yyyy') }}</span>
|
||||
|
||||
<span
|
||||
v-if="actor.ageAtDeath"
|
||||
class="age"
|
||||
><template v-if="actor.ageAtDeathIsApproximate">~</template>{{ actor.ageAtDeath }}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li
|
||||
|
||||
@@ -52,7 +52,8 @@ function getActorNames() {
|
||||
async function merge() {
|
||||
submitted.value = true;
|
||||
|
||||
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, {
|
||||
await post(`/actors/${targetActor.value.id}/merge`, {
|
||||
actorIds: props.actors.map((actor) => actor.id),
|
||||
comment: comment.value || null,
|
||||
}, {
|
||||
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
|
||||
@@ -147,6 +148,7 @@ onMounted(() => {
|
||||
:triggers="[]"
|
||||
:shown="actorResults.length > 0"
|
||||
:auto-hide="false"
|
||||
placement="auto"
|
||||
>
|
||||
<input
|
||||
ref="actorInput"
|
||||
|
||||
@@ -37,17 +37,14 @@ const {
|
||||
|
||||
const actors = ref(props.presetActors);
|
||||
const actorResults = ref([]);
|
||||
const actorInput = ref(null);
|
||||
const actorQuery = ref('');
|
||||
|
||||
const entities = ref(props.presetEntities);
|
||||
const entityResults = ref([]);
|
||||
const entityInput = ref(null);
|
||||
const entityQuery = ref('');
|
||||
|
||||
const tags = ref(props.presetTags);
|
||||
const tagResults = ref([]);
|
||||
const tagInput = ref(null);
|
||||
const tagQuery = ref('');
|
||||
|
||||
const scenes = ref(props.presetScenes);
|
||||
@@ -61,6 +58,8 @@ const actorAnd = ref(true);
|
||||
const tagAnd = ref(true);
|
||||
const matchAnd = ref(true);
|
||||
|
||||
const inputs = {};
|
||||
|
||||
const fieldsLogicTooltipId = useId();
|
||||
const addActorDropdownId = useId();
|
||||
const addTagDropdownId = useId();
|
||||
@@ -132,9 +131,9 @@ async function searchTags() {
|
||||
tagResults.value = res;
|
||||
}
|
||||
|
||||
function focusActorInput() {
|
||||
function focusInput(target) {
|
||||
setTimeout(() => {
|
||||
actorInput.value.focus();
|
||||
inputs[target]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -263,7 +262,8 @@ function rememberThen(domain, checked) {
|
||||
<li class="field-add">
|
||||
<VDropdown
|
||||
:aria-id="addActorDropdownId"
|
||||
@show="focusActorInput"
|
||||
placement="auto"
|
||||
@show="focusInput('actor')"
|
||||
>
|
||||
<button
|
||||
class="button"
|
||||
@@ -272,7 +272,7 @@ function rememberThen(domain, checked) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="actorInput"
|
||||
:ref="(el) => inputs.actor = el"
|
||||
v-model="actorQuery"
|
||||
class="input"
|
||||
@input="searchActors"
|
||||
@@ -339,7 +339,11 @@ function rememberThen(domain, checked) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addTagDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addTagDropdownId"
|
||||
@show="focusInput('tag')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -347,7 +351,7 @@ function rememberThen(domain, checked) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="tagInput"
|
||||
:ref="(el) => inputs.tag = el"
|
||||
v-model="tagQuery"
|
||||
class="input"
|
||||
@input="searchTags"
|
||||
@@ -403,7 +407,11 @@ function rememberThen(domain, checked) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addEntityDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addEntityDropdownId"
|
||||
@show="focusInput('entity')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -411,7 +419,7 @@ function rememberThen(domain, checked) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="entityInput"
|
||||
:ref="(el) => inputs.entity = el"
|
||||
v-model="entityQuery"
|
||||
class="input"
|
||||
@input="searchEntities"
|
||||
@@ -465,7 +473,10 @@ function rememberThen(domain, checked) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addMatchDropdownId">
|
||||
<VDropdown
|
||||
:aria-id="addMatchDropdownId"
|
||||
@show="focusInput('expression')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -482,6 +493,7 @@ function rememberThen(domain, checked) {
|
||||
</select>
|
||||
|
||||
<input
|
||||
:ref="(el) => inputs.expression = el"
|
||||
v-model="matchExpression"
|
||||
placeholder="Expression, // for regex"
|
||||
class="input"
|
||||
|
||||
@@ -5,8 +5,10 @@ import { computed, inject, ref } from 'vue';
|
||||
import Avatar from '#/components/edit/avatar.vue';
|
||||
import Socials from '#/components/edit/revision-socials.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import Pagination from '#/components/pagination/pagination.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { formatDuration } from '#/utils/format.js';
|
||||
|
||||
defineProps({
|
||||
@@ -31,10 +33,14 @@ const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) =>
|
||||
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
|
||||
|
||||
const feedbacks = ref({});
|
||||
const showReviewed = ref(false);
|
||||
const showReviewed = ref(Object.hasOwn(pageContext.urlParsed.search, 'final'));
|
||||
const reviewedRevisions = ref(new Set());
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const total = ref(pageContext.pageProps.revisionTotal);
|
||||
const page = Number(pageContext.routeParams.page) || 1;
|
||||
const limit = Number(pageContext.urlParsed.search.limit) || 100;
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
// tags: tagsById,
|
||||
@@ -163,7 +169,8 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
async function reloadRevisions() {
|
||||
const updatedRevisions = await get(`/revisions/${domain}`, {
|
||||
isFinalized: showReviewed.value ? undefined : false,
|
||||
limit: 50,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
actors.value = updatedRevisions.actors;
|
||||
@@ -171,6 +178,14 @@ async function reloadRevisions() {
|
||||
movies.value = updatedRevisions.movies;
|
||||
avatars.value = updatedRevisions.avatars;
|
||||
revisions.value = updatedRevisions.revisions;
|
||||
total.value = updatedRevisions.total;
|
||||
|
||||
navigate(pageContext.urlParsed.pathname, {
|
||||
...pageContext.urlParsed.search,
|
||||
final: showReviewed.value || undefined,
|
||||
}, {
|
||||
redirect: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewRevision(revision, isApproved) {
|
||||
@@ -208,10 +223,15 @@ async function banEditor(revision) {
|
||||
<template>
|
||||
<div class="page">
|
||||
<div
|
||||
v-if="context === 'admin'"
|
||||
class="revs-header"
|
||||
>
|
||||
<span
|
||||
v-if="typeof total === 'number'"
|
||||
class="revs-total"
|
||||
>{{ total }} revisions</span>
|
||||
|
||||
<Checkbox
|
||||
v-if="context === 'admin'"
|
||||
label="Show finalized"
|
||||
:checked="showReviewed"
|
||||
@change="(checked) => { showReviewed = checked; reloadRevisions(); }"
|
||||
@@ -388,13 +408,12 @@ async function banEditor(revision) {
|
||||
class="rev-compact"
|
||||
@click="expanded.add(rev.id)"
|
||||
>
|
||||
|
||||
<span class="rev-scene nolink noshrink"><template v-if="context !== 'scene' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span>
|
||||
|
||||
<span
|
||||
v-if="context !== 'scene'"
|
||||
class="rev-title nolink ellipsis"
|
||||
>{{ rev.base.title }}</span>
|
||||
>{{ rev.base.title || rev.base.name }}</span>
|
||||
|
||||
<span class="rev-summary">
|
||||
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
|
||||
@@ -431,6 +450,13 @@ async function banEditor(revision) {
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
v-if="total"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -443,13 +469,20 @@ async function banEditor(revision) {
|
||||
|
||||
.revs-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .5rem .75rem .5rem;
|
||||
width: 100%;
|
||||
|
||||
.check-container {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.revs-total {
|
||||
color: var(--glass-strong-10);
|
||||
}
|
||||
|
||||
.revs {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -49,7 +49,7 @@ const mockUser = {
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Your traxxx password has been changed.</Preview>
|
||||
<Preview>Your traxxx password has been changed</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
|
||||
@@ -42,7 +42,7 @@ const mockUser = {
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Recovering your traxxx password.</Preview>
|
||||
<Preview>Reset your traxxx password</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'change',
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Sign up for traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi,</Heading>
|
||||
|
||||
<Text style="font-size: 16px;">
|
||||
<strong>Thank you for signing up for traxxx!</strong>
|
||||
<br>
|
||||
Please complete your registration by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Create my account</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">The link will expire in {{ config.auth.signupTokenExpiry }} minutes. If you did not sign up, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Someone tried to use this e-mail address for traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Someone tried to assign this e-mail address to a traxxx account.</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">If this was you with this account ({{ user.username }}), your e-mail address is already set correctly. If this was you with another account, you will need to use another e-mail address. If this was not you, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -46,7 +46,7 @@ const mockUser = {
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Please verify your traxxx e-mail address.</Preview>
|
||||
<Preview>Please verify your traxxx e-mail address</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
@@ -62,8 +62,7 @@ const mockUser = {
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Text>
|
||||
<strong v-if="trigger === 'signup'">Thank you for creating your traxxx account!</strong>
|
||||
<strong v-else>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||
<strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||
<br>
|
||||
Please confirm your e-mail address by clicking the button below:
|
||||
</Text>
|
||||
@@ -75,10 +74,7 @@ const mockUser = {
|
||||
><strong>Verify e-mail address</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification.
|
||||
<template v-if="trigger === 'signup'">If you did not sign up, you can safely ignore this e-mail.</template>
|
||||
<template v-else>If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</template>
|
||||
</Text>
|
||||
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification. If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
|
||||
@@ -279,7 +279,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
.movies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
redirect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
||||
@@ -370,6 +370,7 @@ function setView(newView) {
|
||||
<Campaign
|
||||
:campaign="sceneCampaign"
|
||||
:backdrop="true"
|
||||
class="campaign-scene"
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -447,6 +448,7 @@ function setView(newView) {
|
||||
.scenes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
@@ -456,6 +458,11 @@ function setView(newView) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.campaign-scene {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1; /* ensures banner can't be taller than scene tile */
|
||||
}
|
||||
|
||||
.scopes {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -10,7 +10,10 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="serie-tile">
|
||||
<div
|
||||
class="serie-tile"
|
||||
:class="{ noposter: !serie.poster }"
|
||||
>
|
||||
<a
|
||||
v-if="serie.poster"
|
||||
:href="`/serie/${serie.id}/${serie.slug}`"
|
||||
@@ -39,8 +42,8 @@ defineProps({
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
background: var(--background-base);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||
@@ -53,6 +56,19 @@ defineProps({
|
||||
&.unstashed {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&.noposter {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
|
||||
.title {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poster-container {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
// import Checkbox from '#/components/form/checkbox.vue';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ const itemAlerts = ref(props.item.alerts);
|
||||
const itemAlerted = ref(props.item.alerts?.only.length > 0);
|
||||
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
|
||||
|
||||
// console.log(props.item);
|
||||
|
||||
const done = ref(true);
|
||||
const stashMenuDropdownId = useId();
|
||||
const stashTogglesDropdownId = useId();
|
||||
@@ -248,6 +246,8 @@ async function reloadStashes(newStash) {
|
||||
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
||||
:aria-id="stashMenuDropdownId"
|
||||
:shown="showStashes"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<Icon
|
||||
@@ -261,6 +261,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
@@ -274,6 +275,8 @@ async function reloadStashes(newStash) {
|
||||
:triggers="[]"
|
||||
:shown="showStashes"
|
||||
:disabled="showSecondary"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<template v-if="pageStash?.user.id === user.id">
|
||||
@@ -316,6 +319,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
|
||||
+135
-20
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
stashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -10,46 +12,141 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref(localStorage.getItem('stashMenuSorting') || 'used');
|
||||
|
||||
const domainKey = {
|
||||
actors: 'stashedActors',
|
||||
scenes: 'stashedScenes',
|
||||
movies: 'stashedMovies',
|
||||
}[props.domain];
|
||||
|
||||
const sortedStashes = computed(() => props.stashes
|
||||
.filter((stash) => !stash.isArchived)
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return stashB[domainKey] - stashA[domainKey];
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}));
|
||||
/*
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
*/
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
localStorage.setItem('stashMenuSorting', sorting.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="stash-menu nolist noselect">
|
||||
<li
|
||||
v-for="userStash in stashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
<div class="stash-menu noselect">
|
||||
<ul class="stash-list nolist noselect">
|
||||
<li
|
||||
v-for="userStash in sortedStashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<li class="menu-item create">
|
||||
<label
|
||||
<div class="menu-actions">
|
||||
<div
|
||||
v-close-popper
|
||||
class="menu-stash"
|
||||
class="menu-item menu-stash menu-create"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<Icon icon="plus3" />
|
||||
New
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="`By total stashed ${domain}`"
|
||||
icon="sort-amount-desc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stash-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stash-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* lets this flex child actually shrink and scroll instead of pushing the container taller */
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
fill: var(--shadow-weak-10);
|
||||
padding: 0 .55rem 0 .2rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +155,7 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: .5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
@@ -72,4 +170,21 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
border-top: solid 1px var(--glass-weak-40);
|
||||
}
|
||||
|
||||
.menu-sort {
|
||||
padding: .75rem 1rem;
|
||||
fill: var(--glass);
|
||||
border-left: solid 1px var(--glass-weak-40);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import StashTile from '#/components/stashes/tile.vue';
|
||||
@@ -13,16 +13,55 @@ const stashes = ref(pageContext.pageProps.stashes);
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
|
||||
const showStashDialog = ref(false);
|
||||
const showArchived = ref(false);
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref('size');
|
||||
|
||||
async function reloadStashes() {
|
||||
// profile.value = await get(`/users/${profile.value.id}`);
|
||||
stashes.value = await get(`/users/${profile.value.id}/stashes`);
|
||||
}
|
||||
|
||||
const sortedStashes = computed(() => stashes.value
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return (stashB.stashedScenes + stashB.stashedMovies + stashB.stashedActors)
|
||||
- (stashA.stashedScenes + stashA.stashedMovies + stashA.stashedActors);
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
// always put favorites first
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
|
||||
const activeStashes = computed(() => sortedStashes.value.filter((stash) => !stash.isArchived));
|
||||
const archivedStashes = computed(() => sortedStashes.value.filter((stash) => stash.isArchived));
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<div class="section-header noselect">
|
||||
<button
|
||||
v-if="profile.id === user?.id"
|
||||
class="button"
|
||||
@@ -31,6 +70,38 @@ async function reloadStashes() {
|
||||
<Icon icon="folder-plus2" />
|
||||
<span class="button-label">New stash</span>
|
||||
</button>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="'By total stashed'"
|
||||
icon="sort-amount-desc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StashDialog
|
||||
@@ -41,7 +112,29 @@ async function reloadStashes() {
|
||||
|
||||
<ul class="stashes nolist">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
v-for="stash in activeStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
:stash="stash"
|
||||
:profile="profile"
|
||||
@reload="reloadStashes"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3
|
||||
v-if="archivedStashes.length > 0"
|
||||
class="archived-heading"
|
||||
@click="showArchived = !showArchived"
|
||||
><Icon icon="drawer3" />{{ showArchived ? 'Hide' : `Show ${archivedStashes.length}` }} archived {{ archivedStashes.length > 1 ? 'stashes' : 'stash' }}</h3>
|
||||
|
||||
<ul
|
||||
v-if="showArchived"
|
||||
class="stashes nolist"
|
||||
>
|
||||
<li
|
||||
v-for="stash in archivedStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
@@ -59,6 +152,7 @@ async function reloadStashes() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: .5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stashes {
|
||||
@@ -68,6 +162,30 @@ async function reloadStashes() {
|
||||
padding: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
padding: .5rem 1rem;
|
||||
fill: var(--glass);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.archived-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
color: var(--glass);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
margin-right: .75rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(--compact) {
|
||||
.stashes {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
@@ -91,6 +91,30 @@ async function rename() {
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
|
||||
async function archive(isArchived = true) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { isArchived }, {
|
||||
...isArchived
|
||||
? {
|
||||
undoFeedback: `Stash archived`,
|
||||
}
|
||||
: {
|
||||
successFeedback: `Stash revived`,
|
||||
},
|
||||
errorFeedback: `Failed to ${isArchived ? 'archive' : 'revive'} stash`,
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -159,6 +183,28 @@ async function rename() {
|
||||
/>Rename
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="stash.isArchived"
|
||||
class="menu-item"
|
||||
@click="archive(false)"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-out"
|
||||
class="noselect"
|
||||
/>Revive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
class="menu-item"
|
||||
@click="archive()"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-in"
|
||||
class="noselect"
|
||||
/>Archive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="!stash.isPrimary"
|
||||
class="menu-item remove"
|
||||
@@ -306,6 +352,7 @@ async function rename() {
|
||||
display: flex;
|
||||
margin-right: .75rem;
|
||||
fill: var(--glass);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.remove {
|
||||
|
||||
@@ -23,6 +23,7 @@ const saving = ref(false);
|
||||
const submitted = ref(false);
|
||||
const verifying = ref(false);
|
||||
const verified = ref(false);
|
||||
const feedback = ref(null);
|
||||
|
||||
function getFeedback() {
|
||||
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
||||
@@ -38,18 +39,26 @@ function getFeedback() {
|
||||
|
||||
async function updateAccount() {
|
||||
saving.value = true;
|
||||
feedback.value = null;
|
||||
|
||||
try {
|
||||
const feedback = getFeedback();
|
||||
|
||||
await patch('/me', {
|
||||
credentials,
|
||||
password: currentPassword.value,
|
||||
}, {
|
||||
successFeedback: getFeedback(),
|
||||
successFeedback: feedback,
|
||||
errorFeedback: 'Failed to update account',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
currentPassword.value = null;
|
||||
credentials.email = null;
|
||||
credentials.password = null;
|
||||
|
||||
submitted.value = true;
|
||||
feedback.value = feedback;
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
@@ -171,7 +180,7 @@ async function verifyEmail() {
|
||||
<strong
|
||||
v-else-if="submitted"
|
||||
class="success"
|
||||
>{{ getFeedback() }}</strong>
|
||||
>{{ feedback }}</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
|
||||
@@ -161,6 +161,7 @@ module.exports = {
|
||||
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||
emailTokenExpiry: 120, // minutes
|
||||
passwordTokenExpiry: 120, // minutes
|
||||
signupTokenExpiry: 120, // minutes
|
||||
},
|
||||
bans: {
|
||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||
@@ -205,6 +206,7 @@ module.exports = {
|
||||
crontab: '*/10 * * * *', // every 10 minutes
|
||||
},
|
||||
alerts: {
|
||||
enabled: true,
|
||||
crontab: '*/10 * * * *', // every 10 minutes
|
||||
seenExpiry: 30, // days
|
||||
finalExpiry: 365, // days
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
@@ -18,11 +19,21 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: { 'import-x': importX },
|
||||
languageOptions: {
|
||||
globals: {
|
||||
CONFIG: 'readonly',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// mirrors the '#'/'#root' aliases in vite.config.js, which is what actually resolves these at build/runtime
|
||||
'import-x/resolver-next': [createNodeResolver({
|
||||
alias: {
|
||||
'#': [import.meta.dirname],
|
||||
'#root': [import.meta.dirname],
|
||||
},
|
||||
})],
|
||||
},
|
||||
rules: {
|
||||
'default-param-last': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
@@ -33,6 +44,7 @@ export default antfu(
|
||||
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
|
||||
}],
|
||||
'prefer-destructuring': 'off',
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import/prefer-default-export': 'off',
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
args: 'after-used',
|
||||
|
||||
Generated
+467
-15
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "traxxx-web",
|
||||
"version": "0.57.0",
|
||||
"version": "0.57.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"version": "0.57.0",
|
||||
"version": "0.57.25",
|
||||
"dependencies": {
|
||||
"@brillout/json-serializer": "^0.5.25",
|
||||
"@dicebear/collection": "7.0.5",
|
||||
@@ -36,6 +36,7 @@
|
||||
"ejs": "^6.0.1",
|
||||
"error-stack-parser": "^2.1.4",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -6113,7 +6114,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
|
||||
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.63.0",
|
||||
@@ -6339,7 +6340,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
|
||||
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.63.0",
|
||||
@@ -6357,7 +6358,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
|
||||
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -6399,7 +6400,6 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
|
||||
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -6413,7 +6413,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
|
||||
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.63.0",
|
||||
@@ -6441,7 +6441,7 @@
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -6454,7 +6454,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
|
||||
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
@@ -6478,7 +6478,7 @@
|
||||
"version": "8.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
|
||||
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.63.0",
|
||||
@@ -6496,7 +6496,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
@@ -6663,6 +6663,328 @@
|
||||
"@universal-middleware/core": "^0.4.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
|
||||
"integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm64": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
|
||||
"integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
|
||||
"integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
|
||||
"integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
|
||||
"integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
|
||||
"integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
|
||||
"integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
|
||||
"integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-loong64-musl": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
|
||||
"integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
|
||||
"integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
|
||||
"integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
|
||||
"integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
|
||||
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
|
||||
"integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||
"integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
|
||||
"integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
|
||||
"integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@videojs/http-streaming": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz",
|
||||
@@ -8363,7 +8685,6 @@
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz",
|
||||
"integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
@@ -9756,6 +10077,30 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-context": {
|
||||
"version": "0.1.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
|
||||
"integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-tsconfig": "^4.10.1",
|
||||
"stable-hash-x": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-import-context"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unrs-resolver": "^1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"unrs-resolver": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
|
||||
@@ -9932,6 +10277,54 @@
|
||||
"eslint": "^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import-x": {
|
||||
"version": "4.17.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz",
|
||||
"integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"comment-parser": "^1.4.1",
|
||||
"debug": "^4.4.1",
|
||||
"eslint-import-context": "^0.1.9",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.3 || ^10.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"stable-hash-x": "^0.2.0",
|
||||
"unrs-resolver": "^1.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-plugin-import-x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/utils": "^8.56.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"eslint-import-resolver-node": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/utils": {
|
||||
"optional": true
|
||||
},
|
||||
"eslint-import-resolver-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import-x/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsdoc": {
|
||||
"version": "63.0.13",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.13.tgz",
|
||||
@@ -11318,7 +11711,6 @@
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
@@ -15871,6 +16263,21 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"napi-postinstall": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/napi-postinstall"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -17694,7 +18101,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
@@ -18478,6 +18884,15 @@
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash-x": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
|
||||
"integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
@@ -19412,7 +19827,7 @@
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
@@ -20320,6 +20735,43 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
|
||||
"integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/unrs-resolver"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@unrs/resolver-binding-android-arm-eabi": "1.12.2",
|
||||
"@unrs/resolver-binding-android-arm64": "1.12.2",
|
||||
"@unrs/resolver-binding-darwin-arm64": "1.12.2",
|
||||
"@unrs/resolver-binding-darwin-x64": "1.12.2",
|
||||
"@unrs/resolver-binding-freebsd-x64": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
|
||||
"@unrs/resolver-binding-linux-x64-musl": "1.12.2",
|
||||
"@unrs/resolver-binding-openharmony-arm64": "1.12.2",
|
||||
"@unrs/resolver-binding-wasm32-wasi": "1.12.2",
|
||||
"@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
|
||||
"@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
|
||||
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
+2
-1
@@ -37,6 +37,7 @@
|
||||
"ejs": "^6.0.1",
|
||||
"error-stack-parser": "^2.1.4",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -91,7 +92,7 @@
|
||||
"overrides": {
|
||||
"vite": "$vite"
|
||||
},
|
||||
"version": "0.57.0",
|
||||
"version": "0.57.25",
|
||||
"imports": {
|
||||
"#/*": "./*.js"
|
||||
},
|
||||
|
||||
@@ -5,11 +5,13 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
avatars,
|
||||
} = await fetchActorRevisions(null, {
|
||||
actorId: actor.id,
|
||||
isFinalized: true,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -18,6 +20,7 @@ export async function onBeforeRender(pageContext) {
|
||||
pageProps: {
|
||||
actor,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
avatars,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -52,9 +52,12 @@ const fields = computed(() => [
|
||||
key: 'dateOfBirth',
|
||||
label: 'date of birth',
|
||||
type: 'date',
|
||||
value: actor.value?.dateOfBirth
|
||||
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
||||
: null,
|
||||
value: {
|
||||
date: actor.value?.dateOfBirth
|
||||
? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
|
||||
: null,
|
||||
precision: actor.value.dateOfBirthPrecision,
|
||||
},
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
@@ -212,9 +215,12 @@ const fields = computed(() => [
|
||||
key: 'dateOfDeath',
|
||||
label: 'date of death',
|
||||
type: 'date',
|
||||
value: actor.value?.dateOfDeath
|
||||
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
||||
: null,
|
||||
value: {
|
||||
date: actor.value?.dateOfDeath
|
||||
? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
|
||||
: null,
|
||||
precision: actor.value.dateOfDeathPrecision,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'allowGlobalMatch',
|
||||
@@ -270,6 +276,14 @@ const keyMap = {
|
||||
country: 'residenceCountry',
|
||||
place: 'residencePlace',
|
||||
},
|
||||
dateOfBirth: {
|
||||
date: 'dateOfBirth',
|
||||
precision: 'dateOfBirthPrecision',
|
||||
},
|
||||
dateOfDeath: {
|
||||
date: 'dateOfDeath',
|
||||
precision: 'dateOfDeathPrecision',
|
||||
},
|
||||
tattoos: {
|
||||
has: 'hasTattoos',
|
||||
description: 'tattoos',
|
||||
@@ -501,6 +515,7 @@ async function submit() {
|
||||
>{{ item.unit }}
|
||||
</template>
|
||||
|
||||
<!--
|
||||
<input
|
||||
v-if="item.type === 'date'"
|
||||
v-model="edits[item.key]"
|
||||
@@ -508,6 +523,30 @@ async function submit() {
|
||||
class="date input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
-->
|
||||
|
||||
<div
|
||||
v-if="item.type === 'date'"
|
||||
class="date"
|
||||
>
|
||||
<input
|
||||
v-model="edits[item.key].date"
|
||||
type="date"
|
||||
class="date input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
|
||||
<select
|
||||
v-if="item.value.precision"
|
||||
v-model="edits[item.key].precision"
|
||||
class="input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="year">Year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-if="item.type === 'select'"
|
||||
|
||||
@@ -10,10 +10,12 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
avatars,
|
||||
} = await fetchActorRevisions(null, {
|
||||
isFinalized: false,
|
||||
limit: 50,
|
||||
isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -21,6 +23,7 @@ export async function onBeforeRender(pageContext) {
|
||||
title: pageContext.routeParams.section,
|
||||
pageProps: {
|
||||
revisions,
|
||||
revisionTotal,
|
||||
avatars,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/:section/:domain(actors)';
|
||||
const path = '/admin/:section/:domain(actors)/:page?';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
@@ -11,6 +11,8 @@ export default (pageContext) => {
|
||||
routeParams: {
|
||||
section: matched.params.section,
|
||||
domain: matched.params.domain,
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
isFinalized: false,
|
||||
limit: 50,
|
||||
isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -23,6 +25,7 @@ export async function onBeforeRender(pageContext) {
|
||||
title: pageContext.routeParams.section,
|
||||
pageProps: {
|
||||
revisions,
|
||||
revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/:section/:domain(scenes)';
|
||||
const path = '/admin/:section/:domain(scenes)/:page?';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
@@ -11,6 +11,8 @@ export default (pageContext) => {
|
||||
routeParams: {
|
||||
section: matched.params.section,
|
||||
domain: matched.params.domain,
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,4 +191,8 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+91
-31
@@ -3,32 +3,29 @@ import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, env } = pageContext;
|
||||
const { token } = pageContext.urlParsed.search;
|
||||
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const passwordConfirm = ref('');
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitted = ref(false);
|
||||
const userInput = ref(null);
|
||||
const success = ref(false);
|
||||
const mainInput = ref(null);
|
||||
const captcha = ref(null);
|
||||
|
||||
async function signup() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
errorMsg.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.captcha.enabled && !captcha.value) {
|
||||
errorMsg.value = 'Please complete the CAPTCHA';
|
||||
return;
|
||||
@@ -36,11 +33,14 @@ async function signup() {
|
||||
|
||||
try {
|
||||
const newUser = await post('/users', {
|
||||
token,
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
redirect: pageContext.urlParsed.search.r,
|
||||
captcha: captcha.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up successful!',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
||||
@@ -53,8 +53,30 @@ async function signup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function request() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
try {
|
||||
await post('/signups', {
|
||||
email: email.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up requested, please check your inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userInput.value.focus();
|
||||
mainInput.value.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -88,7 +110,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
v-else-if="token"
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="signup"
|
||||
@@ -99,34 +121,19 @@ onMounted(() => {
|
||||
>{{ errorMsg }}</div>
|
||||
|
||||
<input
|
||||
ref="userInput"
|
||||
ref="mainInput"
|
||||
v-model="username"
|
||||
placeholder="Username"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<Password
|
||||
:password="password"
|
||||
autocomplete="new-password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<Password
|
||||
:password="passwordConfirm"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
@input="(newPassword) => passwordConfirm = newPassword"
|
||||
/>
|
||||
|
||||
<VueHCaptcha
|
||||
v-if="env.captcha.enabled"
|
||||
:sitekey="env.captcha.siteKey"
|
||||
@@ -135,15 +142,53 @@ onMounted(() => {
|
||||
@expired="captcha = null"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Sign up</button>
|
||||
</form>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>I already have an account</a>
|
||||
<form
|
||||
v-else
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<span
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Thank you for signing up!</strong>
|
||||
Please check your e-mail inbox to continue.
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
ref="mainInput"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Send signup e-mail</button>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link login"
|
||||
>I already have an account</a>
|
||||
</template>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -179,7 +224,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: .5rem;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -221,6 +266,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@@ -234,4 +280,18 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ export async function onBeforeRender(pageContext) {
|
||||
const networks = await fetchEntities(pageContext.urlParsed.search.q
|
||||
? { query: pageContext.urlParsed.search.q }
|
||||
: { type: 'primary' }, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function onBeforeRender(pageContext) {
|
||||
entityReleases,
|
||||
] = await Promise.all([
|
||||
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
fetchReleases(pageContext, entityId),
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 50,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@ function getTitle(movie) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [[movie], movieScenes] = await Promise.all([
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.restriction }),
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchScenes(await curateScenesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: 'oldest',
|
||||
@@ -28,7 +28,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function onBeforeRender(pageContext) {
|
||||
aggregate: true,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function onBeforeRender(pageContext) {
|
||||
includeAssets: true,
|
||||
includePartOf: true,
|
||||
actorStashes: true,
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
if (!scene) {
|
||||
|
||||
@@ -10,13 +10,15 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
sceneId: scene.id,
|
||||
isFinalized: true,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
@@ -25,6 +27,7 @@ export async function onBeforeRender(pageContext) {
|
||||
pageProps: {
|
||||
scene,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
|
||||
@@ -16,24 +16,24 @@ export async function onBeforeRender(pageContext) {
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 15,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchActors(curateActorsQuery(pageContext.urlQuery), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 10,
|
||||
order: ['results', 'desc'],
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchMovies(await curateMoviesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: pageContext.routeParams.scope || 'results',
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 5,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchEntities({
|
||||
query: pageContext.urlParsed.search.q,
|
||||
limit: 5,
|
||||
}, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ onMounted(() => {
|
||||
.tags {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: 1rem .5rem;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const tagSlugs = {
|
||||
};
|
||||
|
||||
async function searchTags(pageContext) {
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.geo.restriction });
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
@@ -137,13 +137,13 @@ export async function onBeforeRender(pageContext) {
|
||||
return searchTags(pageContext);
|
||||
}
|
||||
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
const filteredTags = tags.filter((tag) => !pageContext.tagFilter.includes(tag.name) && !pageContext.tagFilter.includes(tag.slug));
|
||||
const tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
|
||||
|
||||
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [
|
||||
censor(category, pageContext.restriction),
|
||||
censor(category, pageContext.geo.restriction),
|
||||
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
|
||||
]));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const tagSlug = pageContext.routeParams.tagSlug;
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
if (tag.aliasFor) {
|
||||
throw redirect(`/tag/${tag.aliasFor}`);
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 29,
|
||||
aggregate: withQuery,
|
||||
dedupe: true,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 2.0, maxRatio: 5 },
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -20,7 +20,7 @@ const domain = pageContext.routeParams.domain;
|
||||
|
||||
const user = pageContext.user;
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
const primaryStash = pageContext.assets.primaryStash;
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
|
||||
const showFilters = ref(false);
|
||||
|
||||
|
||||
@@ -15,14 +15,16 @@ async function fetchRevisions(pageContext) {
|
||||
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'scenes') {
|
||||
return fetchSceneRevisions(null, {
|
||||
userId: pageContext.user.id,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
}
|
||||
|
||||
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors') {
|
||||
return fetchActorRevisions(null, {
|
||||
userId: pageContext.user.id,
|
||||
limit: 100,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 100,
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
}, pageContext.user);
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ async function fetchRevisions(pageContext) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [profile, alerts, userRevisions, keys] = await Promise.all([
|
||||
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
|
||||
fetchUser(pageContext.routeParams.username, { throwError: false }, pageContext.user),
|
||||
pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
|
||||
? fetchAlerts(pageContext.user)
|
||||
: [],
|
||||
@@ -47,6 +49,7 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
const {
|
||||
revisions,
|
||||
total: revisionTotal,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
@@ -63,6 +66,7 @@ export async function onBeforeRender(pageContext) {
|
||||
stashes,
|
||||
alerts,
|
||||
revisions,
|
||||
revisionTotal,
|
||||
keys,
|
||||
actors,
|
||||
tags,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
import { redirect } from 'vike/abort';
|
||||
|
||||
const path = '/user/:username/:section?/:domain?';
|
||||
const path = '/user/:username/:section?/:domain?/:page?';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
@@ -18,6 +18,8 @@ export default (pageContext) => {
|
||||
username: matched.params.username,
|
||||
section: matched.params.section || '',
|
||||
domain: matched.params.domain || '',
|
||||
page: matched.params.page || '1',
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ export default {
|
||||
'assets',
|
||||
'campaigns',
|
||||
'meta',
|
||||
'restriction',
|
||||
'geo',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ async function onRenderHtml(pageContext) {
|
||||
<meta name="theme-color" content="#f65596">
|
||||
|
||||
<meta property="og:title" content="traxxx" />
|
||||
<meta property="og:image" content="https://traxxx.me/img/og_logo.png" />
|
||||
<meta property="og:description" content="${pageContext.description || 'Never miss new adult videos with traxxx'}" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
||||
|
||||
|
||||
+8
-1
@@ -32,7 +32,14 @@ function createApp(Page, pageProps, pageContext) {
|
||||
|
||||
app.provide('pageContext', pageContext);
|
||||
|
||||
app.use(FloatingVue, { container: '#container' });
|
||||
app.use(FloatingVue, {
|
||||
container: '#container',
|
||||
autoBoundaryMaxSize: true,
|
||||
shiftCrossAxis: true,
|
||||
overflowPadding: 10,
|
||||
arrowPadding: 10,
|
||||
});
|
||||
|
||||
app.use(VueVirtualScroller);
|
||||
|
||||
app.component('Link', Link);
|
||||
|
||||
@@ -189,7 +189,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -32,7 +32,9 @@ const mj = new MerkleJson();
|
||||
const keyMap = {
|
||||
avatar: 'avatar_media_id',
|
||||
dateOfBirth: 'date_of_birth',
|
||||
dateOfBirthPrecision: 'date_of_birth_precision',
|
||||
dateOfDeath: 'date_of_death',
|
||||
dateOfDeathPrecision: 'date_of_death_precision',
|
||||
originCountry: 'birth_country_alpha2',
|
||||
originState: 'birth_state',
|
||||
originCity: 'birth_city',
|
||||
@@ -62,6 +64,24 @@ const keyMap = {
|
||||
|
||||
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
|
||||
|
||||
// with month (but not day) precision on either side, the age is still certain unless both dates fall in the same month,
|
||||
// since only then could the unknown day(s) put the birthday on either side of the reference date
|
||||
function isAgeApproximate(baseDate, basePrecision, referenceDate, referencePrecision = 'day') {
|
||||
if (!baseDate || !referenceDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basePrecision === 'year' || referencePrecision === 'year') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (basePrecision === 'day' && referencePrecision === 'day') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return baseDate.getMonth() === referenceDate.getMonth();
|
||||
}
|
||||
|
||||
export function curateActor(actor, context = {}) {
|
||||
if (!actor) {
|
||||
return null;
|
||||
@@ -97,14 +117,20 @@ export function curateActor(actor, context = {}) {
|
||||
ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(Date.now(), actor.date_of_birth)
|
||||
: null,
|
||||
ageFromBirthIsApproximate: actor.date_of_birth_precision !== 'day'
|
||||
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, new Date()),
|
||||
ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(actor.date_of_death, actor.date_of_birth)
|
||||
: null,
|
||||
ageAtDeathIsApproximate: (actor.date_of_birth_precision !== 'day' || actor.date_of_death_precision !== 'day')
|
||||
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, actor.date_of_death, actor.date_of_death_precision),
|
||||
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
|
||||
? differenceInYears(context.sceneDate, actor.date_of_birth)
|
||||
: null,
|
||||
dateOfBirth: actor.date_of_birth,
|
||||
dateOfBirthPrecision: actor.date_of_birth_precision,
|
||||
dateOfDeath: actor.date_of_death,
|
||||
dateOfDeathPrecision: actor.date_of_death_precision,
|
||||
bust: actor.bust,
|
||||
cup: actor.cup,
|
||||
waist: actor.waist,
|
||||
@@ -867,7 +893,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
|
||||
.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),
|
||||
@@ -1060,6 +1085,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
||||
'actors_revisions.*',
|
||||
'users.username as username',
|
||||
'reviewers.username as reviewer_username',
|
||||
knex.raw('count(*) over() as total'),
|
||||
)
|
||||
.leftJoin('users', 'users.id', 'actors_revisions.user_id')
|
||||
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
|
||||
@@ -1106,6 +1132,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
|
||||
return {
|
||||
revisions: curatedRevisions,
|
||||
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
|
||||
total: Number(revisions[0]?.total) || 0,
|
||||
avatars,
|
||||
};
|
||||
}
|
||||
@@ -1184,7 +1211,9 @@ async function applyActorRevision(revisionIds, reqUser) {
|
||||
'gender',
|
||||
'avatar',
|
||||
'dateOfBirth',
|
||||
'dateOfBirthPrecision',
|
||||
'dateOfDeath',
|
||||
'dateOfDeathPrecision',
|
||||
'originCountry',
|
||||
'originState',
|
||||
'originCity',
|
||||
|
||||
+2
-1
@@ -738,6 +738,7 @@ export async function notify(sceneIds, options = {}) {
|
||||
.insert(stashEntries)
|
||||
.onConflict(['stash_id', 'scene_id'])
|
||||
.ignore()
|
||||
.returning('id')
|
||||
: [],
|
||||
notifications.length > 0
|
||||
? knex('notifications')
|
||||
@@ -754,7 +755,7 @@ export async function notify(sceneIds, options = {}) {
|
||||
|
||||
const docs = stashedEntries.map((stash) => ({
|
||||
replace: {
|
||||
index: 'scenes_stashed',
|
||||
table: 'scenes_stashed',
|
||||
id: stash.id,
|
||||
doc: {
|
||||
scene_id: stash.scene_id,
|
||||
|
||||
+109
-19
@@ -6,7 +6,7 @@ import { shapes } from '@dicebear/collection';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import config from 'config';
|
||||
import { verify } from 'hcaptcha';
|
||||
import { verify as verifyCaptcha } from 'hcaptcha';
|
||||
|
||||
import slugify from '../utils/slugify.js';
|
||||
import { sendEmails } from './email.js';
|
||||
@@ -332,11 +332,78 @@ export async function resetPassword(userId, emailToken, newPassword) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSignup(email) {
|
||||
if (!config.auth.signup || !config.email.enabled) {
|
||||
throw new HttpError('Sign-ups are disabled', 503);
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
||||
|
||||
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenKey = `traxxx:token_signup:${token}`;
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'signup',
|
||||
to: email,
|
||||
subject: 'Sign up for traxxx',
|
||||
props: {
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.token) {
|
||||
throw new HttpError('Token required', 400);
|
||||
}
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
|
||||
if (!curatedUsername) {
|
||||
@@ -355,30 +422,34 @@ export async function signup(credentials, userIp) {
|
||||
throw new HttpError('Username contains invalid characters', 400);
|
||||
}
|
||||
|
||||
if (!credentials.email) {
|
||||
throw new HttpError('E-mail required', 400);
|
||||
}
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.first();
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username already in use', 409);
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
||||
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||
|
||||
if (!captchaVerification.success) {
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||
throw new HttpError('Invalid CAPTCHA', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
||||
.first();
|
||||
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const { email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
@@ -386,8 +457,9 @@ export async function signup(credentials, userIp) {
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
username: curatedUsername,
|
||||
email: credentials.email,
|
||||
email,
|
||||
password: storedPassword,
|
||||
email_verified: true,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
@@ -399,7 +471,7 @@ export async function signup(credentials, userIp) {
|
||||
primary: true,
|
||||
});
|
||||
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||
|
||||
await generateAvatar({
|
||||
id: userId,
|
||||
@@ -410,7 +482,8 @@ export async function signup(credentials, userIp) {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
await sendEmailVerification(user, null, 'signup');
|
||||
// no longer needed with token-first signup flow
|
||||
// await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -423,7 +496,7 @@ async function updatePassword(password, reqUser, reqSession) {
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.country && await knex('countries').where('alpha2', reqSession.country).first();
|
||||
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
@@ -447,7 +520,16 @@ async function updateEmail(email, reqUser) {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new HttpError('This e-mail address is already in use', 409);
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user, // not reqUser, we don't want to leak details about the requester
|
||||
},
|
||||
}]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
@@ -546,6 +628,14 @@ export async function verifyKey(userId, key, req) {
|
||||
throw new HttpError('The API credentials are not provided.', 401);
|
||||
}
|
||||
|
||||
if (typeof userId !== 'number') {
|
||||
throw new HttpError('User ID is not a number', 400);
|
||||
}
|
||||
|
||||
if (typeof key !== 'string') {
|
||||
throw new HttpError('Key is not a string', 400);
|
||||
}
|
||||
|
||||
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
||||
|
||||
const storedKey = await knex('users_keys')
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { pageContext } from '../renderer/usePageContext.js';
|
||||
|
||||
function getBasePath(media, options) {
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return pageContext.env.media.assetPath;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ function getBasePath(media, options) {
|
||||
}
|
||||
|
||||
function getFilename(media, type, options) {
|
||||
if (pageContext.restriction && type && !options?.original) {
|
||||
if (pageContext.geo.restriction && type && !options?.original) {
|
||||
return media.sfw?.[type];
|
||||
}
|
||||
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return media.sfw?.path;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -127,7 +127,7 @@ function curateScene(rawScene, assets, reqUser, context) {
|
||||
title: serie.title,
|
||||
poster: context.restriction
|
||||
? null
|
||||
: (serie.serie_poster, { type: 'poster' }),
|
||||
: curateMedia(serie.serie_poster, { type: 'poster' }),
|
||||
})),
|
||||
poster: curateMedia(assets.poster, { type: 'poster' }),
|
||||
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
|
||||
@@ -766,6 +766,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
|
||||
'scenes_revisions.*',
|
||||
'users.username as username',
|
||||
'reviewers.username as reviewer_username',
|
||||
knexOwner.raw('count(*) over() as total'),
|
||||
)
|
||||
.leftJoin('users', 'users.id', 'scenes_revisions.user_id')
|
||||
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
|
||||
@@ -824,6 +825,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
|
||||
return {
|
||||
revisions: curatedRevisions,
|
||||
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
|
||||
total: Number(revisions[0]?.total) || 0,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
|
||||
+15
-3
@@ -25,10 +25,12 @@ export function curateStash(stash, assets = {}) {
|
||||
slug: stash.slug,
|
||||
isPrimary: stash.primary,
|
||||
isPublic: stash.public,
|
||||
isArchived: stash.archived,
|
||||
createdAt: stash.created_at,
|
||||
stashedScenes: stash.stashed_scenes ?? null,
|
||||
stashedMovies: stash.stashed_movies ?? null,
|
||||
stashedActors: stash.stashed_actors ?? null,
|
||||
lastStashAt: stash.last_stash_at,
|
||||
user: assets.user
|
||||
? {
|
||||
id: assets.user.id,
|
||||
@@ -64,7 +66,8 @@ function curateStashEntry(stash, user) {
|
||||
user_id: user?.id || undefined,
|
||||
name: stash.name || undefined,
|
||||
slug: slugify(stash.name) || undefined,
|
||||
public: stash.isPublic ?? false,
|
||||
public: stash.isPublic ?? undefined,
|
||||
archived: stash.isArchived ?? undefined,
|
||||
comment: stash.comment,
|
||||
meta: stash.meta,
|
||||
};
|
||||
@@ -126,6 +129,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
|
||||
}
|
||||
|
||||
export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
if (!usernameOrId) {
|
||||
throw new HttpError(`No username or user ID provided`, 400);
|
||||
}
|
||||
|
||||
const userId = typeof usernameOrId === 'number'
|
||||
? usernameOrId
|
||||
: await knex('users')
|
||||
@@ -134,7 +141,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
.then((user) => user?.id);
|
||||
|
||||
if (!userId) {
|
||||
throw new HttpError(`Could not find user '${usernameOrId}'`);
|
||||
throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
|
||||
}
|
||||
|
||||
const stashes = await knex('stashes')
|
||||
@@ -146,7 +153,12 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
if (userId !== reqUser?.id) {
|
||||
builder.where('public', true);
|
||||
}
|
||||
});
|
||||
})
|
||||
.orderBy([
|
||||
{ column: 'stashes.primary', order: 'desc' },
|
||||
{ column: 'stashes_meta.last_stash_at', order: 'desc' },
|
||||
{ column: 'stashes.name', order: 'asc' },
|
||||
]);
|
||||
|
||||
return stashes.map((stash) => curateStash(stash, { user: reqUser }));
|
||||
}
|
||||
|
||||
+8
-2
@@ -56,6 +56,14 @@ function whereUser(builder, userId, options = {}) {
|
||||
}
|
||||
|
||||
export async function fetchUser(userId, options = {}, reqUser) {
|
||||
if (!userId) {
|
||||
if (options.raw) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await knex('users')
|
||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
||||
.modify((builder) => whereUser(builder, userId, options))
|
||||
@@ -119,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
|
||||
}
|
||||
|
||||
export async function createBan(ban, reqUser) {
|
||||
console.log(ban);
|
||||
|
||||
if (reqUser.role !== 'admin') {
|
||||
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
||||
}
|
||||
|
||||
+2
-2
@@ -194,7 +194,7 @@ export async function createActorApi(req, res) {
|
||||
export async function mergeActorsApi(req, res) {
|
||||
const result = await mergeActors(
|
||||
Number(req.params.targetActorId),
|
||||
req.params.sourceActorIds.split(',').map((actorId) => Number(actorId)),
|
||||
req.body.actorIds,
|
||||
req.body.comment,
|
||||
req.user,
|
||||
);
|
||||
@@ -225,7 +225,7 @@ export const actorsRouter = Router();
|
||||
actorsRouter.get('/api/actors', fetchActorsApi);
|
||||
actorsRouter.post('/api/actors', createActorApi);
|
||||
|
||||
actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorIds', mergeActorsApi);
|
||||
actorsRouter.post('/api/actors/:targetActorId/merge', mergeActorsApi);
|
||||
|
||||
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
|
||||
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);
|
||||
|
||||
+31
-18
@@ -1,6 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { Router } from 'express';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
|
||||
import runtimeId from '../../utils/runtime-id.js';
|
||||
import argv from '../argv.js';
|
||||
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
resetPassword,
|
||||
sendEmailVerification,
|
||||
sendPasswordReset,
|
||||
sendSignup,
|
||||
signup,
|
||||
updateSettings,
|
||||
updateUser,
|
||||
@@ -22,26 +24,20 @@ import {
|
||||
fetchUser,
|
||||
} from '../users.js';
|
||||
|
||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||
// even if they're within the staleness window below
|
||||
const runtimeId = crypto.randomUUID();
|
||||
const sessionStaleThreshold = 5 * 60 * 1000;
|
||||
|
||||
// spread into a new object rather than mutating the user we're about to hand back to the
|
||||
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
||||
// session bookkeeping only, they shouldn't leak into the API response
|
||||
function toSession(user) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
runtimeId,
|
||||
refreshedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function isSessionStale(sessionUser) {
|
||||
return sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold;
|
||||
}
|
||||
|
||||
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
||||
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
||||
const pendingSessionRefreshes = new Map();
|
||||
@@ -90,16 +86,24 @@ function getIp(req) {
|
||||
return expandedIp.addressStart?.addressMinusSuffix || null;
|
||||
}
|
||||
|
||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||
// even if they're within the staleness window below
|
||||
const sessionStaleThreshold = 5 * 60 * 1000;
|
||||
|
||||
function isSessionStale(sessionUser) {
|
||||
return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold);
|
||||
}
|
||||
|
||||
export async function setUserApi(req, res, next) {
|
||||
const ip = getIp(req);
|
||||
|
||||
req.userIp = ip;
|
||||
|
||||
if (req.session.user) {
|
||||
if (isSessionStale(req.session.user)) {
|
||||
req.session.user = await refreshSessionUser(req.session.user);
|
||||
}
|
||||
if (isSessionStale(req.session.user)) {
|
||||
req.session.user = await refreshSessionUser(req.session.user);
|
||||
}
|
||||
|
||||
if (req.session.user) {
|
||||
req.user = req.session.user;
|
||||
req.user.ip = ip;
|
||||
}
|
||||
@@ -110,10 +114,12 @@ export async function setUserApi(req, res, next) {
|
||||
export async function updateSessionUser(req) {
|
||||
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
||||
|
||||
req.session.user = user;
|
||||
if (user) {
|
||||
req.session.user = user;
|
||||
|
||||
req.user = user;
|
||||
req.user.ip = req.userIp;
|
||||
req.user = user;
|
||||
req.user.ip = req.userIp;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginApi(req, res) {
|
||||
@@ -133,6 +139,12 @@ export async function logoutApi(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSignupApi(req, res) {
|
||||
await sendSignup(req.body.email, req.userIp);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export async function signupApi(req, res) {
|
||||
const user = await signup(req.body, req.userIp);
|
||||
|
||||
@@ -241,6 +253,7 @@ authRouter.post('/api/session', loginApi);
|
||||
authRouter.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
authRouter.post('/api/signups', sendSignupApi);
|
||||
authRouter.post('/api/users', signupApi);
|
||||
|
||||
// SETTINGS
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ export async function graphqlApi(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
||||
await verifyKey(Number(req.headers['api-user']), req.headers['api-key'], req);
|
||||
|
||||
req.user = {
|
||||
id: Number(req.headers['api-user']),
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default async function mainHandler(req, res, next) {
|
||||
siteKey: config.auth.captcha.siteKey,
|
||||
},
|
||||
},
|
||||
restriction: req.restriction,
|
||||
geo: req.geo,
|
||||
meta: {
|
||||
now: new Date().toISOString(),
|
||||
},
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@ export async function fetchMoviesApi(req, res) {
|
||||
} = await fetchMovies(await curateMoviesQuery(req.query), {
|
||||
page: Number(req.query.page) || 1,
|
||||
limit: Number(req.query.limit) || 30,
|
||||
}, req.user, { restriction: req.restriction });
|
||||
}, req.user, { restriction: req.geo.restriction });
|
||||
|
||||
res.send({
|
||||
movies,
|
||||
@@ -45,7 +45,7 @@ export async function fetchMoviesApi(req, res) {
|
||||
}
|
||||
|
||||
export async function fetchMovieApi(req, res) {
|
||||
const [movie] = await fetchMoviesById([Number(req.params.movieId)], { reqUser: req.user }, { restriction: req.restriction });
|
||||
const [movie] = await fetchMoviesById([Number(req.params.movieId)], { reqUser: req.user }, { restriction: req.geo.restriction });
|
||||
|
||||
if (!movie) {
|
||||
throw new HttpError(`No movie with ID ${req.params.movieId} found`, 404);
|
||||
@@ -135,7 +135,7 @@ export async function fetchMoviesGraphql(query, req) {
|
||||
page: query.page || 1,
|
||||
limit: query.limit || 30,
|
||||
aggregate: false,
|
||||
}, req.user, { restriction: req.restriction });
|
||||
}, req.user, { restriction: req.geo.restriction });
|
||||
|
||||
return {
|
||||
nodes: movies,
|
||||
|
||||
+64
-20
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { Reader } from '@maxmind/geoip2-node';
|
||||
import config from 'config';
|
||||
|
||||
import runtimeId from '../../utils/runtime-id.js';
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
@@ -11,22 +12,47 @@ export default async function initRestrictionHandler() {
|
||||
const reader = await Reader.open('assets/GeoLite2-City.mmdb');
|
||||
|
||||
function getRestriction(req) {
|
||||
if (Object.hasOwn(req.session, 'restriction') && Object.hasOwn(req.session, 'country') && req.session.restrictionIp === req.userIp) {
|
||||
return {
|
||||
restriction: req.session.restriction,
|
||||
country: req.session.country,
|
||||
};
|
||||
if (req.session.geo
|
||||
&& Object.hasOwn(req.session.geo, 'country')
|
||||
&& req.session.geo.ip === req.userIp
|
||||
&& req.session.geo.runtimeId === runtimeId) {
|
||||
const { country, subdivision } = req.session.geo;
|
||||
|
||||
if (!config.restrictions.enabled) {
|
||||
return {
|
||||
restriction: 0,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.hasOwn(req.session.geo, 'restriction')) {
|
||||
return {
|
||||
restriction: req.session.geo.restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const location = reader.city(req.userIp);
|
||||
const country = location.country.isoCode;
|
||||
const subdivision = location.subdivisions?.[0]?.isoCode;
|
||||
|
||||
if (!config.restrictions.enabled) {
|
||||
return {
|
||||
restriction: 0,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
if (regions[country]?.[subdivision]) {
|
||||
// state or province restriction
|
||||
return {
|
||||
restriction: config.restrictions.modes[regions[country][subdivision]],
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,23 +61,24 @@ export default async function initRestrictionHandler() {
|
||||
return {
|
||||
restriction: config.restrictions.modes[regions[country]],
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
restriction: null,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
function restrictionHandler(req, res, next) {
|
||||
if (!config.restrictions.enabled) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { restriction, country } = getRestriction(req);
|
||||
const {
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
} = getRestriction(req);
|
||||
|
||||
if (restriction === 'block' || req.path === '/sfw/') {
|
||||
res.render(path.join(import.meta.dirname, '../../assets/sfw.ejs'), {
|
||||
@@ -61,21 +88,38 @@ export default async function initRestrictionHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.restriction !== restriction) {
|
||||
req.session.restrictionIp = req.userIp;
|
||||
req.session.restriction = restriction;
|
||||
req.session.country = country;
|
||||
if (req.session.geo?.restriction !== restriction) {
|
||||
req.session.geo = {
|
||||
ip: req.userIp,
|
||||
runtimeId,
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
req.restriction = restriction;
|
||||
req.country = country;
|
||||
req.geo = {
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`);
|
||||
|
||||
req.session.restrictionIp = req.userIp;
|
||||
req.session.restriction = 0;
|
||||
req.session.country = null;
|
||||
req.session.geo = {
|
||||
ip: req.userIp,
|
||||
runtimeId,
|
||||
restriction: 0,
|
||||
country: null,
|
||||
subdivision: null,
|
||||
};
|
||||
|
||||
req.geo = {
|
||||
restriction: 0,
|
||||
country: null,
|
||||
subdivision: null,
|
||||
};
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
+15
-3
@@ -70,7 +70,7 @@ async function fetchScenesApi(req, res) {
|
||||
page: Number(req.query.page) || 1,
|
||||
limit: Number(req.query.limit) || 30,
|
||||
}, req.user, {
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
res.send({
|
||||
@@ -131,6 +131,7 @@ export const scenesSchema = `
|
||||
network: Entity
|
||||
actors: [Actor!]!
|
||||
tags: [Tag!]!
|
||||
chapters: [Chapter!]!
|
||||
poster: Media
|
||||
trailer: Media
|
||||
photos: [Media!]!
|
||||
@@ -160,6 +161,17 @@ export const scenesSchema = `
|
||||
size: Int
|
||||
createdAt: Int
|
||||
}
|
||||
|
||||
type Chapter {
|
||||
id: Int!
|
||||
time: Int
|
||||
tags: [Tag!]!
|
||||
date: Date
|
||||
duration: Int
|
||||
description: String
|
||||
title: String
|
||||
poster: Media
|
||||
}
|
||||
`;
|
||||
|
||||
function getScope(query) {
|
||||
@@ -254,7 +266,7 @@ export async function fetchScenesGraphql(query, req) {
|
||||
}
|
||||
|
||||
async function fetchSceneApi(req, res) {
|
||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.restriction });
|
||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.geo.restriction });
|
||||
|
||||
if (!scene) {
|
||||
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
||||
@@ -267,7 +279,7 @@ export async function fetchScenesByIdGraphql(query, req) {
|
||||
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
|
||||
reqUser: req.user,
|
||||
includePartOf: true,
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
if (query.ids) {
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ export default async function initServer() {
|
||||
|
||||
router.use('/api/{*splat}', async (req, _res, next) => {
|
||||
if (req.headers['api-user']) {
|
||||
req.user = await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
||||
req.user = await verifyKey(Number(req.headers['api-user']), req.headers['api-key'], req);
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export async function fetchTagsApi(req, res) {
|
||||
const tags = await fetchTags({
|
||||
query: req.query.query,
|
||||
}, {
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
res.send(tags);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MerkleJson } from 'merkle-json';
|
||||
|
||||
import knex from '../knex.js';
|
||||
import knex from '../src/knex.js';
|
||||
|
||||
const mj = new MerkleJson();
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export default crypto.randomUUID();
|
||||
Reference in New Issue
Block a user