Added compact revision overview, formatting duration. Preventing rejected revisions from being reviewed again.

This commit is contained in:
DebaucheryLibrarian 2024-10-08 21:30:55 +02:00
parent 9e87c47b24
commit 067d414cd9
8 changed files with 323 additions and 136 deletions

View File

@ -1,7 +1,7 @@
<template>
<div class="page">
<div
v-if="interactive"
v-if="context === 'admin'"
class="revs-header"
>
<Checkbox
@ -11,136 +11,190 @@
/>
</div>
<ul class="revs nolist">
<ul
class="revs nolist"
:class="{ [`revs-${context}`]: true }"
>
<li
v-for="rev in curatedRevisions"
:key="`rev-${rev.id}`"
class="rev"
:class="{ reviewed: reviewedRevisions.has(rev.id) }"
:class="{
reviewed: reviewedRevisions.has(rev.id),
expanded: context === 'admin' || expanded.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>
<template v-if="context === 'admin' || expanded.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>
<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 && context === 'admin'"
class="approved"
:class="{ rejected: !rev.review.isApproved }"
>{{ rev.review.isApproved ? 'Approved' : 'Rejected' }} by&nbsp;<a
:href="`/user/${rev.review.username}`"
target="_blank"
class="nolink"
>{{ rev.review.username }}</a>&nbsp;{{ format(rev.review.reviewedAt, 'yyyy-MM-dd hh:mm') }}</span>
<template v-else-if="context === 'admin'">
<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"
>
<Icon
v-tooltip="`Approve and apply revision`"
icon="checkmark"
class="review-approve"
@click="reviewRevision(rev, true)"
/>
</template>
</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>&nbsp;]
</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>&nbsp;]
</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>
</template>
<div
v-else
class="rev-compact"
@click="expanded.add(rev.id)"
>
<span class="rev-scene nolink noshrink"><template v-if="context !== 'scene'">{{ rev.sceneId }}</template>@{{ rev.hash.slice(0, 6) }}</span>
<span
v-if="context !== 'scene'"
class="rev-title nolink ellipsis"
>{{ rev.base.title }}</span>
<span class="rev-summary">
<span
v-if="rev.comment"
class="summary-comment ellipsis"
>{{ rev.comment }}</span>
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
</span>
<div class="rev-details noshrink">
<a
v-if="rev.user"
v-if="rev.user && context !== 'user'"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-username nolink"
class="rev-username nolink ellipsis"
@click.stop
>{{ rev.user.username }}</a>
<a
v-if="rev.user && context !== 'user'"
v-tooltip="rev.user.username"
:href="`/user/${rev.user.username}`"
target="_blank"
class="rev-avatar nolink ellipsis"
@click.stop
><Icon icon="user3-long" /></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&nbsp;<a
:href="`/user/${rev.review.username}`"
target="_blank"
class="nolink"
>{{ rev.review.username }}</a>&nbsp;{{ 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>&nbsp;]
</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>&nbsp;]
</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>
@ -154,11 +208,12 @@ import { format } from 'date-fns';
import Checkbox from '#/components/form/checkbox.vue';
import { get, post } from '#/src/api.js';
import { formatDuration } from '#/utils/format.js';
defineProps({
interactive: {
type: Boolean,
default: false,
context: {
type: String,
default: 'admin',
},
});
@ -176,6 +231,7 @@ const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) =>
const feedbacks = ref({});
const showReviewed = ref(false);
const reviewedRevisions = ref(new Set());
const expanded = ref(new Set());
const mappedKeys = {
actors: actorsById,
@ -189,6 +245,10 @@ const dateKeys = [
'createdAt',
];
const curatedKeys = {
duration: (duration) => formatDuration(duration),
};
const curatedRevisions = computed(() => revisions.value.map((revision) => {
const curatedBase = Object.fromEntries(Object.entries(revision.base).map(([key, value]) => {
if (Array.isArray(value) && mappedKeys[key]) {
@ -203,6 +263,10 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
return [key, new Date(value)];
}
if (curatedKeys[key]) {
return [key, curatedKeys[key](value)];
}
return [key, value];
}));
@ -225,6 +289,13 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
};
}
if (curatedKeys[delta.key]) {
return {
...delta,
value: curatedKeys[delta.key](delta.value),
};
}
return delta;
});
@ -267,8 +338,6 @@ async function reviewRevision(revision, isApproved) {
}
async function banEditor(revision) {
console.log('ban!', revision);
await post('/bans', {
userId: revision.user.id,
banIp: true,
@ -302,15 +371,19 @@ async function banEditor(revision) {
}
.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);
margin-bottom: .25rem;
font-size: .9rem;
&.expanded {
min-width: 1200px;
margin-bottom: .5rem;
}
&.reviewed {
pointer-events: none;
opacity: .5;
@ -322,8 +395,6 @@ async function banEditor(revision) {
}
.rev-link {
color: var(--glass-strong-10);
&:hover {
color: var(--primary);
text-decoration: underline;
@ -343,10 +414,11 @@ async function banEditor(revision) {
box-sizing: border-box;
padding: .5rem .5rem;
align-items: center;
color: var(--glass-strong-10);
}
.rev-title {
color: inherit;
padding: .5rem 0;
}
@ -360,7 +432,10 @@ async function banEditor(revision) {
}
.rev-username {
display: flex;
align-items: center;
font-weight: bold;
height: 100%;
}
.rev-actions {
@ -485,4 +560,74 @@ async function banEditor(revision) {
padding: .5rem .5rem;
border-top: solid 1px var(--glass-weak-30);
}
.rev-compact {
display: flex;
align-items: stretch;
cursor: pointer;
.rev-title {
width: 15rem;
margin-right: 2rem;
}
.rev-details {
flex-grow: 0;
}
.rev-avatar {
display: none;
.icon {
fill: var(--glass);
}
}
}
.revs-scene .rev-compact .rev-scene {
width: 6rem;
}
.rev-summary {
display: flex;
align-items: center;
flex-grow: 1;
overflow: hidden;
}
.summary-comment {
padding-right: .5rem;
border-right: solid 1px var(--glass-weak-20);
margin-right: .5rem;
}
.summary-deltas {
font-style: italic;
}
@media(--compact) {
.rev-compact .rev-details {
gap: .5rem;
}
.rev-compact .rev-username {
display: none;
}
.rev-compact .rev-avatar {
display: block;
}
}
@media(--small) {
.rev-compact .summary-comment {
padding: 0;
border: none;
margin: 0;
& + .summary-deltas {
display: none;
}
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<Admin class="page">
<Revisions
:interactive="true"
context="admin"
/>
</Admin>
</template>

View File

@ -227,6 +227,14 @@
<template v-if="qualities[scene.qualities[0]]">{{ qualities[scene.qualities[0]] }} ({{ scene.qualities[0] }}p)</template>
<template v-else>{{ scene.qualities[0] }}p</template>
</div>
<div
v-if="scene.photoCount"
class="detail"
>
<h3 class="heading">Photos</h3>
{{ scene.photoCount }}
</div>
</div>
<div
@ -321,11 +329,11 @@
</div>
<div
v-if="user && user.role !== 'user'"
class="scene-actions section"
>
<a
:href="`/scene/edit/${scene.id}`"
v-if="user && user.role !== 'user'"
:href="`/scene/edit/${scene.id}/${scene.slug}`"
target="_blank"
class="link"
>Edit scene</a>

View File

@ -3,14 +3,21 @@
<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 class="revs-actions">
<a
:href="`/scene/edit/${scene.id}/${scene.slug}`"
class="link"
>Edit scene</a>
<a
:href="`/scene/${scene.id}/${scene.slug}`"
target="_blank"
class="link"
>Go to scene</a>
</div>
</div>
<Revisions />
<Revisions context="scene" />
</div>
</template>
@ -33,5 +40,23 @@ const scene = pageContext.pageProps.scene;
display: flex;
justify-content: space-between;
align-items: center;
.heading {
line-height: 1.5;
}
}
.revs-actions {
display: flex;
gap: 2rem;
flex-shrink: 0;
}
@media(--compact) {
.revs-header {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
}
</style>

View File

@ -58,7 +58,7 @@
class="profile-section revisions"
>
<h3 class="section-header heading">Revisions</h3>
<Revisions />
<Revisions context="user" />
</div>
</div>
</template>

View File

@ -114,6 +114,7 @@ function curateScene(rawScene, assets) {
priority: tag.priority,
})),
qualities: rawScene.qualities?.sort((qualityA, qualityB) => qualityB - qualityA) || [],
photoCount: rawScene.photo_count,
movies: assets.movies.map((movie) => ({
id: movie.id,
slug: movie.slug,
@ -814,9 +815,9 @@ export async function reviewSceneRevision(revisionId, isApproved, { feedback },
throw new HttpError('You must either approve or reject the revision', 400);
}
await knexOwner('scenes_revisions')
const updated = 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('approved') // don't rerun reviewed revision, must be forked into new revision instead
.whereNull('applied_at')
.update({
approved: isApproved,
@ -825,6 +826,10 @@ export async function reviewSceneRevision(revisionId, isApproved, { feedback },
feedback,
});
if (updated === 0) {
throw new HttpError('This revision was already reviewed', 409);
}
if (isApproved) {
await applySceneRevision([revisionId]);
}

2
static

@ -1 +1 @@
Subproject commit cb3f99c5dcc35c9d492658a228709f9e1af29398
Subproject commit 0f7fc7c9f4ba7454bb92482eaffd93b345ea9834

View File

@ -1,6 +1,10 @@
import { format } from 'date-fns';
export function formatDuration(duration, forceHours) {
if (typeof duration !== 'number') {
return duration;
}
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);