Added experimental edit page and revision history.

This commit is contained in:
DebaucheryLibrarian 2024-09-10 02:47:03 +02:00
parent 4b8dfba289
commit 8bf9e22b39
20 changed files with 1177 additions and 14 deletions

View File

@ -57,3 +57,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
.noshrink {
flex-shrink: 0;
}

View File

@ -0,0 +1,115 @@
<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].join(', ') }})
<img
v-if="actor.avatar"
:src="getPath(actor.avatar, 'thumbnail')"
class="avatar"
>
</li>
</ul>
</div>
</template>
</VDropdown>
</template>
<script setup>
import { ref, inject } from 'vue';
import { get } from '#/src/api.js';
import getPath from '#/src/get-path.js';
const pageContext = inject('pageContext');
const actorNames = {
dp: 'double penetration',
};
const defaultActors = pageContext.pageProps.actorIds
? Object.entries(pageContext.pageProps.actorIds).map(([slug, id]) => ({
id,
slug,
name: actorNames[slug] || slug,
}))
: [];
const actors = ref(defaultActors);
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>

153
components/edit/actors.vue Normal file
View File

@ -0,0 +1,153 @@
<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 {
color: var(--shadow);
.remove,
.add {
background: var(--shadow-weak-40);
}
}
.new .icon {
background: var(--shadow-weak-40);
}
}
.new {
display: flex;
align-items: center;
margin-left: .25rem;
.icon {
height: 100%;
padding: 0 .5rem;
fill: var(--text-light);
}
}
}
.actor {
display: flex;
align-items: stretch;
background: var(--glass-weak-30);
border-radius: .25rem;
&.deleted {
color: var(--glass);
text-decoration: line-through;
}
}
.actor,
.new {
.remove,
.add {
height: auto;
padding: .25rem .3rem;
fill: var(--highlight-strong-10);
border-radius: .25rem;
&:hover {
fill: var(--text-light);
cursor: pointer;
}
}
.remove {
background: var(--error);
}
.add {
background: var(--success);
}
}
.actor-name {
padding: .25rem .5rem;
}
</style>

153
components/edit/tags.vue Normal file
View File

@ -0,0 +1,153 @@
<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 {
color: var(--shadow);
.remove,
.add {
background: var(--shadow-weak-40);
}
}
.new .icon {
background: var(--shadow-weak-40);
}
}
.new {
display: flex;
align-items: center;
margin-left: .25rem;
.icon {
height: 100%;
padding: 0 .5rem;
fill: var(--text-light);
}
}
}
.tag {
display: flex;
align-items: stretch;
background: var(--glass-weak-30);
border-radius: .25rem;
&.deleted {
color: var(--glass);
text-decoration: line-through;
}
}
.tag,
.new {
.remove,
.add {
height: auto;
padding: .25rem .3rem;
fill: var(--highlight-strong-10);
border-radius: .25rem;
&:hover {
fill: var(--text-light);
cursor: pointer;
}
}
.remove {
background: var(--error);
}
.add {
background: var(--success);
}
}
.tag-name {
padding: .25rem .5rem;
}
</style>

View File

@ -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>

View File

