<template> <div class="page"> <div v-if="context === 'admin'" class="revs-header" > <Checkbox label="Show finalized" :checked="showReviewed" @change="(checked) => { showReviewed = checked; reloadRevisions(); }" /> </div> <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), expanded: context === 'admin' || expanded.has(rev.id) }" > <template v-if="context === 'admin' || expanded.has(rev.id)"> <div class="rev-header"> <a :href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`" target="_blank" class="rev-link rev-scene nolink noshrink" >{{ rev.sceneId || rev.actorId }}@{{ rev.hash.slice(0, 6) }}</a> <a :href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`" target="_blank" class="rev-link rev-title nolink ellipsis" >{{ rev.base.title || rev.base.name }}</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 <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="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"> <Socials v-if="delta.key === 'socials'" :rev="rev" :index="index" :socials="rev.base[delta.key]" /> <ul v-else-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> <Avatar v-else-if="delta.key === 'avatar'" :avatar="avatarsById[rev.base[delta.key]]" class="delta-avatar" /> <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"> <Socials v-if="delta.key === 'socials'" :rev="rev" :index="index" :socials="delta.value" /> <ul v-else-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> <Avatar v-else-if="delta.key === 'avatar'" :avatar="avatarsById[delta.value]" class="delta-avatar" /> <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' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span> <span v-if="context !== 'scene'" class="rev-title nolink ellipsis" >{{ rev.base.title }}</span> <span class="rev-summary"> <span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span> <span v-if="rev.comment" class="summary-comment ellipsis" >{{ rev.comment }}</span> </span> <div class="rev-details noshrink"> <a v-if="rev.user && context !== 'user'" :href="`/user/${rev.user.username}`" target="_blank" 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> </li> </ul> </div> </template> <script setup> import { ref, computed, inject } from 'vue'; import { format } from 'date-fns'; import Avatar from '#/components/edit/avatar.vue'; import Socials from '#/components/edit/revision-socials.vue'; import Checkbox from '#/components/form/checkbox.vue'; import { get, post } from '#/src/api.js'; import { formatDuration } from '#/utils/format.js'; defineProps({ context: { type: String, default: 'admin', }, }); const pageContext = inject('pageContext'); const revisions = ref(pageContext.pageProps.revisions); const domain = pageContext.routeParams.domain; const actors = ref(pageContext.pageProps.actors); const tags = ref(pageContext.pageProps.tags); const movies = ref(pageContext.pageProps.movies); const avatars = ref(pageContext.pageProps.avatars); 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 avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar]))); const feedbacks = ref({}); const showReviewed = ref(false); const reviewedRevisions = ref(new Set()); const expanded = ref(new Set()); const mappedKeys = { actors: actorsById, tags: tagsById, movies: moviesById, }; const dateKeys = [ 'date', 'dateOfBirth', 'dateOfDeath', 'productionDate', '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]) { 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 (key === 'socials') { // new socials don't have IDs yet, so we need to compare the values return [key, value.map((item) => ({ ...item, modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)), }))]; } if (dateKeys.includes(key)) { return [key, new Date(value)]; } if (curatedKeys[key]) { return [key, curatedKeys[key](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 (delta.key === 'socials') { // new socials don't have IDs yet, so we need to compare the values return { ...delta, value: delta.value.map((social) => ({ ...social, modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`), })), }; } if (dateKeys.includes(delta.key)) { return { ...delta, value: new Date(delta.value), }; } if (curatedKeys[delta.key]) { return { ...delta, value: curatedKeys[delta.key](delta.value), }; } return delta; }); return { ...revision, base: curatedBase, deltas: curatedDeltas, }; })); async function reloadRevisions() { const updatedRevisions = await get(`/revisions/${domain}`, { isFinalized: showReviewed.value ? undefined : false, limit: 50, }); actors.value = updatedRevisions.actors; tags.value = updatedRevisions.tags; movies.value = updatedRevisions.movies; avatars.value = updatedRevisions.avatars; revisions.value = updatedRevisions.revisions; } async function reviewRevision(revision, isApproved) { reviewedRevisions.value.add(revision.id); try { await post(`/revisions/${domain}/${revision.id}/reviews`, { isApproved, feedback: feedbacks.value[revision.id], }); const updatedRevision = await get(`/revisions/${domain}/${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) { 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 { display: flex; flex-direction: column; background: var(--background); border-radius: .25rem; 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; } &:hover { box-shadow: 0 0 3px var(--primary-light-20); } } .rev-link { &: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; color: var(--glass-strong-10); } .rev-title { padding: .5rem 0; } .rev-details { display: flex; flex-grow: 1; justify-content: flex-end; gap: 1rem; align-items: center; margin: 0 1rem; } .rev-username { display: flex; align-items: center; font-weight: bold; height: 100%; } .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); } .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-left: .5rem; border-left: solid 1px var(--glass-weak-20); margin-left: .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>