Expanded edit fields. Added revision history to scene and user pages.
|  | @ -61,3 +61,7 @@ | |||
| .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); | ||||
|  |  | |||
|  | @ -23,7 +23,7 @@ | |||
| 						v-close-popper | ||||
| 						class="actor" | ||||
| 						@click="emit('actor', actor)" | ||||
| 					>{{ actor.name }} ({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].join(', ') }}) | ||||
| 					>{{ actor.name }} ({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }}) | ||||
| 						<img | ||||
| 							v-if="actor.avatar" | ||||
| 							:src="getPath(actor.avatar, 'thumbnail')" | ||||
|  | @ -37,26 +37,12 @@ | |||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { ref, inject } from 'vue'; | ||||
| import { ref } 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 actors = ref([]); | ||||
| const query = ref(null); | ||||
| const queryInput = ref(null); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -85,11 +85,13 @@ watch(() => props.scene, () => { newActors.value = []; }); | |||
| 
 | ||||
| 	&.disabled { | ||||
| 		.actor { | ||||
| 			color: var(--shadow); | ||||
| 			background: var(--glass-weak-50); | ||||
| 			color: var(--glass-strong-10); | ||||
| 
 | ||||
| 			.remove, | ||||
| 			.add { | ||||
| 				background: var(--shadow-weak-40); | ||||
| 				fill: var(--shadow-weak-30); | ||||
| 				background: var(--shadow-weak-50); | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  | @ -103,9 +105,14 @@ watch(() => props.scene, () => { newActors.value = []; }); | |||
| 		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); | ||||
| 		} | ||||
| 	} | ||||
|  | @ -114,8 +121,9 @@ watch(() => props.scene, () => { newActors.value = []; }); | |||
| .actor { | ||||
| 	display: flex; | ||||
| 	align-items: stretch; | ||||
| 	background: var(--glass-weak-30); | ||||
| 	border-radius: .25rem; | ||||
| 	background: var(--background); | ||||
| 	box-shadow: 0 0 3px var(--shadow-weak-30); | ||||
| 
 | ||||
| 	&.deleted  { | ||||
| 		color: var(--glass); | ||||
|  | @ -129,8 +137,8 @@ watch(() => props.scene, () => { newActors.value = []; }); | |||
| 	.add { | ||||
| 		height: auto; | ||||
| 		padding: .25rem .3rem; | ||||
| 		fill: var(--highlight-strong-10); | ||||
| 		border-radius: .25rem; | ||||
| 		fill: var(--highlight-strong-10); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			fill: var(--text-light); | ||||
|  | @ -139,11 +147,19 @@ watch(() => props.scene, () => { newActors.value = []; }); | |||
| 	} | ||||
| 
 | ||||
| 	.remove { | ||||
| 		background: var(--error); | ||||
| 		fill: var(--error); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			background: var(--error); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	.add { | ||||
| 		background: var(--success); | ||||
| 		fill: var(--success); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			background: var(--success); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -85,11 +85,13 @@ watch(() => props.scene, () => { newTags.value = []; }); | |||
| 
 | ||||
| 	&.disabled { | ||||
| 		.tag { | ||||
| 			color: var(--shadow); | ||||
| 			background: var(--glass-weak-50); | ||||
| 			color: var(--glass-strong-10); | ||||
| 
 | ||||
| 			.remove, | ||||
| 			.add { | ||||
| 				background: var(--shadow-weak-40); | ||||
| 				fill: var(--shadow-weak-30); | ||||
| 				background: var(--shadow-weak-50); | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  | @ -103,9 +105,14 @@ watch(() => props.scene, () => { newTags.value = []; }); | |||
| 		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); | ||||
| 		} | ||||
| 	} | ||||
|  | @ -114,8 +121,9 @@ watch(() => props.scene, () => { newTags.value = []; }); | |||
| .tag { | ||||
| 	display: flex; | ||||
| 	align-items: stretch; | ||||
| 	background: var(--glass-weak-30); | ||||
| 	border-radius: .25rem; | ||||
| 	background: var(--background); | ||||
| 	box-shadow: 0 0 3px var(--shadow-weak-30); | ||||
| 
 | ||||
| 	&.deleted  { | ||||
| 		color: var(--glass); | ||||
|  | @ -129,8 +137,8 @@ watch(() => props.scene, () => { newTags.value = []; }); | |||
| 	.add { | ||||
| 		height: auto; | ||||
| 		padding: .25rem .3rem; | ||||
| 		fill: var(--highlight-strong-10); | ||||
| 		border-radius: .25rem; | ||||
| 		fill: var(--highlight-strong-10); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			fill: var(--text-light); | ||||
|  | @ -139,11 +147,19 @@ watch(() => props.scene, () => { newTags.value = []; }); | |||
| 	} | ||||
| 
 | ||||
| 	.remove { | ||||
| 		background: var(--error); | ||||
| 		fill: var(--error); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			background: var(--error); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	.add { | ||||
| 		background: var(--success); | ||||
| 		fill: var(--success); | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			background: var(--success); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -63,6 +63,9 @@ 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
 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| module.exports = { | ||||
| 	apps: [ | ||||
| 		{ | ||||
| 			name: 'newtraxxx', | ||||
| 			name: 'traxxx', | ||||
| 			script: 'npm', | ||||
| 			args: 'run server:prod', | ||||
| 			exec_mode: 'cluster', | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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/*'; | ||||
|  | @ -286,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'" | ||||
|  | @ -319,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> | ||||
|  | @ -670,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,40 @@ | |||
| <template> | ||||
| 	<div class="editor"> | ||||
| 		<form @submit.prevent> | ||||
| 		<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> | ||||
| 
 | ||||
|  | @ -52,6 +86,15 @@ | |||
| 							@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" | ||||
|  | @ -128,13 +171,24 @@ | |||
| 				</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" | ||||
| 					>Submit</button> | ||||
| 					> | ||||
| 						<template v-if="apply">Submit</template> | ||||
| 						<template v-else>Submit for review</template> | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</form> | ||||
|  | @ -147,8 +201,13 @@ 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, patch } from '#/src/api.js'; | ||||
| import { | ||||
| 	// get, | ||||
| 	post, | ||||
| } from '#/src/api.js'; | ||||
| 
 | ||||
| const pageContext = inject('pageContext'); | ||||
| 
 | ||||
|  | @ -168,6 +227,11 @@ const fields = computed(() => [ | |||
| 		type: 'tags', | ||||
| 		value: scene.value.tags, | ||||
| 	}, | ||||
| 	{ | ||||
| 		key: 'movies', | ||||
| 		type: 'movies', | ||||
| 		value: scene.value.movies, | ||||
| 	}, | ||||
| 	{ | ||||
| 		key: 'title', | ||||
| 		type: 'string', | ||||
|  | @ -211,6 +275,8 @@ const fields = computed(() => [ | |||
| 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)) { | ||||
|  | @ -219,6 +285,7 @@ function toggleField(item) { | |||
| 
 | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	editing.value.add(item.key); | ||||
| 
 | ||||
| 	if (Array.isArray(item.value)) { | ||||
|  | @ -231,6 +298,8 @@ function toggleField(item) { | |||
| 
 | ||||
| function setValue(item, event) { | ||||
| 	edits.value[item.key] = event.target.value; | ||||
| 
 | ||||
| 	console.log(edits.value); | ||||
| } | ||||
| 
 | ||||
| const timeUnits = ['h', 'm', 's']; | ||||
|  | @ -241,7 +310,8 @@ function setDuration(unit, event) { | |||
| 
 | ||||
| async function submit() { | ||||
| 	try { | ||||
| 		await patch(`/scenes/${scene.value.id}`, { | ||||
| 		await post('/revisions', { | ||||
| 			sceneId: scene.value.id, | ||||
| 			edits: { | ||||
| 				...edits.value, | ||||
| 				duration: edits.value.duration | ||||
|  | @ -249,6 +319,7 @@ async function submit() { | |||
| 					: undefined, | ||||
| 			}, | ||||
| 			comment: comment.value, | ||||
| 			apply: apply.value, | ||||
| 		}, { | ||||
| 			successFeedback: 'Your revision has been submitted for approval.', | ||||
| 			appendErrorMessage: true, | ||||
|  | @ -258,9 +329,9 @@ async function submit() { | |||
| 		edits.value = {}; | ||||
| 		comment.value = null; | ||||
| 
 | ||||
| 		scene.value = await get(`/scenes/${scene.value.id}`); | ||||
| 		submitted.value = true; | ||||
| 
 | ||||
| 		console.log(scene.value); | ||||
| 		// scene.value = await get(`/scenes/${scene.value.id}`); | ||||
| 	} catch (error) { | ||||
| 		// do nothing | ||||
| 	} | ||||
|  | @ -270,6 +341,7 @@ async function submit() { | |||
| <style scoped> | ||||
| .editor { | ||||
| 	flex-grow: 1; | ||||
| 	background: var(--background-dark-10); | ||||
| } | ||||
| 
 | ||||
| .editor-header { | ||||
|  | @ -295,6 +367,10 @@ async function submit() { | |||
| 	font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| .input { | ||||
| 	background: var(--background); | ||||
| } | ||||
| 
 | ||||
| .item-header { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
|  | @ -368,8 +444,10 @@ async function submit() { | |||
| 
 | ||||
| .editor-actions { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	align-items: center; | ||||
| 	gap: 2rem; | ||||
| 	gap: 1.5rem; | ||||
| 	margin: .5rem 0; | ||||
| 
 | ||||
| 	.button { | ||||
| 		padding: .5rem 1rem; | ||||
|  | @ -377,6 +455,15 @@ async function submit() { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| .submitted { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	align-items: center; | ||||
| 	padding: 1rem; | ||||
| 	font-weight: bold; | ||||
| 	line-height: 1.5; | ||||
| } | ||||
| 
 | ||||
| @media(--small) { | ||||
| 	.row { | ||||
| 		flex-direction: column; | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| export default '/scene/@sceneId/*/edit'; | ||||
| 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
 | ||||
|  |  | |||
|  | @ -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})`); | ||||
|  |  | |||
							
								
								
									
										242
									
								
								src/scenes.js
								
								
								
								
							
							
						
						|  | @ -1,11 +1,13 @@ | |||
| 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'; | ||||
|  | @ -14,6 +16,7 @@ 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) { | ||||
|  | @ -600,59 +603,169 @@ export async function fetchScenes(filters, rawOptions, reqUser) { | |||
| 	}; | ||||
| } | ||||
| 
 | ||||
| async function applySceneValueDelta(sceneId, delta, trx) { | ||||
| 	console.log('value delta', delta); | ||||
| 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(delta.key, delta.value) | ||||
| 		.update(keyMap[delta.key] || 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); | ||||
| 	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) { | ||||
| 	console.log('tags delta', delta); | ||||
| 
 | ||||
| 	// don't remove unidentified tags
 | ||||
| 	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); | ||||
| 	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 applySceneRevision(sceneIds) { | ||||
| 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('scene_id', sceneIds) | ||||
| 		.whereNull('applied_at'); | ||||
| 		.whereIn('id', revisionIds) | ||||
| 		.whereNull('applied_at'); // should not re-apply revision that was already applied
 | ||||
| 
 | ||||
| 	await revisions.reduce(async (chain, revision) => { | ||||
| 		await chain; | ||||
| 
 | ||||
| 		console.log('revision', revision); | ||||
| 
 | ||||
| 		await knexOwner.transaction(async (trx) => { | ||||
| 			await revision.deltas.map(async (delta) => { | ||||
| 				if ([ | ||||
|  | @ -660,10 +773,10 @@ async function applySceneRevision(sceneIds) { | |||
| 					'description', | ||||
| 					'date', | ||||
| 					'duration', | ||||
| 					'production_date', | ||||
| 					'production_location', | ||||
| 					'production_city', | ||||
| 					'production_state', | ||||
| 					'productionDate', | ||||
| 					'productionLocation', | ||||
| 					'productionCity', | ||||
| 					'productionState', | ||||
| 				].includes(delta.key)) { | ||||
| 					return applySceneValueDelta(revision.scene_id, delta, trx); | ||||
| 				} | ||||
|  | @ -676,6 +789,10 @@ async function applySceneRevision(sceneIds) { | |||
| 					return applySceneTagsDelta(revision.scene_id, delta, trx); | ||||
| 				} | ||||
| 
 | ||||
| 				if (delta.key === 'movies') { | ||||
| 					return applySceneMoviesDelta(revision.scene_id, delta, trx); | ||||
| 				} | ||||
| 
 | ||||
| 				return null; | ||||
| 			}); | ||||
| 
 | ||||
|  | @ -690,20 +807,44 @@ async function applySceneRevision(sceneIds) { | |||
| 	}, Promise.resolve()); | ||||
| } | ||||
| 
 | ||||
| const keyMap = { | ||||
| 	productionDate: 'production_date', | ||||
| }; | ||||
| export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) { | ||||
| 	if (!reqUser || reqUser.role === 'user') { | ||||
| 		throw new HttpError('You are not permitted to approve revisions', 403); | ||||
| 	} | ||||
| 
 | ||||
| export async function createSceneRevision(sceneId, { edits, comment }, reqUser) { | ||||
| 	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 }), | ||||
| 		fetchScenesById([sceneId], { | ||||
| 			reqUser, | ||||
| 			includeAssets: true, | ||||
| 			includePartOf: true, | ||||
| 		}), | ||||
| 		knexOwner('scenes_revisions') | ||||
| 			.where('user_id', reqUser.id) | ||||
| 			.whereNull('approved_by') | ||||
| 			.whereNot('failed', true), | ||||
| 			.whereNull('approved'), | ||||
| 	]); | ||||
| 
 | ||||
| 	if (!scene) { | ||||
|  | @ -754,25 +895,28 @@ export async function createSceneRevision(sceneId, { edits, comment }, reqUser) | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		return { | ||||
| 			key: keyMap[key] || key, | ||||
| 			value, | ||||
| 		}; | ||||
| 		return { 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, | ||||
| 	}); | ||||
| 	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)) { | ||||
| 		await applySceneRevision([scene.id]); | ||||
| 	if (['admin', 'editor'].includes(reqUser.role) && apply) { | ||||
| 		await reviewSceneRevision(revisionEntry.id, true, {}, reqUser); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
							
								
								
									
										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
 | ||||
|  |  | |||
|  | @ -1,9 +1,12 @@ | |||
| import Router from 'express-promise-router'; | ||||
| import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */ | ||||
| 
 | ||||
| import { | ||||
| 	fetchScenes, | ||||
| 	fetchScenesById, | ||||
| 	fetchSceneRevisions, | ||||
| 	createSceneRevision, | ||||
| 	reviewSceneRevision, | ||||
| } from '../scenes.js'; | ||||
| 
 | ||||
| import { parseActorIdentifier } from '../query.js'; | ||||
|  | @ -48,7 +51,7 @@ export async function curateScenesQuery(query) { | |||
| 	}; | ||||
| } | ||||
| 
 | ||||
| export async function fetchScenesApi(req, res) { | ||||
| async function fetchScenesApi(req, res) { | ||||
| 	const { | ||||
| 		scenes, | ||||
| 		aggYears, | ||||
|  | @ -203,11 +206,9 @@ export async function fetchScenesGraphql(query, req) { | |||
| 	}; | ||||
| } | ||||
| 
 | ||||
| export async function fetchSceneApi(req, res) { | ||||
| 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); | ||||
| 	} | ||||
|  | @ -228,8 +229,30 @@ export async function fetchScenesByIdGraphql(query, req) { | |||
| 	return scenes[0]; | ||||
| } | ||||
| 
 | ||||
| export async function createSceneRevisionApi(req, res) { | ||||
| 	await createSceneRevision(Number(req.params.sceneId), req.body, req.user); | ||||
| 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,11 +13,7 @@ import redis from '../redis.js'; | |||
| import errorHandler from './error.js'; | ||||
| import consentHandler from './consent.js'; | ||||
| 
 | ||||
| import { | ||||
| 	fetchScenesApi, | ||||
| 	fetchSceneApi, | ||||
| 	createSceneRevisionApi, | ||||
| } from './scenes.js'; | ||||
| import { scenesRouter } from './scenes.js'; | ||||
| 
 | ||||
| import { fetchActorsApi } from './actors.js'; | ||||
| import { fetchMoviesApi } from './movies.js'; | ||||
|  | @ -39,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, | ||||
|  | @ -145,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); | ||||
|  | @ -182,10 +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.get('/api/scenes/:sceneId', fetchSceneApi); | ||||
| 	router.patch('/api/scenes/:sceneId', createSceneRevisionApi); | ||||
| 	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 7ed5e9579b65904738b1322c222f35d516cf52c5 | ||||
| Subproject commit cb3f99c5dcc35c9d492658a228709f9e1af29398 | ||||
|  | @ -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==
 | ||||