@ -69,6 +69,9 @@ module.exports = {
keyLimit: 5, // max keys per user keyLimit: 5, // max keys per user
keyCooldown: 1, // minutes between key generation keyCooldown: 1, // minutes between key generation
}, },
revisions: {
unapprovedLimit: 3,
},
psa: { psa: {
text: 'Welcome to traxxx!', // html enabled text: 'Welcome to traxxx!', // html enabled
type: 'notice', // notice, alert type: 'notice', // notice, alert

View File

@ -196,6 +196,16 @@
{{ scene.shootId }} {{ scene.shootId }}
</div> </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 <div
v-if="scene.studio" v-if="scene.studio"
class="detail" class="detail"

View File

@ -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 { fetchScenesById } from '#/src/scenes.js';
import { getRandomCampaigns } from '#/src/campaigns.js'; import { getRandomCampaigns } from '#/src/campaigns.js';
import { getIdsBySlug } from '#/src/cache.js';
function getTitle(scene) { function getTitle(scene) {
if (scene.title) { if (scene.title) {
@ -15,6 +16,10 @@ function getTitle(scene) {
} }
export async function onBeforeRender(pageContext) { 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)], { const [scene] = await fetchScenesById([Number(pageContext.routeParams.sceneId)], {
reqUser: pageContext.user, reqUser: pageContext.user,
includeAssets: true, includeAssets: true,
@ -22,13 +27,21 @@ export async function onBeforeRender(pageContext) {
actorStashes: true, actorStashes: true,
}); });
const campaigns = await getRandomCampaigns([ const [campaigns, tagIds] = await Promise.all([
getRandomCampaigns([
{ {
minRatio: 1.5, minRatio: 1.5,
entityIds: [scene.channel.id, scene.network?.id].filter(Boolean), entityIds: [scene.channel.id, scene.network?.id].filter(Boolean),
allowRandomFallback: false, allowRandomFallback: false,
}, },
], { tagFilter: pageContext.tagFilter }); ], { tagFilter: pageContext.tagFilter }),
getIdsBySlug([
'anal',
'creampie',
'dp',
'facial',
], 'tags', true),
]);
if (!scene) { if (!scene) {
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`); throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
@ -39,6 +52,7 @@ export async function onBeforeRender(pageContext) {
title: getTitle(scene), title: getTitle(scene),
pageProps: { pageProps: {
scene, scene,
tagIds,
}, },
campaigns: { campaigns: {
scene: campaigns[0], scene: campaigns[0],

395
pages/scene/edit/+Page.vue Normal file
View File

@ -0,0 +1,395 @@
<template>
<div class="editor">
<form @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; }"
/>
<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">
<!-- we don't want the return key to submit the form -->
<button
class="button button-primary"
type="button"
:disabled="editing.size === 0"
@click="submit"
>Submit</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 { get, patch } 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: '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);
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;
}
const timeUnits = ['h', 'm', 's'];
function setDuration(unit, event) {
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value);
}
async function submit() {
try {
await patch(`/scenes/${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,
}, {
successFeedback: 'Your revision has been submitted for approval.',
appendErrorMessage: true,
});
editing.value = new Set();
edits.value = {};
comment.value = null;
scene.value = await get(`/scenes/${scene.value.id}`);
console.log(scene.value);
} catch (error) {
// do nothing
}
}
</script>
<style scoped>
.editor {
flex-grow: 1;
}
.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;
}
.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;
align-items: center;
gap: 2rem;
.button {
padding: .5rem 1rem;
font-size: 1.1rem;
}
}
@media(--small) {
.row {
flex-direction: column;
align-items: stretch;
margin-bottom: .25rem;
}
.item-header {
margin-bottom: .25rem;
}
.key {
flex-grow: 1;
}
}
</style>

View File

@ -0,0 +1 @@
export default '/scene/@sceneId/*/edit';

View File

@ -112,6 +112,7 @@ export async function patch(path, data, options = {}) {
}); });
if (res.status === 204) { if (res.status === 204) {
showFeedback(true, options);
return null; return null;
} }

View File

@ -1,6 +1,6 @@
import redis from './redis.js'; import redis from './redis.js';
export async function getIdsBySlug(slugs, domain) { export async function getIdsBySlug(slugs, domain, toMap) {
if (!slugs) { if (!slugs) {
return []; return [];
} }
@ -21,5 +21,9 @@ export async function getIdsBySlug(slugs, domain) {
return Number(id); return Number(id);
})); }));
if (toMap) {
return Object.fromEntries(slugs.map((slug, index) => [slug, ids[index]]));
}
return ids.filter(Boolean); return ids.filter(Boolean);
} }

View File

