Compare commits
3 Commits
4b8dfba289
...
99c10cf394
Author | SHA1 | Date |
---|---|---|
DebaucheryLibrarian | 99c10cf394 | |
DebaucheryLibrarian | 8f843f321d | |
DebaucheryLibrarian | 8bf9e22b39 |
|
@ -57,3 +57,11 @@
|
|||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.noshrink {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
|
|
@ -81,6 +81,9 @@
|
|||
--success: #5c2;
|
||||
--notice: #25c;
|
||||
|
||||
--approve: #3a1;
|
||||
--reject: #a22;
|
||||
|
||||
--gold: #d5b522;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M8 3.188c0.561 0 1.084 0.165 1.523 0.449l-3.887 3.887c-0.284-0.439-0.449-0.962-0.449-1.523 0-1.551 1.262-2.813 2.813-2.813zM6.477 8.363l3.887-3.887c0.284 0.439 0.449 0.962 0.449 1.523 0 1.551-1.262 2.812-2.813 2.812-0.561 0-1.084-0.165-1.523-0.449zM14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM8 10c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 606 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM11 4.282l-1.718 1.718 1.718 1.718v1.282h-1.282l-1.718-1.718-1.718 1.718h-1.282v-1.282l1.718-1.718-1.718-1.718v-1.282h1.282l1.718 1.718 1.718-1.718h1.282v1.282z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 455 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM7 9.414l-3.207-3.707 0.914-0.914 2.293 1.793 4.293-3.793 0.914 0.914-5.207 5.707z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 377 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 1h-13c-0.825 0-1.5 0.675-1.5 1.5v8c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-8c0-0.825-0.675-1.5-1.5-1.5zM7 9h-4v-1h4v1zM11 7h-8v-1h8v1zM13 5h-10v-1h10v1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 344 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM7 9h-3c-1.1 0-2-0.9-2-2v-1c0-1.1 0.9-2 2-2h3v1h-3c-0.265 0-0.515 0.105-0.705 0.295s-0.295 0.441-0.295 0.705v1c0 0.265 0.105 0.515 0.295 0.705s0.441 0.295 0.705 0.295h3v1zM6 7v-1h4v1h-4zM14 7c0 1.1-0.9 2-2 2h-3v-1h3c0.265 0 0.515-0.105 0.705-0.295s0.295-0.441 0.295-0.705v-1c0-0.265-0.105-0.515-0.295-0.705s-0.44-0.295-0.705-0.295h-3v-1h3c1.1 0 2 0.9 2 2v1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 652 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM9 10h-2v-2h2v2zM9 6h-2v-4h2v4z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 326 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.5 0h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h2.5v4l4.8-4h5.7c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM12 7h-3v3h-2v-3h-3v-2h3v-3h2v3h3v2z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 331 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M15.784 14.309l-8.572-7.804 0.399-0.4c0.326-0.327 0.503-0.75 0.53-1.181 0.016-0.007 0.031-0.014 0.046-0.023l1.609-1.006c0.218-0.256 0.202-0.66-0.036-0.898l-2.799-2.806c-0.237-0.238-0.641-0.254-0.896-0.036l-1.004 1.614c-0.008 0.015-0.015 0.031-0.022 0.046-0.43 0.027-0.852 0.204-1.178 0.531l-1.522 1.527c-0.327 0.327-0.503 0.75-0.53 1.181-0.016 0.007-0.031 0.014-0.046 0.023l-1.609 1.006c-0.218 0.256-0.202 0.66 0.036 0.898l2.799 2.806c0.237 0.238 0.641 0.254 0.896 0.036l1.004-1.614c0.008-0.015 0.015-0.031 0.023-0.046 0.43-0.027 0.852-0.204 1.178-0.531l0.442-0.443 7.783 8.596c0.226 0.249 0.573 0.289 0.773 0.089l0.787-0.789c0.199-0.2 0.159-0.549-0.089-0.775z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 817 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M12 16h-5c-0.133 0-0.26-0.053-0.354-0.146l-3-3c-0.027-0.027-0.050-0.056-0.070-0.089l-2.5-4c-0.143-0.229-0.078-0.531 0.147-0.681l1.5-1c0.213-0.142 0.498-0.1 0.661 0.096l1.616 1.939v-7.619c0-0.276 0.224-0.5 0.5-0.5h1.5v-0.5c0-0.276 0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5v0.5h1.5c0.276 0 0.5 0.224 0.5 0.5v1.5h1.5c0.276 0 0.5 0.224 0.5 0.5v9c0 0.078-0.018 0.154-0.053 0.224l-1.5 3c-0.085 0.169-0.258 0.276-0.447 0.276zM7.207 15h4.484l1.309-2.618v-8.382h-1v4.5c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5v-6.5h-1v6.5c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5v-7.5h-1v7.5c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5v-6.5h-1v8.5c0 0.21-0.132 0.398-0.33 0.47s-0.42 0.012-0.554-0.15l-2.212-2.655-0.722 0.481 2.213 3.54 2.813 2.813z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 892 B |
|
@ -0,0 +1,4 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M16 11.5l-4.5-11.5h-7l-4.5 4.5v7l4.5 4.5h7l4.5-4.5v-7l-4.5-4.5zM9 13h-2v-2h2v2zM9 9h-2v-6h2v6z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 251 B |
|
@ -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="M6 11.5c0-2.363 1.498-4.383 3.594-5.159 0.254-0.571 0.406-1.206 0.406-1.841 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h6.208c-0.135-0.477-0.208-0.98-0.208-1.5z"></path>
|
||||
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5c2.485 0 4.5-2.015 4.5-4.5s-2.015-4.5-4.5-4.5zM8 11.5c0-1.933 1.567-3.5 3.5-3.5 0.763 0 1.47 0.245 2.045 0.659l-4.885 4.886c-0.415-0.575-0.66-1.282-0.66-2.045zM11.5 15c-0.763 0-1.47-0.245-2.045-0.659l4.886-4.886c0.415 0.575 0.659 1.282 0.659 2.045 0 1.933-1.567 3.5-3.5 3.5z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 716 B |
|
@ -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="M6 11.5c0-2.363 1.498-4.383 3.594-5.159 0.254-0.571 0.406-1.206 0.406-1.841 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h6.208c-0.135-0.477-0.208-0.98-0.208-1.5z"></path>
|
||||
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5c2.485 0 4.5-2.015 4.5-4.5s-2.015-4.5-4.5-4.5zM13.898 13.102c0.22 0.22 0.22 0.576 0 0.795-0.11 0.11-0.254 0.165-0.398 0.165s-0.288-0.055-0.398-0.165l-1.602-1.602-1.602 1.602c-0.11 0.11-0.254 0.165-0.398 0.165s-0.288-0.055-0.398-0.165c-0.22-0.22-0.22-0.576 0-0.795l1.602-1.602-1.602-1.602c-0.22-0.22-0.22-0.576 0-0.795s0.576-0.22 0.795 0l1.602 1.602 1.602-1.602c0.22-0.22 0.576-0.22 0.795 0s0.22 0.576 0 0.795l-1.602 1.602 1.602 1.602z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 876 B |
|
@ -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 9.5l-4.5 4.5-1.5-1.5-1 1 2.5 2.5 5.5-5.5z"></path>
|
||||
<path d="M7 12h5v-1.799c-1.050-0.613-2.442-1.033-4-1.16v-0.825c1.102-0.621 2-2.168 2-3.716 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h7v-1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 406 B |
|
@ -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.25 11h-0.25v-1c0-1.103-0.897-2-2-2s-2 0.897-2 2v1h-0.25c-0.412 0-0.75 0.338-0.75 0.75v3.5c0 0.412 0.338 0.75 0.75 0.75h4.5c0.412 0 0.75-0.338 0.75-0.75v-3.5c0-0.412-0.338-0.75-0.75-0.75zM12 10c0-0.551 0.449-1 1-1s1 0.449 1 1v1h-2v-1z"></path>
|
||||
<path d="M9 9.166c-0.324-0.055-0.658-0.097-1-0.125v-0.825c1.102-0.621 2-2.168 2-3.716 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h9v-3.834z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 597 B |
|
@ -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="M6 11.5c0-2.363 1.498-4.383 3.594-5.159 0.254-0.571 0.406-1.206 0.406-1.841 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h6.208c-0.135-0.477-0.208-0.98-0.208-1.5z"></path>
|
||||
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5c2.485 0 4.5-2.015 4.5-4.5s-2.015-4.5-4.5-4.5zM14 12h-5v-1h5v1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 505 B |
|
@ -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="M6 11.5c0-2.363 1.498-4.383 3.594-5.159 0.254-0.571 0.406-1.206 0.406-1.841 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h6.208c-0.135-0.477-0.208-0.98-0.208-1.5z"></path>
|
||||
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5c2.485 0 4.5-2.015 4.5-4.5s-2.015-4.5-4.5-4.5zM14 12h-2v2h-1v-2h-2v-1h2v-2h1v2h2v1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 525 B |
|
@ -8,7 +8,7 @@
|
|||
class="avatar-container"
|
||||
>
|
||||
<img
|
||||
:src="getMediaPath(actor.avatar, 'thumbnail')"
|
||||
:src="getPath(actor.avatar, 'thumbnail')"
|
||||
:title="actor.avatar.credit && `© ${actor.avatar.credit}`"
|
||||
class="avatar"
|
||||
>
|
||||
|
@ -296,7 +296,7 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { getMediaPath } from '#/utils/media-path.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
import { formatDate } from '#/utils/format.js';
|
||||
|
||||
const expanded = ref(false);
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<VDropdown
|
||||
:disabled="disabled"
|
||||
class="trigger"
|
||||
@show="focus"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<template #popper>
|
||||
<div>
|
||||
<input
|
||||
ref="queryInput"
|
||||
v-model="query"
|
||||
placeholder="Search actor"
|
||||
class="input"
|
||||
@input="search"
|
||||
>
|
||||
|
||||
<ul class="actors nolist">
|
||||
<li
|
||||
v-for="actor in actors"
|
||||
:key="`actor-${actor.slug}`"
|
||||
v-close-popper
|
||||
class="actor"
|
||||
@click="emit('actor', actor)"
|
||||
>{{ actor.name }} ({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})
|
||||
<img
|
||||
v-if="actor.avatar"
|
||||
:src="getPath(actor.avatar, 'thumbnail')"
|
||||
class="avatar"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const actors = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actor']);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/actors', { q: query.value });
|
||||
|
||||
actors.value = data.actors;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: block;
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: fixed;
|
||||
display: none;
|
||||
left: 7rem;
|
||||
width: 8rem;
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-10);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<nav class="nav">
|
||||
<ul class="nav-items nolist">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="/admin/revisions"
|
||||
class="nav-link nolink"
|
||||
:class="{ active: pageContext.routeParams.section === 'revisions' }"
|
||||
>Revisions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
// console.log(pageContext);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
background: var(--background-base-10);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
background: var(--background-dark-20);
|
||||
border-radius: 1rem;
|
||||
color: var(--glass-strong-20);
|
||||
font-size: .9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: .5rem 1rem;
|
||||
font-weight: bold;
|
||||
|
||||
&.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<ul
|
||||
class="actors nolist"
|
||||
:class="{ disabled: !editing.has(item.key) }"
|
||||
>
|
||||
<li
|
||||
v-for="actor in [...item.value, ...newActors]"
|
||||
:key="`actor-${actor.id}`"
|
||||
class="actor"
|
||||
:class="{ deleted: edits.actors && !edits.actors.some((actorId) => actorId === actor.id) }"
|
||||
>
|
||||
<span class="actor-name">{{ actor.name }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="edits.actors && !edits.actors.some((actorId) => actorId === actor.id)"
|
||||
icon="checkmark"
|
||||
class="add"
|
||||
@click="emit('actors', edits.actors.concat(actor.id))"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
icon="cross2"
|
||||
class="remove"
|
||||
@click="emit('actors', edits.actors.filter((actorId) => actorId !== actor.id))"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="new">
|
||||
<ActorSearch
|
||||
:disabled="!editing.has(item.key)"
|
||||
@actor="addActor"
|
||||
>
|
||||
<Icon
|
||||
icon="plus3"
|
||||
class="add"
|
||||
/>
|
||||
</ActorSearch>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import ActorSearch from '#/components/actors/search.vue';
|
||||
|
||||
const newActors = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actors']);
|
||||
|
||||
function addActor(actor) {
|
||||
newActors.value = newActors.value.concat(actor);
|
||||
|
||||
emit('actors', props.edits.actors.concat(actor.id));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newActors.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
|
||||
&.disabled {
|
||||
.actor {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--glass-strong-10);
|
||||
|
||||
.remove,
|
||||
.add {
|
||||
fill: var(--shadow-weak-30);
|
||||
background: var(--shadow-weak-50);
|
||||
}
|
||||
}
|
||||
|
||||
.new .icon {
|
||||
background: var(--shadow-weak-40);
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: .25rem;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 100%;
|
||||
padding: 0 .5rem;
|
||||
background: var(--success);
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: .25rem;
|
||||
background: var(--background);
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
|
||||
&.deleted {
|
||||
color: var(--glass);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.actor,
|
||||
.new {
|
||||
.remove,
|
||||
.add {
|
||||
height: auto;
|
||||
padding: .25rem .3rem;
|
||||
border-radius: .25rem;
|
||||
fill: var(--highlight-strong-10);
|
||||
|
||||
&:hover {
|
||||
fill: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
fill: var(--error);
|
||||
|
||||
&:hover {
|
||||
background: var(--error);
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
fill: var(--success);
|
||||
|
||||
&:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<ul
|
||||
class="movies nolist"
|
||||
:class="{ disabled: !editing.has(item.key) }"
|
||||
>
|
||||
<li
|
||||
v-for="movie in [...item.value, ...newMovies]"
|
||||
:key="`movie-${movie.id}`"
|
||||
class="movie"
|
||||
:class="{ deleted: edits.movies && !edits.movies.some((movieId) => movieId === movie.id) }"
|
||||
>
|
||||
<span class="movie-name">{{ movie.title }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="edits.movies && !edits.movies.some((movieId) => movieId === movie.id)"
|
||||
icon="checkmark"
|
||||
class="add"
|
||||
@click="emit('movies', edits.movies.concat(movie.id))"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
icon="cross2"
|
||||
class="remove"
|
||||
@click="emit('movies', edits.movies.filter((movieId) => movieId !== movie.id))"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="new">
|
||||
<MovieSearch
|
||||
:disabled="!editing.has(item.key)"
|
||||
@movie="addMovie"
|
||||
>
|
||||
<Icon
|
||||
icon="plus3"
|
||||
class="add"
|
||||
/>
|
||||
</MovieSearch>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import MovieSearch from '#/components/movies/search.vue';
|
||||
|
||||
const newMovies = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['movies']);
|
||||
|
||||
function addMovie(movie) {
|
||||
newMovies.value = newMovies.value.concat(movie);
|
||||
|
||||
console.log(movie);
|
||||
|
||||
emit('movies', props.edits.movies.concat(movie.id));
|
||||
|
||||
console.log(props.edits);
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newMovies.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.movies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
|
||||
&.disabled {
|
||||
.movie {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--glass-strong-10);
|
||||
|
||||
.remove,
|
||||
.add {
|
||||
fill: var(--shadow-weak-30);
|
||||
background: var(--shadow-weak-50);
|
||||
}
|
||||
}
|
||||
|
||||
.new .icon {
|
||||
background: var(--shadow-weak-40);
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: .25rem;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 100%;
|
||||
padding: 0 .5rem;
|
||||
background: var(--success);
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.movie {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: .25rem;
|
||||
background: var(--background);
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
|
||||
&.deleted {
|
||||
color: var(--glass);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.movie,
|
||||
.new {
|
||||
.remove,
|
||||
.add {
|
||||
height: auto;
|
||||
padding: .25rem .3rem;
|
||||
border-radius: .25rem;
|
||||
fill: var(--highlight-strong-10);
|
||||
|
||||
&:hover {
|
||||
fill: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
fill: var(--error);
|
||||
|
||||
&:hover {
|
||||
background: var(--error);
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
fill: var(--success);
|
||||
|
||||
&:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.movie-name {
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,488 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div
|
||||
v-if="interactive"
|
||||
class="revs-header"
|
||||
>
|
||||
<Checkbox
|
||||
label="Show finalized"
|
||||
:checked="showReviewed"
|
||||
@change="(checked) => { showReviewed = checked; reloadRevisions(); }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="revs nolist">
|
||||
<li
|
||||
v-for="rev in curatedRevisions"
|
||||
:key="`rev-${rev.id}`"
|
||||
class="rev"
|
||||
:class="{ reviewed: reviewedRevisions.has(rev.id) }"
|
||||
>
|
||||
<div class="rev-header">
|
||||
<a
|
||||
:href="`/scene/${rev.sceneId}`"
|
||||
target="_blank"
|
||||
class="rev-link rev-scene nolink noshrink"
|
||||
>{{ rev.sceneId }}@{{ rev.hash.slice(0, 6) }}</a>
|
||||
|
||||
<a
|
||||
:href="`/scene/${rev.sceneId}`"
|
||||
target="_blank"
|
||||
class="rev-link rev-title nolink ellipsis"
|
||||
>{{ rev.base.title }}</a>
|
||||
|
||||
<div class="rev-details noshrink">
|
||||
<a
|
||||
v-if="rev.user"
|
||||
:href="`/user/${rev.user.username}`"
|
||||
target="_blank"
|
||||
class="rev-username nolink"
|
||||
>{{ rev.user.username }}</a>
|
||||
|
||||
<time
|
||||
:datetime="rev.createdAt"
|
||||
class="rev-created"
|
||||
>{{ format(rev.createdAt, 'yyyy-MM-dd hh:mm') }}</time>
|
||||
</div>
|
||||
|
||||
<div class="rev-actions noshrink">
|
||||
<span
|
||||
v-if="rev.review"
|
||||
class="approved"
|
||||
:class="{ rejected: !rev.review.isApproved }"
|
||||
>{{ rev.review.isApproved ? 'Approved' : 'Rejected' }} by <a
|
||||
:href="`/user/${rev.review.username}`"
|
||||
target="_blank"
|
||||
class="nolink"
|
||||
>{{ rev.review.username }}</a> {{ format(rev.review.reviewedAt, 'yyyy-MM-dd hh:mm') }}</span>
|
||||
|
||||
<template v-else-if="interactive">
|
||||
<Icon
|
||||
v-tooltip="`Ban user from submitting revisions`"
|
||||
icon="user-block"
|
||||
class="review-reject review-ban"
|
||||
@click="banEditor(rev)"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="`Reject revision`"
|
||||
icon="blocked"
|
||||
class="review-reject"
|
||||
@click="reviewRevision(rev, false)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="feedbacks[rev.id]"
|
||||
placeholder="Feedback"
|
||||
class="input"
|
||||
>
|
||||
</template>
|
||||
|
||||
<Icon
|
||||
v-if="(!rev.review || !rev.review.isApproved) && interactive"
|
||||
v-tooltip="`Approve and apply revision`"
|
||||
icon="checkmark"
|
||||
class="review-approve"
|
||||
@click="reviewRevision(rev, true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="rev-deltas">
|
||||
<li
|
||||
v-for="(delta, index) in rev.deltas"
|
||||
:key="`delta-${rev.id}-${index}`"
|
||||
class="delta"
|
||||
>
|
||||
<span class="delta-key ellipsis">{{ delta.key }}</span>
|
||||
|
||||
<div class="delta-deltas">
|
||||
<span class="delta-from delta-value">
|
||||
<ul
|
||||
v-if="Array.isArray(rev.base[delta.key])"
|
||||
class="nolist"
|
||||
>[
|
||||
<li
|
||||
v-for="item in rev.base[delta.key]"
|
||||
:key="`item-${rev.id}-${index}-${item.id}`"
|
||||
class="delta-item"
|
||||
:class="{ modified: item.modified }"
|
||||
>{{ item.name || item.id || item }}</li> ]
|
||||
</ul>
|
||||
|
||||
<template v-else-if="rev.base[delta.key] instanceof Date">{{ format(rev.base[delta.key], 'yyyy-MM-dd hh:mm') }}</template>
|
||||
<template v-else>{{ rev.base[delta.key] }}</template>
|
||||
</span>
|
||||
|
||||
<span class="delta-arrow">⇒</span>
|
||||
|
||||
<span class="delta-to delta-value">
|
||||
<ul
|
||||
v-if="Array.isArray(delta.value)"
|
||||
class="nolist"
|
||||
>[
|
||||
<li
|
||||
v-for="item in delta.value"
|
||||
:key="`item-${rev.id}-${index}-${item.id}`"
|
||||
class="delta-item"
|
||||
:class="{ modified: item.modified }"
|
||||
>{{ item.name || item.id || item }}</li> ]
|
||||
</ul>
|
||||
|
||||
<template v-else-if="delta.value instanceof Date">{{ format(delta.value, 'yyyy-MM-dd hh:mm') }}</template>
|
||||
<template v-else>{{ delta.value }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="rev.comment"
|
||||
class="rev-comment"
|
||||
>
|
||||
{{ rev.comment }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { get, post } from '#/src/api.js';
|
||||
|
||||
defineProps({
|
||||
interactive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const revisions = ref(pageContext.pageProps.revisions);
|
||||
|
||||
const actors = ref(pageContext.pageProps.actors);
|
||||
const tags = ref(pageContext.pageProps.tags);
|
||||
const movies = ref(pageContext.pageProps.movies);
|
||||
|
||||
const actorsById = computed(() => Object.fromEntries(actors.value.map((actor) => [actor.id, actor])));
|
||||
const tagsById = computed(() => Object.fromEntries(tags.value.map((tag) => [tag.id, tag])));
|
||||
const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) => [movie.id, movie])));
|
||||
|
||||
const feedbacks = ref({});
|
||||
const showReviewed = ref(false);
|
||||
const reviewedRevisions = ref(new Set());
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
tags: tagsById,
|
||||
movies: moviesById,
|
||||
};
|
||||
|
||||
const dateKeys = [
|
||||
'date',
|
||||
'productionDate',
|
||||
'createdAt',
|
||||
];
|
||||
|
||||
const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
|
||||
if (Array.isArray(value) && mappedKeys[key]) {
|
||||
return [key, value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
||||
}))];
|
||||
}
|
||||
|
||||
if (dateKeys.includes(key)) {
|
||||
return [key, new Date(value)];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}));
|
||||
|
||||
const curatedDeltas = revision.deltas.map((delta) => {
|
||||
if (Array.isArray(delta.value) && mappedKeys[delta.key]) {
|
||||
return {
|
||||
...delta,
|
||||
value: delta.value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
|
||||
modified: !revision.base[delta.key].includes(itemId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (dateKeys.includes(delta.key)) {
|
||||
return {
|
||||
...delta,
|
||||
value: new Date(delta.value),
|
||||
};
|
||||
}
|
||||
|
||||
return delta;
|
||||
});
|
||||
|
||||
return {
|
||||
...revision,
|
||||
base: curatedBase,
|
||||
deltas: curatedDeltas,
|
||||
};
|
||||
}));
|
||||
|
||||
async function reloadRevisions() {
|
||||
const updatedRevisions = await get('/revisions', {
|
||||
isFinalized: showReviewed.value ? undefined : false,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
actors.value = updatedRevisions.actors;
|
||||
tags.value = updatedRevisions.tags;
|
||||
movies.value = updatedRevisions.movies;
|
||||
revisions.value = updatedRevisions.revisions;
|
||||
}
|
||||
|
||||
async function reviewRevision(revision, isApproved) {
|
||||
reviewedRevisions.value.add(revision.id);
|
||||
|
||||
try {
|
||||
await post(`/revisions/${revision.id}/reviews`, {
|
||||
isApproved,
|
||||
feedback: feedbacks.value[revision.id],
|
||||
});
|
||||
|
||||
const updatedRevision = await get(`/revisions/${revision.id}`, {
|
||||
revisionId: revision.id,
|
||||
});
|
||||
|
||||
revisions.value = revisions.value.map((rev) => (rev.id === updatedRevision.revision.id ? updatedRevision.revision : rev));
|
||||
} catch (error) {
|
||||
reviewedRevisions.value.delete(revision.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function banEditor(revision) {
|
||||
console.log('ban!', revision);
|
||||
|
||||
await post('/bans', {
|
||||
userId: revision.user.id,
|
||||
banIp: true,
|
||||
});
|
||||
|
||||
await reviewRevision(revision, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.revs-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.check-container {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.revs {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-x: auto;
|
||||
padding: 3px; /* prevent shadow from getting cut off */
|
||||
}
|
||||
|
||||
.rev {
|
||||
min-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background);
|
||||
border-radius: .25rem;
|
||||
margin-bottom: .5rem;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
font-size: .9rem;
|
||||
|
||||
&.reviewed {
|
||||
pointer-events: none;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--primary-light-20);
|
||||
}
|
||||
}
|
||||
|
||||
.rev-link {
|
||||
color: var(--glass-strong-10);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.rev-header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rev-scene {
|
||||
width: 9rem;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rev-title {
|
||||
color: inherit;
|
||||
padding: .5rem 0;
|
||||
}
|
||||
|
||||
.rev-details {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.rev-username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rev-actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.icon {
|
||||
height: 100%;
|
||||
padding: 0 1.5rem;
|
||||
fill: var(--glass);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.review-approve {
|
||||
fill: var(--success);
|
||||
|
||||
&:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
|
||||
.review-reject {
|
||||
fill: var(--error);
|
||||
|
||||
&:hover {
|
||||
background: var(--error);
|
||||
}
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.approved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--success);
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.rejected {
|
||||
color: var(--error);
|
||||
}
|
||||
}
|
||||
|
||||
.rev-deltas {
|
||||
flex-grow: 1;
|
||||
padding: 0;
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
.delta {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: .15rem .5rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 1px var(--glass-weak-40);
|
||||
}
|
||||
}
|
||||
|
||||
.delta-key {
|
||||
width: 8.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delta-deltas {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.delta-from {
|
||||
width: 40%;
|
||||
flex-shrink: 0;
|
||||
color: var(--reject);
|
||||
padding: .25rem 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.delta-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--glass-weak-10);
|
||||
}
|
||||
|
||||
.delta-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delta-to {
|
||||
flex-grow: 1;
|
||||
color: var(--approve);
|
||||
}
|
||||
|
||||
.delta-item {
|
||||
line-height: 1.5;
|
||||
|
||||
&:not(:last-child):after {
|
||||
content: ',\00a0';
|
||||
}
|
||||
|
||||
&.modified {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.rev-comment {
|
||||
padding: .5rem .5rem;
|
||||
border-top: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<ul
|
||||
class="tags nolist"
|
||||
:class="{ disabled: !editing.has(item.key) }"
|
||||
>
|
||||
<li
|
||||
v-for="tag in [...item.value, ...newTags]"
|
||||
:key="`tag-${tag.id}`"
|
||||
class="tag"
|
||||
:class="{ deleted: edits.tags && !edits.tags.some((tagId) => tagId === tag.id) }"
|
||||
>
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="edits.tags && !edits.tags.some((tagId) => tagId === tag.id)"
|
||||
icon="checkmark"
|
||||
class="add"
|
||||
@click="emit('tags', edits.tags.concat(tag.id))"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
icon="cross2"
|
||||
class="remove"
|
||||
@click="emit('tags', edits.tags.filter((tagId) => tagId !== tag.id))"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li class="new">
|
||||
<TagSearch
|
||||
:disabled="!editing.has(item.key)"
|
||||
@tag="addTag"
|
||||
>
|
||||
<Icon
|
||||
icon="plus3"
|
||||
class="add"
|
||||
/>
|
||||
</TagSearch>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import TagSearch from '#/components/tags/search.vue';
|
||||
|
||||
const newTags = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
scene: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
edits: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
editing: {
|
||||
type: Set,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['tags']);
|
||||
|
||||
function addTag(tag) {
|
||||
newTags.value = newTags.value.concat(tag);
|
||||
|
||||
emit('tags', props.edits.tags.concat(tag.id));
|
||||
}
|
||||
|
||||
watch(() => props.scene, () => { newTags.value = []; });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
|
||||
&.disabled {
|
||||
.tag {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--glass-strong-10);
|
||||
|
||||
.remove,
|
||||
.add {
|
||||
fill: var(--shadow-weak-30);
|
||||
background: var(--shadow-weak-50);
|
||||
}
|
||||
}
|
||||
|
||||
.new .icon {
|
||||
background: var(--shadow-weak-40);
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: .25rem;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 3px var(--shadow-weak-20);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 100%;
|
||||
padding: 0 .5rem;
|
||||
background: var(--success);
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: .25rem;
|
||||
background: var(--background);
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
|
||||
&.deleted {
|
||||
color: var(--glass);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.tag,
|
||||
.new {
|
||||
.remove,
|
||||
.add {
|
||||
height: auto;
|
||||
padding: .25rem .3rem;
|
||||
border-radius: .25rem;
|
||||
fill: var(--highlight-strong-10);
|
||||
|
||||
&:hover {
|
||||
fill: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
fill: var(--error);
|
||||
|
||||
&:hover {
|
||||
background: var(--error);
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
fill: var(--success);
|
||||
|
||||
&:hover {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
</style>
|
|
@ -11,6 +11,7 @@
|
|||
:checked="checked"
|
||||
type="checkbox"
|
||||
class="check-checkbox"
|
||||
:disabled="disabled"
|
||||
@change="$emit('change', $event.target.checked)"
|
||||
>
|
||||
|
||||
|
@ -33,6 +34,10 @@ defineProps({
|
|||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['change']);
|
||||
|
@ -98,6 +103,10 @@ defineEmits(['change']);
|
|||
}
|
||||
}
|
||||
|
||||
.check-checkbox:disabled + .check {
|
||||
background: var(--shadow);
|
||||
}
|
||||
|
||||
.check-container.minus .check-checkbox:checked + .check {
|
||||
background: var(--error);
|
||||
|
||||
|
@ -108,7 +117,6 @@ defineEmits(['change']);
|
|||
|
||||
.check-label {
|
||||
overflow: hidden;
|
||||
text-transform: capitalize;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 .5rem 0 0;
|
||||
}
|
||||
|
|
|
@ -189,6 +189,20 @@
|
|||
Settings
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user?.role === 'admin'"
|
||||
v-close-popper
|
||||
class="menu-item"
|
||||
>
|
||||
<a
|
||||
href="/admin"
|
||||
class="menu-button nolink favorites"
|
||||
>
|
||||
<Icon icon="wrench" />
|
||||
Admin
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="theme === 'dark'"
|
||||
v-close-popper
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<VDropdown
|
||||
:disabled="disabled"
|
||||
class="trigger"
|
||||
@show="focus"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<template #popper>
|
||||
<div>
|
||||
<input
|
||||
ref="queryInput"
|
||||
v-model="query"
|
||||
placeholder="Search movie"
|
||||
class="input"
|
||||
@input="search"
|
||||
>
|
||||
|
||||
<ul class="movies nolist">
|
||||
<li
|
||||
v-for="movie in movies"
|
||||
:key="`movie-${movie.id}`"
|
||||
v-close-popper
|
||||
class="movie"
|
||||
@click="emit('movie', movie)"
|
||||
>{{ movie.title }} ({{ [format(movie.effectiveDate, 'yyyy')].filter(Boolean).join(', ') }})
|
||||
<img
|
||||
v-if="movie.covers.length > 0"
|
||||
:src="getPath(movie.covers[0], 'thumbnail')"
|
||||
class="avatar"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
import getPath from '#/src/get-path.js';
|
||||
|
||||
const movies = ref([]);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['movie']);
|
||||
|
||||
async function search() {
|
||||
const data = await get('/movies', { q: query.value });
|
||||
|
||||
movies.value = data.movies;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.movie {
|
||||
display: block;
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: fixed;
|
||||
display: none;
|
||||
left: 7rem;
|
||||
width: 8rem;
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-10);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<VDropdown
|
||||
:disabled="disabled"
|
||||
class="trigger"
|
||||
@show="focus"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<template #popper>
|
||||
<div>
|
||||
<input
|
||||
ref="queryInput"
|
||||
v-model="query"
|
||||
placeholder="Search tag"
|
||||
class="input"
|
||||
@input="search"
|
||||
>
|
||||
|
||||
<ul class="tags nolist">
|
||||
<li
|
||||
v-for="tag in tags"
|
||||
:key="`tag-${tag.slug}`"
|
||||
v-close-popper
|
||||
class="tag"
|
||||
@click="emit('tag', tag)"
|
||||
>{{ tag.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const tagNames = {
|
||||
dp: 'double penetration',
|
||||
};
|
||||
|
||||
const defaultTags = pageContext.pageProps.tagIds
|
||||
? Object.entries(pageContext.pageProps.tagIds).map(([slug, id]) => ({
|
||||
id,
|
||||
slug,
|
||||
name: tagNames[slug] || slug,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const tags = ref(defaultTags);
|
||||
const query = ref(null);
|
||||
const queryInput = ref(null);
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['tag']);
|
||||
|
||||
async function search() {
|
||||
tags.value = await get('/tags', { query: query.value });
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
queryInput.value?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: block;
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-weak-50);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -63,12 +63,18 @@ module.exports = {
|
|||
usernameLength: [2, 24],
|
||||
usernamePattern: /^[a-zA-Z0-9_-]+$/,
|
||||
},
|
||||
bans: {
|
||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||
},
|
||||
apiAccess: {
|
||||
graphqlEnabled: true,
|
||||
keySize: 24, // bytes
|
||||
keyLimit: 5, // max keys per user
|
||||
keyCooldown: 1, // minutes between key generation
|
||||
},
|
||||
revisions: {
|
||||
unapprovedLimit: 3,
|
||||
},
|
||||
psa: {
|
||||
text: 'Welcome to traxxx!', // html enabled
|
||||
type: 'notice', // notice, alert
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'newtraxxx',
|
||||
name: 'traxxx',
|
||||
script: 'npm',
|
||||
args: 'run server:prod',
|
||||
exec_mode: 'cluster',
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "traxxx-web",
|
||||
"version": "0.35.3",
|
||||
"version": "0.36.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"version": "0.35.3",
|
||||
"version": "0.36.0",
|
||||
"dependencies": {
|
||||
"@brillout/json-serializer": "^0.5.8",
|
||||
"@dicebear/collection": "^7.0.5",
|
||||
|
@ -43,6 +43,7 @@
|
|||
"manticoresearch": "^4.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"mathjs": "^12.2.1",
|
||||
"merkle-json": "^2.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"mysql": "^2.18.1",
|
||||
"nanoid": "^5.0.4",
|
||||
|
@ -7324,6 +7325,17 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/merkle-json": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/merkle-json/-/merkle-json-2.6.0.tgz",
|
||||
"integrity": "sha512-sJM+SNINn3/5GzFyY8MMCj+647UbDVcZv3wcynX1vv9Vhnm1gWGI5ZPOA+EYm3iInITyQHKnmcpYKqZkeY+iAQ==",
|
||||
"dependencies": {
|
||||
"merkle-json": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
|
@ -15647,6 +15659,14 @@
|
|||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
|
||||
},
|
||||
"merkle-json": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/merkle-json/-/merkle-json-2.6.0.tgz",
|
||||
"integrity": "sha512-sJM+SNINn3/5GzFyY8MMCj+647UbDVcZv3wcynX1vv9Vhnm1gWGI5ZPOA+EYm3iInITyQHKnmcpYKqZkeY+iAQ==",
|
||||
"requires": {
|
||||
"merkle-json": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
"manticoresearch": "^4.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"mathjs": "^12.2.1",
|
||||
"merkle-json": "^2.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"mysql": "^2.18.1",
|
||||
"nanoid": "^5.0.4",
|
||||
|
@ -79,5 +80,5 @@
|
|||
"postcss-custom-media": "^10.0.2",
|
||||
"postcss-nesting": "^12.0.2"
|
||||
},
|
||||
"version": "0.35.3"
|
||||
"version": "0.36.0"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<Admin>
|
||||
<h2 class="heading">Admin Panel</h2>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
</script>
|
|
@ -0,0 +1,13 @@
|
|||
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||
|
||||
export function onBeforeRender(pageContext) {
|
||||
if (pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: pageContext.routeParams.section,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<Admin class="page">
|
||||
<Revisions
|
||||
:interactive="true"
|
||||
/>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
import Revisions from '#/components/edit/revisions.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,30 @@
|
|||
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||
import { fetchSceneRevisions } from '#/src/scenes.js';
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
const {
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
isFinalized: false,
|
||||
limit: 50,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: pageContext.routeParams.section,
|
||||
pageProps: {
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export default '/admin/@section/*';
|
|
@ -196,6 +196,16 @@
|
|||
{{ scene.shootId }}
|
||||
</div>
|
||||
|
||||
<time
|
||||
v-if="scene.productionDate"
|
||||
:datetime="formatDate(scene.productionDate, 'yyyy-MM-dd')"
|
||||
:title="formatDate(scene.productionDate, 'yyyy-MM-dd')"
|
||||
class="detail"
|
||||
>
|
||||
<h3 class="heading">Shoot date</h3>
|
||||
{{ formatDate(scene.productionDate, 'MMMM d, yyyy') }}
|
||||
</time>
|
||||
|
||||
<div
|
||||
v-if="scene.studio"
|
||||
class="detail"
|
||||
|
@ -276,7 +286,7 @@
|
|||
v-if="user"
|
||||
class="icon-link"
|
||||
target="_blank"
|
||||
:href="`/user/${user.username}/summaries?t=${selectedTemplate}`"
|
||||
:href="`/user/${user.username}/templates?t=${selectedTemplate}`"
|
||||
>
|
||||
<Icon
|
||||
v-tooltip="'Edit templates'"
|
||||
|
@ -309,6 +319,23 @@
|
|||
>{{ userTemplate.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="user && user.role !== 'user'"
|
||||
class="scene-actions section"
|
||||
>
|
||||
<a
|
||||
:href="`/scene/edit/${scene.id}`"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Edit scene</a>
|
||||
|
||||
<a
|
||||
:href="`/scene/revisions/${scene.id}/${scene.slug}`"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Revisions</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -660,6 +687,13 @@ function copySummary() {
|
|||
}
|
||||
}
|
||||
|
||||
.scene-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
display: flex;
|
||||
height: auto;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||
import { render, redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
||||
import { fetchScenesById } from '#/src/scenes.js';
|
||||
import { getRandomCampaigns } from '#/src/campaigns.js';
|
||||
import { getIdsBySlug } from '#/src/cache.js';
|
||||
|
||||
function getTitle(scene) {
|
||||
if (scene.title) {
|
||||
|
@ -15,6 +16,10 @@ function getTitle(scene) {
|
|||
}
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
if (pageContext._pageId === '/pages/scene/edit' && !pageContext.user) {
|
||||
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
|
||||
}
|
||||
|
||||
const [scene] = await fetchScenesById([Number(pageContext.routeParams.sceneId)], {
|
||||
reqUser: pageContext.user,
|
||||
includeAssets: true,
|
||||
|
@ -22,13 +27,21 @@ export async function onBeforeRender(pageContext) {
|
|||
actorStashes: true,
|
||||
});
|
||||
|
||||
const campaigns = await getRandomCampaigns([
|
||||
{
|
||||
minRatio: 1.5,
|
||||
entityIds: [scene.channel.id, scene.network?.id].filter(Boolean),
|
||||
allowRandomFallback: false,
|
||||
},
|
||||
], { tagFilter: pageContext.tagFilter });
|
||||
const [campaigns, tagIds] = await Promise.all([
|
||||
getRandomCampaigns([
|
||||
{
|
||||
minRatio: 1.5,
|
||||
entityIds: [scene.channel.id, scene.network?.id].filter(Boolean),
|
||||
allowRandomFallback: false,
|
||||
},
|
||||
], { tagFilter: pageContext.tagFilter }),
|
||||
getIdsBySlug([
|
||||
'anal',
|
||||
'creampie',
|
||||
'dp',
|
||||
'facial',
|
||||
], 'tags', true),
|
||||
]);
|
||||
|
||||
if (!scene) {
|
||||
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
|
||||
|
@ -39,6 +52,7 @@ export async function onBeforeRender(pageContext) {
|
|||
title: getTitle(scene),
|
||||
pageProps: {
|
||||
scene,
|
||||
tagIds,
|
||||
},
|
||||
campaigns: {
|
||||
scene: campaigns[0],
|
||||
|
|
|
@ -0,0 +1,482 @@
|
|||
<template>
|
||||
<div class="editor">
|
||||
<p
|
||||
v-if="submitted"
|
||||
class="submitted"
|
||||
>
|
||||
<template v-if="apply">Your revision has been submitted. Thank you for your contribution!</template>
|
||||
<template v-else>Your revision has been submitted for review. Thank you for your contribution!</template>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
:href="`/scene/${scene.id}/${scene.slug}`"
|
||||
class="link"
|
||||
>Return to scene</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
:href="`/scene/edit/${scene.id}`"
|
||||
class="link"
|
||||
>Make another edit</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
:href="`/user/${user.username}`"
|
||||
class="link"
|
||||
>Go to profile</a>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-else
|
||||
@submit.prevent
|
||||
>
|
||||
<div class="editor-header">
|
||||
<h2 class="heading ellipsis">Edit scene #{{ scene.id }}</h2>
|
||||
|
||||
<a
|
||||
:href="`/scene/${scene.id}/${scene.slug}`"
|
||||
target="_blank"
|
||||
class="link noshrink"
|
||||
>Go to scene</a>
|
||||
</div>
|
||||
|
||||
<ul class="nolist">
|
||||
<li
|
||||
v-for="item in fields"
|
||||
:key="`item-${item.key}`"
|
||||
class="row"
|
||||
>
|
||||
<div class="item-header">
|
||||
<div class="key">{{ item.label || item.key }}</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<Icon
|
||||
v-if="!item.forced"
|
||||
icon="pencil5"
|
||||
:class="{ active: editing.has(item.key) }"
|
||||
@click="toggleField(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="value"
|
||||
:class="{ disabled: !editing.has(item.key) }"
|
||||
>
|
||||
<EditActors
|
||||
v-if="item.type === 'actors'"
|
||||
:scene="scene"
|
||||
:item="item"
|
||||
:edits="edits"
|
||||
:editing="editing"
|
||||
@actors="(actors) => { edits.actors = actors; }"
|
||||
/>
|
||||
|
||||
<EditTags
|
||||
v-if="item.type === 'tags'"
|
||||
:scene="scene"
|
||||
:item="item"
|
||||
:edits="edits"
|
||||
:editing="editing"
|
||||
@tags="(tags) => { edits.tags = tags; }"
|
||||
/>
|
||||
|
||||
<EditMovies
|
||||
v-if="item.type === 'movies'"
|
||||
:scene="scene"
|
||||
:item="item"
|
||||
:edits="edits"
|
||||
:editing="editing"
|
||||
@movies="(movies) => { edits.movies = movies; }"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="item.type === 'string'"
|
||||
:value="edits[item.key] || item.value"
|
||||
class="string input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setValue(item, $event)"
|
||||
>
|
||||
|
||||
<textarea
|
||||
v-if="item.type === 'text'"
|
||||
:value="edits[item.key] || item.value"
|
||||
:placeholder="item.placeholder"
|
||||
rows="3"
|
||||
class="text input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setValue(item, $event)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="item.type === 'date'"
|
||||
type="datetime-local"
|
||||
:value="edits[item.key] || item.value"
|
||||
class="date input"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setValue(item, $event)"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="item.type === 'duration'"
|
||||
class="duration"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
:value="item.value[0]"
|
||||
min="0"
|
||||
max="100"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setDuration('h', $event)"
|
||||
>H
|
||||
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
:value="item.value[1]"
|
||||
min="0"
|
||||
max="59"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setDuration('m', $event)"
|
||||
>M
|
||||
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
:value="item.value[2]"
|
||||
min="0"
|
||||
max="59"
|
||||
:disabled="!editing.has(item.key)"
|
||||
@input="setDuration('s', $event)"
|
||||
>S
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="comment">
|
||||
<textarea
|
||||
v-model="comment"
|
||||
rows="3"
|
||||
placeholder="Please provide verifiable information supporting your edits."
|
||||
class="text input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<Checkbox
|
||||
v-if="user.role !== 'user'"
|
||||
label="Approve and apply immediately"
|
||||
:checked="apply"
|
||||
:disabled="editing.size === 0"
|
||||
@change="(checked) => apply = checked"
|
||||
/>
|
||||
|
||||
<!-- we don't want the return key to submit the form -->
|
||||
<button
|
||||
class="button button-primary"
|
||||
type="button"
|
||||
:disabled="editing.size === 0"
|
||||
@click="submit"
|
||||
>
|
||||
<template v-if="apply">Submit</template>
|
||||
<template v-else>Submit for review</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import EditActors from '#/components/edit/actors.vue';
|
||||
import EditTags from '#/components/edit/tags.vue';
|
||||
import EditMovies from '#/components/edit/movies.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import {
|
||||
// get,
|
||||
post,
|
||||
} from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
const user = pageContext.user;
|
||||
const scene = ref(pageContext.pageProps.scene);
|
||||
|
||||
// console.log(scene);
|
||||
|
||||
const fields = computed(() => [
|
||||
{
|
||||
key: 'actors',
|
||||
type: 'actors',
|
||||
value: scene.value.actors,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
type: 'tags',
|
||||
value: scene.value.tags,
|
||||
},
|
||||
{
|
||||
key: 'movies',
|
||||
type: 'movies',
|
||||
value: scene.value.movies,
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
type: 'string',
|
||||
value: scene.value.title,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
type: 'text',
|
||||
value: scene.value.description,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
type: 'date',
|
||||
value: scene.value.date
|
||||
? format(scene.value.date, 'yyyy-MM-dd hh:mm')
|
||||
: null,
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
type: 'duration',
|
||||
value: [Math.floor(scene.value.duration / 3600), Math.floor((scene.value.duration % 3600) / 60), scene.value.duration % 60],
|
||||
},
|
||||
{
|
||||
key: 'productionDate',
|
||||
label: 'production date',
|
||||
type: 'date',
|
||||
value: scene.value.productionDate
|
||||
? format(scene.value.productionDate, 'yyyy-MM-dd hh:mm')
|
||||
: null,
|
||||
},
|
||||
...(user.role === 'user'
|
||||
? []
|
||||
: [{
|
||||
key: 'comment',
|
||||
type: 'text',
|
||||
placeholder: 'Do NOT use this field to summarize and clarify your revision. This field is for permanent notes and comments regarding the scene or database entry itself.',
|
||||
value: scene.value.comment,
|
||||
}]),
|
||||
]);
|
||||
|
||||
const editing = ref(new Set());
|
||||
const edits = ref({});
|
||||
const comment = ref(null);
|
||||
const apply = ref(user.role !== 'user');
|
||||
const submitted = ref(false);
|
||||
|
||||
function toggleField(item) {
|
||||
if (editing.value.has(item.key)) {
|
||||
editing.value.delete(item.key);
|
||||
delete edits.value[item.key];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editing.value.add(item.key);
|
||||
|
||||
if (Array.isArray(item.value)) {
|
||||
edits.value[item.key] = item.value.map((value) => value.hash || value.id);
|
||||
return;
|
||||
}
|
||||
|
||||
edits.value[item.key] = item.value;
|
||||
}
|
||||
|
||||
function setValue(item, event) {
|
||||
edits.value[item.key] = event.target.value;
|
||||
|
||||
console.log(edits.value);
|
||||
}
|
||||
|
||||
const timeUnits = ['h', 'm', 's'];
|
||||
|
||||
function setDuration(unit, event) {
|
||||
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await post('/revisions', {
|
||||
sceneId: scene.value.id,
|
||||
edits: {
|
||||
...edits.value,
|
||||
duration: edits.value.duration
|
||||
? (edits.value.duration[0] * 3600) + (edits.value.duration[1] * 60) + (edits.value.duration[2])
|
||||
: undefined,
|
||||
},
|
||||
comment: comment.value,
|
||||
apply: apply.value,
|
||||
}, {
|
||||
successFeedback: 'Your revision has been submitted for approval.',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
editing.value = new Set();
|
||||
edits.value = {};
|
||||
comment.value = null;
|
||||
|
||||
submitted.value = true;
|
||||
|
||||
// scene.value = await get(`/scenes/${scene.value.id}`);
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
flex-grow: 1;
|
||||
background: var(--background-dark-10);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .25rem 1rem;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 8rem;
|
||||
text-transform: capitalize;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-grow: 1;
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
|
||||
&:disabled {
|
||||
color: var(--glass-strong-10);
|
||||
background: none;
|
||||
border: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
}
|
||||
|
||||
.duration {
|
||||
.input {
|
||||
width: 5rem;
|
||||
margin-right: .25rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
.icon {
|
||||
padding: .25rem 1rem;
|
||||
fill: var(--glass);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
fill: var(--text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
border-top: solid 1px var(--primary-light-30);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.comment {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin: .5rem 0;
|
||||
|
||||
.button {
|
||||
padding: .5rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.submitted {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media(--small) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1 @@
|
|||
export default '/scene/edit/@sceneId/*';
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="content">
|
||||
<div class="revs-header">
|
||||
<h2 class="heading">Revisions for "{{ scene.title }}"</h2>
|
||||
|
||||
<a
|
||||
:href="`/scene/${scene.id}/${scene.slug}`"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Go to scene</a>
|
||||
</div>
|
||||
|
||||
<Revisions />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Revisions from '#/components/edit/revisions.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const scene = pageContext.pageProps.scene;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
padding: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.revs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,34 @@
|
|||
import { fetchScenesById, fetchSceneRevisions } from '#/src/scenes.js';
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [scene] = await fetchScenesById([Number(pageContext.routeParams.sceneId)], {
|
||||
reqUser: pageContext.user,
|
||||
includeAssets: true,
|
||||
includePartOf: true,
|
||||
actorStashes: true,
|
||||
});
|
||||
|
||||
const {
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = await fetchSceneRevisions(null, {
|
||||
sceneId: scene.id,
|
||||
isFinalized: true,
|
||||
limit: 100,
|
||||
}, pageContext.user);
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: `Revisions for '${scene.title}'`,
|
||||
pageProps: {
|
||||
scene,
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export default '/scene/revisions/@sceneId/*';
|
|
@ -36,6 +36,12 @@
|
|||
class="domain nolink"
|
||||
:class="{ active: domain === 'templates' }"
|
||||
>Templates</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions`"
|
||||
class="domain nolink"
|
||||
:class="{ active: domain === 'revisions' }"
|
||||
>Revisions</a>
|
||||
</nav>
|
||||
|
||||
<Stashes v-if="domain === 'stashes'" />
|
||||
|
@ -46,6 +52,14 @@
|
|||
:release="mockupRelease"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="domain === 'revisions' && profile.id === user?.id"
|
||||
class="profile-section revisions"
|
||||
>
|
||||
<h3 class="section-header heading">Revisions</h3>
|
||||
<Revisions />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -56,6 +70,7 @@ import { formatDistanceStrict } from 'date-fns';
|
|||
import Stashes from '#/components/stashes/stashes.vue';
|
||||
import Alerts from '#/components/alerts/alerts.vue';
|
||||
import Summaries from '#/components/scenes/summaries.vue';
|
||||
import Revisions from '#/components/edit/revisions.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const domain = pageContext.routeParams.domain;
|
||||
|
@ -125,8 +140,9 @@ const mockupRelease = {
|
|||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
background: var(--background-base-10);
|
||||
}
|
||||
|
||||
|
@ -200,6 +216,12 @@ const mockupRelease = {
|
|||
}
|
||||
}
|
||||
|
||||
.revisions {
|
||||
width: 100%; /* necessary for FF */
|
||||
box-sizing: border-box;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media(--compact) {
|
||||
.domains {
|
||||
padding: .5rem 1rem;
|
||||
|
|
|
@ -3,19 +3,33 @@ import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
|
|||
import { fetchUser } from '#/src/users.js';
|
||||
import { fetchUserStashes } from '#/src/stashes.js';
|
||||
import { fetchAlerts } from '#/src/alerts.js';
|
||||
import { fetchSceneRevisions } from '#/src/scenes.js';
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [profile, alerts] = await Promise.all([
|
||||
const [profile, alerts, userRevisions] = await Promise.all([
|
||||
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
|
||||
pageContext.routeParams.username === pageContext.user?.username
|
||||
pageContext.routeParams.domain === 'stashes' && pageContext.routeParams.username === pageContext.user?.username
|
||||
? fetchAlerts(pageContext.user)
|
||||
: [],
|
||||
pageContext.routeParams.domain === 'revisions' && pageContext.routeParams.username === pageContext.user?.username
|
||||
? fetchSceneRevisions(null, {
|
||||
userId: pageContext.user.id,
|
||||
limit: 100,
|
||||
}, pageContext.user)
|
||||
: {},
|
||||
]);
|
||||
|
||||
if (!profile) {
|
||||
throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`);
|
||||
}
|
||||
|
||||
const {
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
} = userRevisions;
|
||||
|
||||
const stashes = await fetchUserStashes(profile.id, pageContext.user);
|
||||
|
||||
return {
|
||||
|
@ -25,6 +39,10 @@ export async function onBeforeRender(pageContext) {
|
|||
profile, // differentiate from authed 'user'
|
||||
stashes,
|
||||
alerts,
|
||||
revisions,
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -46,7 +46,6 @@ async function onRenderHtml(pageContext) {
|
|||
// See https://vike.dev/head
|
||||
const { documentProps } = pageContext.exports;
|
||||
const title = getTitle(documentProps?.title || pageContext.title);
|
||||
const desc = (documentProps && documentProps.description) || 'traxxx';
|
||||
|
||||
const documentHtml = escapeInject`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
@ -68,7 +67,8 @@ async function onRenderHtml(pageContext) {
|
|||
<meta property="og:image" content="https://traxxx.me/img/og_logo.png" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
|
||||
<meta name="description" content="${desc}" />
|
||||
|
||||
<meta name="description" content="Keep track of new porn releases and re-discover classics from your favorite porn stars and sites" />
|
||||
|
||||
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" async></script>`) : ''}
|
||||
|
||||
|
|
|
@ -218,7 +218,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||
}
|
||||
|
||||
if (filters.query) {
|
||||
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
|
||||
if (filters.query.charAt(0) === '#') {
|
||||
builder.where('id', Number(escape(filters.query.slice(1))));
|
||||
} else {
|
||||
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
|
||||
}
|
||||
}
|
||||
|
||||
// attribute filters
|
||||
|
|
|
@ -112,6 +112,7 @@ export async function patch(path, data, options = {}) {
|
|||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
showFeedback(true, options);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@ export async function login(credentials, userIp) {
|
|||
|
||||
await knex('users')
|
||||
.update('last_login', 'NOW()')
|
||||
.update('last_ip', userIp)
|
||||
.where('id', user.id);
|
||||
|
||||
logger.verbose(`Login from '${user.username}' (${user.id}, ${userIp})`);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import redis from './redis.js';
|
||||
|
||||
export async function getIdsBySlug(slugs, domain) {
|
||||
export async function getIdsBySlug(slugs, domain, toMap) {
|
||||
if (!slugs) {
|
||||
return [];
|
||||
}
|
||||
|
@ -21,5 +21,9 @@ export async function getIdsBySlug(slugs, domain) {
|
|||
return Number(id);
|
||||
}));
|
||||
|
||||
if (toMap) {
|
||||
return Object.fromEntries(slugs.map((slug, index) => [slug, ids[index]]));
|
||||
}
|
||||
|
||||
return ids.filter(Boolean);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ export function curateMedia(media, context = {}) {
|
|||
|
||||
return {
|
||||
id: media.id,
|
||||
hash: media.hash,
|
||||
path: media.path,
|
||||
thumbnail: media.thumbnail,
|
||||
lazy: media.lazy,
|
||||
|
|
325
src/scenes.js
|
@ -1,16 +1,22 @@
|
|||
import config from 'config';
|
||||
import util from 'util'; /* eslint-disable-line no-unused-vars */
|
||||
import { MerkleJson } from 'merkle-json';
|
||||
|
||||
import { knexQuery as knex, knexOwner, knexManticore } from './knex.js';
|
||||
import { utilsApi } from './manticore.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
|
||||
import { fetchTagsById } from './tags.js';
|
||||
import { fetchMoviesById } from './movies.js';
|
||||
import { fetchEntitiesById } from './entities.js';
|
||||
import { curateStash } from './stashes.js';
|
||||
import { curateMedia } from './media.js';
|
||||
import escape from '../utils/escape-manticore.js';
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import initLogger from './logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
const mj = new MerkleJson();
|
||||
|
||||
function getWatchUrl(scene) {
|
||||
if (scene.url) {
|
||||
|
@ -64,6 +70,7 @@ function curateScene(rawScene, assets) {
|
|||
description: rawScene.description,
|
||||
duration: rawScene.duration,
|
||||
shootId: rawScene.shoot_id,
|
||||
productionDate: rawScene.production_date,
|
||||
channel: {
|
||||
id: assets.channel.id,
|
||||
slug: assets.channel.slug,
|
||||
|
@ -595,3 +602,321 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
|
|||
limit: options.limit,
|
||||
};
|
||||
}
|
||||
|
||||
function curateRevision(revision) {
|
||||
return {
|
||||
id: revision.id,
|
||||
sceneId: revision.scene_id,
|
||||
base: revision.base,
|
||||
deltas: revision.deltas,
|
||||
hash: revision.hash,
|
||||
comment: revision.comment,
|
||||
user: revision.user_id && {
|
||||
id: revision.user_id,
|
||||
username: revision.username,
|
||||
},
|
||||
review: typeof revision.approved === 'boolean' ? {
|
||||
isApproved: revision.approved,
|
||||
userId: revision.reviewed_by,
|
||||
username: revision.reviewer_username,
|
||||
reviewedAt: revision.reviewed_at,
|
||||
} : null,
|
||||
appliedAt: revision.applied_at,
|
||||
failed: revision.failed,
|
||||
createdAt: revision.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
|
||||
const limit = filters.limit || 50;
|
||||
const page = filters.page || 1;
|
||||
|
||||
const revisions = await knexOwner('scenes_revisions')
|
||||
.select(
|
||||
'scenes_revisions.*',
|
||||
'users.username as username',
|
||||
'reviewers.username as reviewer_username',
|
||||
)
|
||||
.leftJoin('users', 'users.id', 'scenes_revisions.user_id')
|
||||
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
|
||||
.modify((builder) => {
|
||||
if (reqUser?.role !== 'admin' && !filters.userId && !filters.sceneId) {
|
||||
builder.where('user_id', reqUser.id);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
if (reqUser?.role !== 'admin' && filters.userId !== reqUser.id) {
|
||||
throw new HttpError('You are not permitted to view revisions from other users.', 403);
|
||||
}
|
||||
|
||||
builder.where('scenes_revisions.user_id', filters.userId);
|
||||
}
|
||||
|
||||
if (revisionId) {
|
||||
builder.where('scenes_revisions.id', revisionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filters.sceneId) {
|
||||
builder.where('scenes_revisions.scene_id', filters.sceneId);
|
||||
}
|
||||
|
||||
console.log(filters);
|
||||
|
||||
if (filters.isFinalized === false) {
|
||||
builder.whereNull('approved');
|
||||
}
|
||||
|
||||
if (filters.isFinalized === true) {
|
||||
builder.whereNotNull('approved');
|
||||
}
|
||||
})
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
|
||||
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.actors, ...(revision.deltas.find((delta) => delta.key === 'actors')?.value || [])])));
|
||||
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])])));
|
||||
const movieIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.movies, ...(revision.deltas.find((delta) => delta.key === 'movies')?.value || [])])));
|
||||
|
||||
const [actors, tags, movies] = await Promise.all([
|
||||
fetchActorsById(actorIds),
|
||||
fetchTagsById(tagIds),
|
||||
fetchMoviesById(movieIds),
|
||||
]);
|
||||
|
||||
const curatedRevisions = revisions.map((revision) => curateRevision(revision));
|
||||
|
||||
return {
|
||||
revisions: curatedRevisions,
|
||||
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
|
||||
actors,
|
||||
tags,
|
||||
movies,
|
||||
};
|
||||
}
|
||||
|
||||
const keyMap = {
|
||||
productionDate: 'production_date',
|
||||
};
|
||||
|
||||
async function applySceneValueDelta(sceneId, delta, trx) {
|
||||
return knexOwner('releases')
|
||||
.where('id', sceneId)
|
||||
.update(keyMap[delta.key] || delta.key, delta.value)
|
||||
.transacting(trx);
|
||||
}
|
||||
|
||||
async function applySceneActorsDelta(sceneId, delta, trx) {
|
||||
await knexOwner('releases_actors')
|
||||
.where('release_id', sceneId)
|
||||
.delete()
|
||||
.transacting(trx);
|
||||
|
||||
if (delta.value.length > 0) {
|
||||
await knexOwner('releases_actors')
|
||||
.insert(delta.value.map((actorId) => ({
|
||||
release_id: sceneId,
|
||||
actor_id: actorId,
|
||||
})))
|
||||
.transacting(trx);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySceneTagsDelta(sceneId, delta, trx) {
|
||||
// don't remove unidentified tags
|
||||
await knexOwner('releases_tags')
|
||||
.where('release_id', sceneId)
|
||||
.whereNotNull('tag_id')
|
||||
.delete()
|
||||
.transacting(trx);
|
||||
|
||||
if (delta.value.length > 0) {
|
||||
await knexOwner('releases_tags')
|
||||
.insert(delta.value.map((tagId) => ({
|
||||
release_id: sceneId,
|
||||
tag_id: tagId,
|
||||
source: 'editor',
|
||||
})))
|
||||
.transacting(trx);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySceneMoviesDelta(sceneId, delta, trx) {
|
||||
await knexOwner('movies_scenes')
|
||||
.where('scene_id', sceneId)
|
||||
.delete()
|
||||
.transacting(trx);
|
||||
|
||||
if (delta.value.length > 0) {
|
||||
await knexOwner('movies_scenes')
|
||||
.insert(delta.value.map((movieId) => ({
|
||||
scene_id: sceneId,
|
||||
movie_id: movieId,
|
||||
})))
|
||||
.transacting(trx);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySceneRevision(revisionIds) {
|
||||
const revisions = await knexOwner('scenes_revisions')
|
||||
.whereIn('id', revisionIds)
|
||||
.whereNull('applied_at'); // should not re-apply revision that was already applied
|
||||
|
||||
await revisions.reduce(async (chain, revision) => {
|
||||
await chain;
|
||||
|
||||
await knexOwner.transaction(async (trx) => {
|
||||
await revision.deltas.map(async (delta) => {
|
||||
if ([
|
||||
'title',
|
||||
'description',
|
||||
'date',
|
||||
'duration',
|
||||
'productionDate',
|
||||
'productionLocation',
|
||||
'productionCity',
|
||||
'productionState',
|
||||
].includes(delta.key)) {
|
||||
return applySceneValueDelta(revision.scene_id, delta, trx);
|
||||
}
|
||||
|
||||
if (delta.key === 'actors') {
|
||||
return applySceneActorsDelta(revision.scene_id, delta, trx);
|
||||
}
|
||||
|
||||
if (delta.key === 'tags') {
|
||||
return applySceneTagsDelta(revision.scene_id, delta, trx);
|
||||
}
|
||||
|
||||
if (delta.key === 'movies') {
|
||||
return applySceneMoviesDelta(revision.scene_id, delta, trx);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await knexOwner('scenes_revisions')
|
||||
.where('id', revision.id)
|
||||
.update('applied_at', knex.fn.now());
|
||||
|
||||
// await trx.commit();
|
||||
}).catch(async (error) => {
|
||||
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
|
||||
});
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
|
||||
if (!reqUser || reqUser.role === 'user') {
|
||||
throw new HttpError('You are not permitted to approve revisions', 403);
|
||||
}
|
||||
|
||||
if (typeof isApproved !== 'boolean') {
|
||||
throw new HttpError('You must either approve or reject the revision', 400);
|
||||
}
|
||||
|
||||
await knexOwner('scenes_revisions')
|
||||
.where('id', revisionId)
|
||||
.whereRaw('approved is not true') // don't rerun approved and applied revision, must be forked into new revision instead
|
||||
.whereNull('applied_at')
|
||||
.update({
|
||||
approved: isApproved,
|
||||
reviewed_at: knex.fn.now(),
|
||||
reviewed_by: reqUser.id,
|
||||
feedback,
|
||||
});
|
||||
|
||||
if (isApproved) {
|
||||
await applySceneRevision([revisionId]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
|
||||
const [
|
||||
[scene],
|
||||
openRevisions,
|
||||
] = await Promise.all([
|
||||
fetchScenesById([sceneId], {
|
||||
reqUser,
|
||||
includeAssets: true,
|
||||
includePartOf: true,
|
||||
}),
|
||||
knexOwner('scenes_revisions')
|
||||
.where('user_id', reqUser.id)
|
||||
.whereNull('approved'),
|
||||
]);
|
||||
|
||||
if (!scene) {
|
||||
throw new HttpError(`No scene with ID ${sceneId} found to update`, 404);
|
||||
}
|
||||
|
||||
if (openRevisions.length >= config.revisions.unapprovedLimit) {
|
||||
throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429);
|
||||
}
|
||||
|
||||
const baseScene = Object.fromEntries(Object.entries(scene).map(([key, values]) => {
|
||||
if ([
|
||||
'effectiveDate',
|
||||
'isNew',
|
||||
'network',
|
||||
'stashes',
|
||||
'watchUrl',
|
||||
].includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values?.hash) {
|
||||
return [key, values.hash];
|
||||
}
|
||||
|
||||
if (values?.id) {
|
||||
return [key, values.id];
|
||||
}
|
||||
|
||||
if (Array.isArray(values)) {
|
||||
return [key, values.map((value) => value?.hash || value?.id || value)];
|
||||
}
|
||||
|
||||
return [key, values];
|
||||
}).filter(Boolean));
|
||||
|
||||
const deltas = Object.entries(edits).map(([key, value]) => {
|
||||
if (baseScene[key] === value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const valueSet = new Set(value);
|
||||
const baseSet = new Set(baseScene[key]);
|
||||
|
||||
if (valueSet.size === baseSet.size && baseScene[key].every((id) => valueSet.has(id))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { key, value };
|
||||
}).filter(Boolean);
|
||||
|
||||
if (deltas.length === 0) {
|
||||
throw new HttpError('No effective changes provided', 400);
|
||||
}
|
||||
|
||||
const [revisionEntry] = await knexOwner('scenes_revisions')
|
||||
.insert({
|
||||
user_id: reqUser.id,
|
||||
scene_id: scene.id,
|
||||
base: JSON.stringify(baseScene),
|
||||
deltas: JSON.stringify(deltas),
|
||||
hash: mj.hash({
|
||||
base: baseScene,
|
||||
deltas,
|
||||
}),
|
||||
comment,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
if (['admin', 'editor'].includes(reqUser.role) && apply) {
|
||||
await reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,13 +47,17 @@ export async function fetchTags(options = {}) {
|
|||
column: knex.raw('similarity(aliases.slug, :query)', { query }),
|
||||
order: 'desc',
|
||||
},
|
||||
{
|
||||
column: 'aliases.priority',
|
||||
order: 'desc',
|
||||
},
|
||||
{
|
||||
column: 'aliases.slug',
|
||||
order: 'asc',
|
||||
},
|
||||
]);
|
||||
} else if (!options.includeAliases) {
|
||||
builder.whereNull('alias_for');
|
||||
builder.whereNull('tags.alias_for');
|
||||
}
|
||||
}),
|
||||
knex('tags_posters')
|
||||
|
|
20
src/users.js
|
@ -1,3 +1,4 @@
|
|||
import config from 'config';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
|
@ -128,3 +129,22 @@ export async function removeTemplate(templateId, reqUser) {
|
|||
.where('user_id', reqUser.id)
|
||||
.delete();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const targetUser = ban.userId && await knex('users').where('id', ban.userId).first();
|
||||
|
||||
const curatedBan = {
|
||||
user_id: ban.userId,
|
||||
username: ban.username,
|
||||
ip: ban.banIp && targetUser.last_ip,
|
||||
expires_at: knex.raw('now() + make_interval(mins => :minutes)', { minutes: config.bans.defaultExpiry }),
|
||||
};
|
||||
|
||||
await knex('bans').insert(curatedBan);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,11 @@ function getIp(req) {
|
|||
? ip.slice(ip.lastIndexOf(':') + 1)
|
||||
: ip;
|
||||
|
||||
if (!unmappedIp) {
|
||||
console.log('failed unmapped ip', ip, unmappedIp);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ensure IP is in expanded notation for consistency and matching
|
||||
const expandedIp = unmappedIp.includes(':')
|
||||
? new IPCIDR(`${ip}/128`) // IPv6
|
||||
|
|
|
@ -22,6 +22,7 @@ export default async function mainHandler(req, res, next) {
|
|||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
email: req.user.email,
|
||||
role: req.user.role,
|
||||
avatar: req.user.avatar,
|
||||
},
|
||||
assets: req.user ? {
|
||||
|
|
|
@ -1,9 +1,18 @@
|
|||
import Router from 'express-promise-router';
|
||||
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
|
||||
|
||||
import { fetchScenes, fetchScenesById } from '../scenes.js';
|
||||
import {
|
||||
fetchScenes,
|
||||
fetchScenesById,
|
||||
fetchSceneRevisions,
|
||||
createSceneRevision,
|
||||
reviewSceneRevision,
|
||||
} from '../scenes.js';
|
||||
|
||||
import { parseActorIdentifier } from '../query.js';
|
||||
import { getIdsBySlug } from '../cache.js';
|
||||
import slugify from '../../utils/slugify.js';
|
||||
import { HttpError } from '../errors.js';
|
||||
import promiseProps from '../../utils/promise-props.js';
|
||||
|
||||
export async function curateScenesQuery(query) {
|
||||
|
@ -42,7 +51,7 @@ export async function curateScenesQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function fetchScenesApi(req, res) {
|
||||
async function fetchScenesApi(req, res) {
|
||||
const {
|
||||
scenes,
|
||||
aggYears,
|
||||
|
@ -197,6 +206,16 @@ export async function fetchScenesGraphql(query, req) {
|
|||
};
|
||||
}
|
||||
|
||||
async function fetchSceneApi(req, res) {
|
||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user });
|
||||
|
||||
if (!scene) {
|
||||
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
||||
}
|
||||
|
||||
res.send(scene);
|
||||
}
|
||||
|
||||
export async function fetchScenesByIdGraphql(query, req) {
|
||||
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
|
||||
reqUser: req.user,
|
||||
|
@ -209,3 +228,31 @@ export async function fetchScenesByIdGraphql(query, req) {
|
|||
|
||||
return scenes[0];
|
||||
}
|
||||
|
||||
async function fetchSceneRevisionsApi(req, res) {
|
||||
const revisions = await fetchSceneRevisions(Number(req.params.revisionId) || null, req.query, req.user);
|
||||
|
||||
res.send(revisions);
|
||||
}
|
||||
|
||||
async function createSceneRevisionApi(req, res) {
|
||||
await createSceneRevision(Number(req.body.sceneId), req.body, req.user);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function reviewSceneRevisionApi(req, res) {
|
||||
await reviewSceneRevision(Number(req.params.revisionId), req.body.isApproved, req.body, req.user);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export const scenesRouter = Router();
|
||||
|
||||
scenesRouter.get('/api/scenes', fetchScenesApi);
|
||||
scenesRouter.get('/api/scenes/:sceneId', fetchSceneApi);
|
||||
|
||||
scenesRouter.get('/api/revisions', fetchSceneRevisionsApi);
|
||||
scenesRouter.get('/api/revisions/:revisionId', fetchSceneRevisionsApi);
|
||||
scenesRouter.post('/api/revisions', createSceneRevisionApi);
|
||||
scenesRouter.post('/api/revisions/:revisionId/reviews', reviewSceneRevisionApi);
|
||||
|
|
|
@ -13,7 +13,8 @@ import redis from '../redis.js';
|
|||
import errorHandler from './error.js';
|
||||
import consentHandler from './consent.js';
|
||||
|
||||
import { fetchScenesApi } from './scenes.js';
|
||||
import { scenesRouter } from './scenes.js';
|
||||
|
||||
import { fetchActorsApi } from './actors.js';
|
||||
import { fetchMoviesApi } from './movies.js';
|
||||
import { fetchEntitiesApi } from './entities.js';
|
||||
|
@ -34,25 +35,8 @@ import {
|
|||
flushUserKeysApi,
|
||||
} from './auth.js';
|
||||
|
||||
import {
|
||||
fetchUserApi,
|
||||
fetchUserTemplatesApi,
|
||||
createTemplateApi,
|
||||
removeTemplateApi,
|
||||
} from './users.js';
|
||||
|
||||
import {
|
||||
fetchUserStashesApi,
|
||||
createStashApi,
|
||||
removeStashApi,
|
||||
stashActorApi,
|
||||
stashSceneApi,
|
||||
stashMovieApi,
|
||||
unstashActorApi,
|
||||
unstashSceneApi,
|
||||
unstashMovieApi,
|
||||
updateStashApi,
|
||||
} from './stashes.js';
|
||||
import { router as userRouter } from './users.js';
|
||||
import { router as stashesRouter } from './stashes.js';
|
||||
|
||||
import {
|
||||
fetchAlertsApi,
|
||||
|
@ -140,32 +124,12 @@ export default async function initServer() {
|
|||
router.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
router.get('/api/users/:userId', fetchUserApi);
|
||||
router.post('/api/users', signupApi);
|
||||
|
||||
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||
|
||||
// STASHES
|
||||
router.get('/api/users/:userId/stashes', fetchUserStashesApi);
|
||||
router.post('/api/stashes', createStashApi);
|
||||
router.patch('/api/stashes/:stashId', updateStashApi);
|
||||
router.delete('/api/stashes/:stashId', removeStashApi);
|
||||
|
||||
router.post('/api/stashes/:stashId/actors', stashActorApi);
|
||||
router.post('/api/stashes/:stashId/scenes', stashSceneApi);
|
||||
router.post('/api/stashes/:stashId/movies', stashMovieApi);
|
||||
|
||||
router.delete('/api/stashes/:stashId/actors/:actorId', unstashActorApi);
|
||||
router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashSceneApi);
|
||||
router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovieApi);
|
||||
|
||||
// SUMMARY TEMPLATES
|
||||
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
||||
router.post('/api/templates', createTemplateApi);
|
||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||
|
||||
// API KEYS
|
||||
router.get('/api/me/keys', fetchUserKeysApi);
|
||||
router.post('/api/keys', createKeyApi);
|
||||
|
@ -177,8 +141,9 @@ export default async function initServer() {
|
|||
router.post('/api/alerts', createAlertApi);
|
||||
router.delete('/api/alerts/:alertId', removeAlertApi);
|
||||
|
||||
// SCENES
|
||||
router.get('/api/scenes', fetchScenesApi);
|
||||
router.use(userRouter);
|
||||
router.use(stashesRouter);
|
||||
router.use(scenesRouter);
|
||||
|
||||
// ACTORS
|
||||
router.get('/api/actors', fetchActorsApi);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import Router from 'express-promise-router';
|
||||
|
||||
import {
|
||||
fetchUserStashes,
|
||||
createStash,
|
||||
|
@ -70,3 +72,18 @@ export async function unstashMovieApi(req, res) {
|
|||
|
||||
res.send(stashes);
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/api/users/:userId/stashes', fetchUserStashesApi);
|
||||
router.post('/api/stashes', createStashApi);
|
||||
router.patch('/api/stashes/:stashId', updateStashApi);
|
||||
router.delete('/api/stashes/:stashId', removeStashApi);
|
||||
|
||||
router.post('/api/stashes/:stashId/actors', stashActorApi);
|
||||
router.post('/api/stashes/:stashId/scenes', stashSceneApi);
|
||||
router.post('/api/stashes/:stashId/movies', stashMovieApi);
|
||||
|
||||
router.delete('/api/stashes/:stashId/actors/:actorId', unstashActorApi);
|
||||
router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashSceneApi);
|
||||
router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovieApi);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from 'express-promise-router';
|
||||
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
|
||||
|
||||
import {
|
||||
|
@ -5,28 +6,45 @@ import {
|
|||
fetchUserTemplates,
|
||||
createTemplate,
|
||||
removeTemplate,
|
||||
createBan,
|
||||
} from '../users.js';
|
||||
|
||||
export async function fetchUserApi(req, res) {
|
||||
async function fetchUserApi(req, res) {
|
||||
const user = await fetchUser(req.params.userId, {}, req.user);
|
||||
|
||||
res.send(stringify(user));
|
||||
}
|
||||
|
||||
export async function fetchUserTemplatesApi(req, res) {
|
||||
async function fetchUserTemplatesApi(req, res) {
|
||||
const templates = await fetchUserTemplates(req.user);
|
||||
|
||||
res.send(templates);
|
||||
}
|
||||
|
||||
export async function createTemplateApi(req, res) {
|
||||
async function createTemplateApi(req, res) {
|
||||
const template = await createTemplate(req.body, req.user);
|
||||
|
||||
res.send(stringify(template));
|
||||
}
|
||||
|
||||
export async function removeTemplateApi(req, res) {
|
||||
async function removeTemplateApi(req, res) {
|
||||
await removeTemplate(req.params.templateId, req.user);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function createBanApi(req, res) {
|
||||
await createBan(req.body, req.user);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/api/users/:userId', fetchUserApi);
|
||||
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
||||
|
||||
router.post('/api/templates', createTemplateApi);
|
||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||
|
||||
router.post('/api/bans', createBanApi);
|
||||
|
|
2
static
|
@ -1 +1 @@
|
|||
Subproject commit 514a7accf3835913a7c168d34b996bde23dcf2d8
|
||||
Subproject commit cb3f99c5dcc35c9d492658a228709f9e1af29398
|
|
@ -23,7 +23,7 @@ const propProcessors = {
|
|||
.map((actor) => actor.name);
|
||||
},
|
||||
tags: (sceneInfo, options) => sceneInfo.tags
|
||||
.filter((tag) => {
|
||||
?.filter((tag) => {
|
||||
if (options.include && !options.include.includes(tag.slug)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
// vite.config.js
|
||||
import vue from "file:///home/niels/Projects/traxxx-web/node_modules/@vitejs/plugin-vue/dist/index.mjs";
|
||||
import vike from "file:///home/niels/Projects/traxxx-web/node_modules/vike/dist/esm/node/plugin/index.js";
|
||||
import postCssGlobalData from "file:///home/niels/Projects/traxxx-web/node_modules/@csstools/postcss-global-data/dist/index.mjs";
|
||||
import postCssNesting from "file:///home/niels/Projects/traxxx-web/node_modules/postcss-nesting/dist/index.mjs";
|
||||
import postCssCustomMedia from "file:///home/niels/Projects/traxxx-web/node_modules/postcss-custom-media/dist/index.mjs";
|
||||
import ViteYaml from "file:///home/niels/Projects/traxxx-web/node_modules/@modyfi/vite-plugin-yaml/dist/index.js";
|
||||
var __vite_injected_original_dirname = "/home/niels/Projects/traxxx-web";
|
||||
var vite_config_default = {
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
transformAssetUrls: {
|
||||
base: null,
|
||||
includeAbsolute: false
|
||||
}
|
||||
}
|
||||
}),
|
||||
vike({
|
||||
redirects: {
|
||||
"/": "/updates"
|
||||
},
|
||||
trailingSlash: true
|
||||
// for some reason /tags breaks without this, ERR_TOO_MANY_REDIRECTS
|
||||
}),
|
||||
ViteYaml()
|
||||
],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
postCssGlobalData({
|
||||
files: ["./assets/css/breakpoints.css"]
|
||||
}),
|
||||
postCssNesting(),
|
||||
postCssCustomMedia()
|
||||
]
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"#": __vite_injected_original_dirname,
|
||||
"#root": __vite_injected_original_dirname
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9uaWVscy9Qcm9qZWN0cy90cmF4eHgtd2ViXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvaG9tZS9uaWVscy9Qcm9qZWN0cy90cmF4eHgtd2ViL3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9ob21lL25pZWxzL1Byb2plY3RzL3RyYXh4eC13ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgdnVlIGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZSc7XG5pbXBvcnQgdmlrZSBmcm9tICd2aWtlL3BsdWdpbic7IC8vIGVzbGludC1kaXNhYmxlLWxpbmUgaW1wb3J0L2V4dGVuc2lvbnNcblxuaW1wb3J0IHBvc3RDc3NHbG9iYWxEYXRhIGZyb20gJ0Bjc3N0b29scy9wb3N0Y3NzLWdsb2JhbC1kYXRhJztcbmltcG9ydCBwb3N0Q3NzTmVzdGluZyBmcm9tICdwb3N0Y3NzLW5lc3RpbmcnO1xuaW1wb3J0IHBvc3RDc3NDdXN0b21NZWRpYSBmcm9tICdwb3N0Y3NzLWN1c3RvbS1tZWRpYSc7XG5pbXBvcnQgVml0ZVlhbWwgZnJvbSAnQG1vZHlmaS92aXRlLXBsdWdpbi15YW1sJztcblxuZXhwb3J0IGRlZmF1bHQge1xuXHRwbHVnaW5zOiBbXG5cdFx0dnVlKHtcblx0XHRcdHRlbXBsYXRlOiB7XG5cdFx0XHRcdHRyYW5zZm9ybUFzc2V0VXJsczoge1xuXHRcdFx0XHRcdGJhc2U6IG51bGwsXG5cdFx0XHRcdFx0aW5jbHVkZUFic29sdXRlOiBmYWxzZSxcblx0XHRcdFx0fSxcblx0XHRcdH0sXG5cdFx0fSksXG5cdFx0dmlrZSh7XG5cdFx0XHRyZWRpcmVjdHM6IHtcblx0XHRcdFx0Jy8nOiAnL3VwZGF0ZXMnLFxuXHRcdFx0fSxcblx0XHRcdHRyYWlsaW5nU2xhc2g6IHRydWUsIC8vIGZvciBzb21lIHJlYXNvbiAvdGFncyBicmVha3Mgd2l0aG91dCB0aGlzLCBFUlJfVE9PX01BTllfUkVESVJFQ1RTXG5cdFx0fSksXG5cdFx0Vml0ZVlhbWwoKSxcblx0XSxcblx0Y3NzOiB7XG5cdFx0cG9zdGNzczoge1xuXHRcdFx0cGx1Z2luczogW1xuXHRcdFx0XHRwb3N0Q3NzR2xvYmFsRGF0YSh7XG5cdFx0XHRcdFx0ZmlsZXM6IFsnLi9hc3NldHMvY3NzL2JyZWFrcG9pbnRzLmNzcyddLFxuXHRcdFx0XHR9KSxcblx0XHRcdFx0cG9zdENzc05lc3RpbmcoKSxcblx0XHRcdFx0cG9zdENzc0N1c3RvbU1lZGlhKCksXG5cdFx0XHRdLFxuXHRcdH0sXG5cdH0sXG5cdHJlc29sdmU6IHtcblx0XHRhbGlhczoge1xuXHRcdFx0JyMnOiBfX2Rpcm5hbWUsXG5cdFx0XHQnI3Jvb3QnOiBfX2Rpcm5hbWUsXG5cdFx0fSxcblx0fSxcbn07XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQStRLE9BQU8sU0FBUztBQUMvUixPQUFPLFVBQVU7QUFFakIsT0FBTyx1QkFBdUI7QUFDOUIsT0FBTyxvQkFBb0I7QUFDM0IsT0FBTyx3QkFBd0I7QUFDL0IsT0FBTyxjQUFjO0FBTnJCLElBQU0sbUNBQW1DO0FBUXpDLElBQU8sc0JBQVE7QUFBQSxFQUNkLFNBQVM7QUFBQSxJQUNSLElBQUk7QUFBQSxNQUNILFVBQVU7QUFBQSxRQUNULG9CQUFvQjtBQUFBLFVBQ25CLE1BQU07QUFBQSxVQUNOLGlCQUFpQjtBQUFBLFFBQ2xCO0FBQUEsTUFDRDtBQUFBLElBQ0QsQ0FBQztBQUFBLElBQ0QsS0FBSztBQUFBLE1BQ0osV0FBVztBQUFBLFFBQ1YsS0FBSztBQUFBLE1BQ047QUFBQSxNQUNBLGVBQWU7QUFBQTtBQUFBLElBQ2hCLENBQUM7QUFBQSxJQUNELFNBQVM7QUFBQSxFQUNWO0FBQUEsRUFDQSxLQUFLO0FBQUEsSUFDSixTQUFTO0FBQUEsTUFDUixTQUFTO0FBQUEsUUFDUixrQkFBa0I7QUFBQSxVQUNqQixPQUFPLENBQUMsOEJBQThCO0FBQUEsUUFDdkMsQ0FBQztBQUFBLFFBQ0QsZUFBZTtBQUFBLFFBQ2YsbUJBQW1CO0FBQUEsTUFDcEI7QUFBQSxJQUNEO0FBQUEsRUFDRDtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ1IsT0FBTztBQUFBLE1BQ04sS0FBSztBQUFBLE1BQ0wsU0FBUztBQUFBLElBQ1Y7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==
|