Compare commits
34 Commits
60ad068939
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4c48700086 | |||
| 6f68a50eb7 | |||
| ef9be8ef9a | |||
| a81343c519 |
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
assets/img/icons/drawer-out.svg
Executable file
5
assets/img/icons/drawer-out.svg
Executable file
@@ -0,0 +1,5 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="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 |
18
assets/img/icons/sort-add-asc.svg
Normal file
18
assets/img/icons/sort-add-asc.svg
Normal file
@@ -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 |
18
assets/img/icons/sort-add-desc.svg
Normal file
18
assets/img/icons/sort-add-desc.svg
Normal file
@@ -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 |
16
assets/img/icons/sort-add.svg
Normal file
16
assets/img/icons/sort-add.svg
Normal file
@@ -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 |
38
assets/img/icons/sort-heart.svg
Normal file
38
assets/img/icons/sort-heart.svg
Normal file
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -77,7 +77,10 @@ function getSocialUrl(social) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||
const socials = props.actor.socials
|
||||
.filter((social) => social.platform)
|
||||
.slice(0, 10)
|
||||
.map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
79
components/emails/recovery.vue
Normal file
79
components/emails/recovery.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<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/recovery?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
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>Reset your traxxx password</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>
|
||||
|
||||
<Text>
|
||||
<strong>You requested to reset the password for your traxxx account.</strong>
|
||||
<br>
|
||||
Please set a new password 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>Reset password</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
|
||||
If you did not request a password reset, you can safely ignore this e-mail.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
80
components/emails/signup.vue
Normal file
80
components/emails/signup.vue
Normal file
@@ -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>
|
||||
62
components/emails/unavailable.vue
Normal file
62
components/emails/unavailable.vue
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,15 +12,64 @@ 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">
|
||||
<div class="stash-menu noselect">
|
||||
<ul class="stash-list nolist noselect">
|
||||
<li
|
||||
v-for="userStash in stashes"
|
||||
v-for="userStash in sortedStashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
@@ -29,27 +80,73 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
/>{{ 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;
|
||||
@@ -79,7 +88,7 @@ async function verifyEmail() {
|
||||
class="profile-section"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<label class="setting">
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">E-mail address</strong>
|
||||
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
|
||||
@@ -132,9 +141,9 @@ async function verifyEmail() {
|
||||
class="input"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="setting">
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">New password</strong>
|
||||
<span class="setting-description">Change the password you use for signing in</span>
|
||||
@@ -148,9 +157,9 @@ async function verifyEmail() {
|
||||
@input="(password) => credentials.password = password"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="setting">
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Current password</strong>
|
||||
<span class="setting-description">Enter your current password to save your changes</span>
|
||||
@@ -163,7 +172,7 @@ async function verifyEmail() {
|
||||
@input="(password) => currentPassword = password"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<Ellipsis v-if="saving" />
|
||||
@@ -171,7 +180,7 @@ async function verifyEmail() {
|
||||
<strong
|
||||
v-else-if="submitted"
|
||||
class="success"
|
||||
>{{ getFeedback() }}</strong>
|
||||
>{{ feedback }}</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@@ -277,4 +286,15 @@ async function verifyEmail() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function updateSettings() {
|
||||
class="profile-section"
|
||||
@submit.prevent
|
||||
>
|
||||
<div class="setting">
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert notifications</strong>
|
||||
<span class="setting-description">Your alerts can trigger notifications in traxxx</span>
|
||||
@@ -39,7 +39,7 @@ async function updateSettings() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert e-mails</strong>
|
||||
<span class="setting-description">Your alerts can trigger e-mails</span>
|
||||
@@ -53,7 +53,7 @@ async function updateSettings() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="setting">
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Alert e-mail frequency</strong>
|
||||
<span class="setting-description">Alert e-mails are sent immediately, or accumulated and sent periodically</span>
|
||||
@@ -71,7 +71,7 @@ async function updateSettings() {
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -112,4 +112,15 @@ async function updateSettings() {
|
||||
color: var(--glass);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting:not(.noflip) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -154,7 +154,14 @@ module.exports = {
|
||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||
secretKey: '0x0000000000000000000000000000000000000000',
|
||||
},
|
||||
loginCredentialAttempts: 5,
|
||||
loginCredentialCooldown: 10, // minutes,
|
||||
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
|
||||
loginIpCooldown: 10, // minutes
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
482
package-lock.json
generated
482
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "traxxx-web",
|
||||
"version": "0.56.3",
|
||||
"version": "0.57.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"version": "0.56.3",
|
||||
"version": "0.57.15",
|
||||
"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",
|
||||
|
||||
@@ -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.56.3",
|
||||
"version": "0.57.15",
|
||||
"imports": {
|
||||
"#/*": "./*.js"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export default {
|
||||
meta: {
|
||||
title: {
|
||||
env: {
|
||||
server: true,
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
env: {
|
||||
server: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'actors',
|
||||
pageProps: {
|
||||
actors,
|
||||
countries,
|
||||
|
||||
1
pages/actors/+title.js
Normal file
1
pages/actors/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Actors';
|
||||
@@ -9,10 +9,7 @@ import navigate from '#/src/navigate.js';
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
const allowSignup = pageContext.env.allowSignup;
|
||||
// const allowRecovery = pageContext.env.emailEnabled;
|
||||
const allowRecovery = false; // TODO: implement
|
||||
|
||||
console.log(pageContext.env);
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -194,4 +191,8 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
1
pages/auth/login/+title.js
Normal file
1
pages/auth/login/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Login';
|
||||
218
pages/auth/recovery/+Page.vue
Normal file
218
pages/auth/recovery/+Page.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { userId, token } = pageContext.urlParsed.search;
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const credential = ref('');
|
||||
|
||||
const submitted = ref(false);
|
||||
const success = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const password = ref(null);
|
||||
|
||||
async function request() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/request', {
|
||||
credential: credential.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset requested, please check your e-mail inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/reset', {
|
||||
userId,
|
||||
token,
|
||||
password: password.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset successfully',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recovery-container">
|
||||
<h3 class="heading">
|
||||
<template v-if="userId && token">Password reset</template>
|
||||
<template v-else>Account recovery</template>
|
||||
</h3>
|
||||
|
||||
<template v-if="allowRecovery">
|
||||
<strong
|
||||
v-if="errorMsg"
|
||||
class="error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<form
|
||||
v-else-if="userId && token"
|
||||
class="login-panel"
|
||||
@submit.prevent="reset"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset successfully</strong>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>Go to login</a>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Reset password</button>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="allowRecovery"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset requested</strong>
|
||||
If the account exists, instructions have been sent to the associated e-mail address.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="credential"
|
||||
placeholder="Username or e-mail"
|
||||
class="input"
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Request password reset</button>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="error"
|
||||
>Password recovery is currently unavailable</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: center;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.recovery-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--background-base-10);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1rem 1rem 1rem;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
background: var(--background-base);
|
||||
font-size: 1rem;
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem .5rem 1rem;
|
||||
}
|
||||
</style>
|
||||
1
pages/auth/recovery/+route.js
Normal file
1
pages/auth/recovery/+route.js
Normal file
@@ -0,0 +1 @@
|
||||
export default '/recovery';
|
||||
1
pages/auth/recovery/+title.js
Normal file
1
pages/auth/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default `Account recovery`;
|
||||
@@ -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>
|
||||
|
||||
<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"
|
||||
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>
|
||||
|
||||
1
pages/auth/signup/+title.js
Normal file
1
pages/auth/signup/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Sign up';
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
1
pages/recovery/+title.js
Normal file
1
pages/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Account recovery';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -270,7 +270,7 @@ function scrollHorizontal(event) {
|
||||
gap: .5rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem 0;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.domain {
|
||||
|
||||
@@ -31,7 +31,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)
|
||||
: [],
|
||||
|
||||
@@ -9,6 +9,6 @@ export default {
|
||||
'assets',
|
||||
'campaigns',
|
||||
'meta',
|
||||
'restriction',
|
||||
'geo',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
|
||||
function getTitle(location) {
|
||||
function getTitle(location, pageContext) {
|
||||
if (!location) {
|
||||
return config.title;
|
||||
}
|
||||
|
||||
if (typeof location === 'function') {
|
||||
return getTitle(location(pageContext), pageContext);
|
||||
}
|
||||
|
||||
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
|
||||
}
|
||||
|
||||
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
|
||||
const appHtml = await renderToString(app);
|
||||
|
||||
// See https://vike.dev/head
|
||||
const { documentProps } = pageContext.exports;
|
||||
const title = getTitle(documentProps?.title || pageContext.title);
|
||||
const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
|
||||
|
||||
const documentHtml = escapeInject`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.includeAliased) {
|
||||
if (!filters.includeAliased && !filters.stashId) {
|
||||
builder.where('alias_for', 0);
|
||||
}
|
||||
|
||||
@@ -867,7 +867,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),
|
||||
|
||||
@@ -754,7 +754,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,
|
||||
|
||||
488
src/auth.js
488
src/auth.js
@@ -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';
|
||||
@@ -46,19 +46,71 @@ async function generateAvatar(user) {
|
||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
||||
}
|
||||
|
||||
async function checkLoginCooldown(identifier, attemptThreshold) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
||||
|
||||
if (attempts >= attemptThreshold) {
|
||||
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function setLoginCooldown(identifier, attemptCooldown) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
await redis.incr(cooldownKey);
|
||||
await redis.expire(cooldownKey, attemptCooldown * 60);
|
||||
}
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.username) {
|
||||
throw new HttpError('Please provide a username or e-mail', 400);
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError('Please provide a password', 400);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
||||
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
||||
]);
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
}).catch(async () => {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
try {
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
}
|
||||
catch (error) {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await knex('users')
|
||||
.update('last_login', 'NOW()')
|
||||
@@ -90,6 +142,17 @@ async function hashPassword(password) {
|
||||
};
|
||||
}
|
||||
|
||||
async function tokenCooldown(identifier) {
|
||||
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
if (await redis.exists(cooldownKey)) {
|
||||
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
||||
}
|
||||
|
||||
await redis.set(cooldownKey, JSON.stringify(true));
|
||||
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('E-mail verification is disabled', 503);
|
||||
@@ -105,6 +168,8 @@ export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -135,127 +200,6 @@ export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
|
||||
if (!curatedUsername) {
|
||||
throw new HttpError('Username required', 400);
|
||||
}
|
||||
|
||||
if (curatedUsername.length < config.auth.usernameLength[0]) {
|
||||
throw new HttpError('Username is too short', 400);
|
||||
}
|
||||
|
||||
if (curatedUsername.length > config.auth.usernameLength[1]) {
|
||||
throw new HttpError('Username is too long', 400);
|
||||
}
|
||||
|
||||
if (!config.auth.usernamePattern.test(curatedUsername)) {
|
||||
throw new HttpError('Username contains invalid characters', 400);
|
||||
}
|
||||
|
||||
if (!credentials.email) {
|
||||
throw new HttpError('E-mail required', 400);
|
||||
}
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
||||
|
||||
if (!captchaVerification.success) {
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${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();
|
||||
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
username: curatedUsername,
|
||||
email: credentials.email,
|
||||
password: storedPassword,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
await knex('stashes').insert({
|
||||
user_id: userId,
|
||||
name: 'Favorites',
|
||||
slug: 'favorites',
|
||||
public: false,
|
||||
primary: true,
|
||||
});
|
||||
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
||||
|
||||
await generateAvatar({
|
||||
id: userId,
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.country && await knex('countries').where('alpha2', reqSession.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new HttpError('This e-mail address is already in use', 409);
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
@@ -293,6 +237,304 @@ export async function verifyEmail(userId, emailToken) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(credential) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('Password reset is disabled', 503);
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
throw new HttpError('No credential provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(credential);
|
||||
|
||||
const user = await fetchUser(credential, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
||||
if (!user) {
|
||||
logger.info(`Password reset requested for unknown user '${credential}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'recovery',
|
||||
to: user.email,
|
||||
subject: 'Reset your traxxx password',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function resetPassword(userId, emailToken, newPassword) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new HttpError('New password not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { token } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(newPassword);
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update('password', storedPassword);
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('Password reset failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new HttpError('Username required', 400);
|
||||
}
|
||||
|
||||
if (curatedUsername.length < config.auth.usernameLength[0]) {
|
||||
throw new HttpError('Username is too short', 400);
|
||||
}
|
||||
|
||||
if (curatedUsername.length > config.auth.usernameLength[1]) {
|
||||
throw new HttpError('Username is too long', 400);
|
||||
}
|
||||
|
||||
if (!config.auth.usernamePattern.test(curatedUsername)) {
|
||||
throw new HttpError('Username contains invalid characters', 400);
|
||||
}
|
||||
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.first();
|
||||
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username already in use', 409);
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||
|
||||
if (!captchaVerification.success) {
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||
throw new HttpError('Invalid CAPTCHA', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
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);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
username: curatedUsername,
|
||||
email,
|
||||
password: storedPassword,
|
||||
email_verified: true,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
await knex('stashes').insert({
|
||||
user_id: userId,
|
||||
name: 'Favorites',
|
||||
slug: 'favorites',
|
||||
public: false,
|
||||
primary: true,
|
||||
});
|
||||
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||
|
||||
await generateAvatar({
|
||||
id: userId,
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
// no longer needed with token-first signup flow
|
||||
// await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
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');
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
10
src/users.js
10
src/users.js
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
flushUserKeys,
|
||||
login,
|
||||
removeUserKey,
|
||||
resetPassword,
|
||||
sendEmailVerification,
|
||||
sendPasswordReset,
|
||||
sendSignup,
|
||||
signup,
|
||||
updateSettings,
|
||||
updateUser,
|
||||
@@ -20,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();
|
||||
@@ -88,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 (req.session.user) {
|
||||
req.user = req.session.user;
|
||||
req.user.ip = ip;
|
||||
}
|
||||
@@ -108,11 +114,13 @@ export async function setUserApi(req, res, next) {
|
||||
export async function updateSessionUser(req) {
|
||||
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
||||
|
||||
if (user) {
|
||||
req.session.user = user;
|
||||
|
||||
req.user = user;
|
||||
req.user.ip = req.userIp;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginApi(req, res) {
|
||||
const user = await login(req.body, req.userIp);
|
||||
@@ -131,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);
|
||||
|
||||
@@ -184,6 +198,20 @@ async function verifyEmailApi(req, res) {
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function sendPasswordResetApi(req, res) {
|
||||
await sendPasswordReset(req.body.credential);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function resetPasswordApi(req, res) {
|
||||
const userId = Number(req.body.userId) || null;
|
||||
|
||||
await resetPassword(userId, req.body.token, req.body.password);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function updateSettingsApi(req, res) {
|
||||
const updatedSettings = await updateSettings(req.body, req.user);
|
||||
|
||||
@@ -225,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
|
||||
@@ -234,6 +263,8 @@ authRouter.patch('/api/me/settings', updateSettingsApi);
|
||||
// EMAIL VERIFICATION
|
||||
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||
authRouter.post('/api/passwords/request', sendPasswordResetApi);
|
||||
authRouter.post('/api/passwords/reset', resetPasswordApi);
|
||||
|
||||
// API KEYS
|
||||
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
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: req.session.restriction,
|
||||
country: req.session.country,
|
||||
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();
|
||||
|
||||
@@ -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({
|
||||
@@ -254,7 +254,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 +267,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
3
utils/runtime-id.js
Normal file
3
utils/runtime-id.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export default crypto.randomUUID();
|
||||
Reference in New Issue
Block a user