@ -5,6 +5,7 @@ export function curateMedia(media, context = {}) {
return { return {
id: media.id, id: media.id,
hash: media.hash,
path: media.path, path: media.path,
thumbnail: media.thumbnail, thumbnail: media.thumbnail,
lazy: media.lazy, lazy: media.lazy,

View File

@ -11,6 +11,9 @@ import { curateStash } from './stashes.js';
import { curateMedia } from './media.js'; import { curateMedia } from './media.js';
import escape from '../utils/escape-manticore.js'; import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js'; import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js';
const logger = initLogger();
function getWatchUrl(scene) { function getWatchUrl(scene) {
if (scene.url) { if (scene.url) {
@ -64,6 +67,7 @@ function curateScene(rawScene, assets) {
description: rawScene.description, description: rawScene.description,
duration: rawScene.duration, duration: rawScene.duration,
shootId: rawScene.shoot_id, shootId: rawScene.shoot_id,
productionDate: rawScene.production_date,
channel: { channel: {
id: assets.channel.id, id: assets.channel.id,
slug: assets.channel.slug, slug: assets.channel.slug,
@ -595,3 +599,180 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
limit: options.limit, limit: options.limit,
}; };
} }
async function applySceneValueDelta(sceneId, delta, trx) {
console.log('value delta', delta);
return knexOwner('releases')
.where('id', sceneId)
.update(delta.key, delta.value)
.transacting(trx);
}
async function applySceneActorsDelta(sceneId, delta, trx) {
console.log('actors delta', delta);
await knexOwner('releases_actors')
.where('release_id', sceneId)
.delete()
.transacting(trx);
await knexOwner('releases_actors')
.insert(delta.value.map((actorId) => ({
release_id: sceneId,
actor_id: actorId,
})))
.transacting(trx);
}
async function applySceneTagsDelta(sceneId, delta, trx) {
console.log('tags delta', delta);
await knexOwner('releases_tags')
.where('release_id', sceneId)
.whereNotNull('tag_id')
.delete()
.transacting(trx);
await knexOwner('releases_tags')
.insert(delta.value.map((tagId) => ({
release_id: sceneId,
tag_id: tagId,
source: 'editor',
})))
.transacting(trx);
}
async function applySceneRevision(sceneIds) {
const revisions = await knexOwner('scenes_revisions')
.whereIn('scene_id', sceneIds)
.whereNull('applied_at');
await revisions.reduce(async (chain, revision) => {
await chain;
console.log('revision', revision);
await knexOwner.transaction(async (trx) => {
await revision.deltas.map(async (delta) => {
if ([
'title',
'description',
'date',
'duration',
'production_date',
'production_location',
'production_city',
'production_state',
].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);
}
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());
}
const keyMap = {
productionDate: 'production_date',
};
export async function createSceneRevision(sceneId, { edits, comment }, reqUser) {
const [
[scene],
openRevisions,
] = await Promise.all([
fetchScenesById([sceneId], { reqUser, includeAssets: true }),
knexOwner('scenes_revisions')
.where('user_id', reqUser.id)
.whereNull('approved_by')
.whereNot('failed', true),
]);
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: keyMap[key] || key,
value,
};
}).filter(Boolean);
if (deltas.length === 0) {
throw new HttpError('No effective changes provided', 400);
}
await knexOwner('scenes_revisions').insert({
user_id: reqUser.id,
scene_id: scene.id,
base: JSON.stringify(baseScene),
deltas: JSON.stringify(deltas),
comment,
});
if (['admin', 'editor'].includes(reqUser.role)) {
await applySceneRevision([scene.id]);
}
}

View File

@ -47,13 +47,17 @@ export async function fetchTags(options = {}) {
column: knex.raw('similarity(aliases.slug, :query)', { query }), column: knex.raw('similarity(aliases.slug, :query)', { query }),
order: 'desc', order: 'desc',
}, },
{
column: 'aliases.priority',
order: 'desc',
},
{ {
column: 'aliases.slug', column: 'aliases.slug',
order: 'asc', order: 'asc',
}, },
]); ]);
} else if (!options.includeAliases) { } else if (!options.includeAliases) {
builder.whereNull('alias_for'); builder.whereNull('tags.alias_for');
} }
}), }),
knex('tags_posters') knex('tags_posters')

View File

@ -22,6 +22,7 @@ export default async function mainHandler(req, res, next) {
id: req.user.id, id: req.user.id,
username: req.user.username, username: req.user.username,
email: req.user.email, email: req.user.email,
role: req.user.role,
avatar: req.user.avatar, avatar: req.user.avatar,
}, },
assets: req.user ? { assets: req.user ? {

View File

@ -1,9 +1,15 @@
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */ import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
import { fetchScenes, fetchScenesById } from '../scenes.js'; import {
fetchScenes,
fetchScenesById,
createSceneRevision,
} from '../scenes.js';
import { parseActorIdentifier } from '../query.js'; import { parseActorIdentifier } from '../query.js';
import { getIdsBySlug } from '../cache.js'; import { getIdsBySlug } from '../cache.js';
import slugify from '../../utils/slugify.js'; import slugify from '../../utils/slugify.js';
import { HttpError } from '../errors.js';
import promiseProps from '../../utils/promise-props.js'; import promiseProps from '../../utils/promise-props.js';
export async function curateScenesQuery(query) { export async function curateScenesQuery(query) {
@ -197,6 +203,18 @@ export async function fetchScenesGraphql(query, req) {
}; };
} }
export async function fetchSceneApi(req, res) {
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user });
console.log(req.params.sceneId, scene);
if (!scene) {
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
}
res.send(scene);
}
export async function fetchScenesByIdGraphql(query, req) { export async function fetchScenesByIdGraphql(query, req) {
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), { const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
reqUser: req.user, reqUser: req.user,
@ -209,3 +227,9 @@ export async function fetchScenesByIdGraphql(query, req) {
return scenes[0]; return scenes[0];
} }
export async function createSceneRevisionApi(req, res) {
await createSceneRevision(Number(req.params.sceneId), req.body, req.user);
res.status(204).send();
}

View File

@ -13,7 +13,12 @@ import redis from '../redis.js';
import errorHandler from './error.js'; import errorHandler from './error.js';
import consentHandler from './consent.js'; import consentHandler from './consent.js';
import { fetchScenesApi } from './scenes.js'; import {
fetchScenesApi,
fetchSceneApi,
createSceneRevisionApi,
} from './scenes.js';
import { fetchActorsApi } from './actors.js'; import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js'; import { fetchMoviesApi } from './movies.js';
import { fetchEntitiesApi } from './entities.js'; import { fetchEntitiesApi } from './entities.js';
@ -179,6 +184,8 @@ export default async function initServer() {
// SCENES // SCENES
router.get('/api/scenes', fetchScenesApi); router.get('/api/scenes', fetchScenesApi);
router.get('/api/scenes/:sceneId', fetchSceneApi);
router.patch('/api/scenes/:sceneId', createSceneRevisionApi);
// ACTORS // ACTORS
router.get('/api/actors', fetchActorsApi); router.get('/api/actors', fetchActorsApi);

2
static

@ -1 +1 @@
Subproject commit 514a7accf3835913a7c168d34b996bde23dcf2d8 Subproject commit 7ed5e9579b65904738b1322c222f35d516cf52c5

View File

@ -23,7 +23,7 @@ const propProcessors = {
.map((actor) => actor.name); .map((actor) => actor.name);
}, },
tags: (sceneInfo, options) => sceneInfo.tags tags: (sceneInfo, options) => sceneInfo.tags
.filter((tag) => { ?.filter((tag) => {
if (options.include && !options.include.includes(tag.slug)) { if (options.include && !options.include.includes(tag.slug)) {
return false; return false;
} }