Added summary template editor, improved summary template format and options.
This commit is contained in:
		
							parent
							
								
									d2c9b447ee
								
							
						
					
					
						commit
						e2cffbdde2
					
				|  | @ -29,6 +29,7 @@ | |||
| 	--background-level-20: #eee; | ||||
| 	--background-level-30: #eee; | ||||
| 	--background-dim: var(--shadow-weak-10); | ||||
| 	--background-error: rgba(255, 0, 0, .1); | ||||
| 
 | ||||
|     --shadow-weak-50: rgba(0, 0, 0, .02); | ||||
|     --shadow-weak-40: rgba(0, 0, 0, .05); | ||||
|  |  | |||
|  | @ -1,8 +1,14 @@ | |||
| - ' - ': | ||||
| - delimit: ' - ' | ||||
|   items: | ||||
|   - channel | ||||
|   - - movie | ||||
|     - scene|Scene $ | ||||
|   - items: | ||||
|     - movie | ||||
|     - scene | ||||
|   - title | ||||
| - ', |(|)': | ||||
|   - actors | ||||
|   - date|yyyy-MM-dd | ||||
| - delimit: ', ' | ||||
|   wrap: ['(', ')'] | ||||
|   items: | ||||
|   - key: actors | ||||
|     genders: fmtou | ||||
|   - key: date | ||||
|     format: yyyy-MM-dd | ||||
|  |  | |||
|  | @ -0,0 +1,186 @@ | |||
| <template> | ||||
| 	<Dialog title="Edit summary template"> | ||||
| 		<div class="dialog-body"> | ||||
| 			<textarea | ||||
| 				v-model="template" | ||||
| 				height="3" | ||||
| 				class="input edit" | ||||
| 				@input="update" | ||||
| 			/> | ||||
| 
 | ||||
| 			<textarea | ||||
| 				:value="summary" | ||||
| 				class="input summary" | ||||
| 				:class="{ error: hasError }" | ||||
| 				wrap="soft" | ||||
| 				@click="$event.target.select()" | ||||
| 			/> | ||||
| 
 | ||||
| 			<div class="dialog-actions"> | ||||
| 				<div class="actions"> | ||||
| 					<button | ||||
| 						class="button" | ||||
| 						@click="copy" | ||||
| 					>Copy</button> | ||||
| 
 | ||||
| 					<button | ||||
| 						class="button" | ||||
| 						@click="reset" | ||||
| 					>Reset</button> | ||||
| 				</div> | ||||
| 
 | ||||
| 				<div class="actions save"> | ||||
| 					<!-- | ||||
| 					<input | ||||
| 						class="input" | ||||
| 						placeholder="Name" | ||||
| 					> | ||||
| 					--> | ||||
| 
 | ||||
| 					<button | ||||
| 						class="button" | ||||
| 						@click="save" | ||||
| 					>Save</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</Dialog> | ||||
| </template> | ||||
| 
 | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import { parse } from 'yaml'; | ||||
| import Cookies from 'js-cookie'; | ||||
| 
 | ||||
| import events from '#/src/events.js'; | ||||
| import processSummaryTemplate from '#/utils/process-summary-template.js'; | ||||
| 
 | ||||
| import Dialog from '#/components/dialog/dialog.vue'; | ||||
| 
 | ||||
| import defaultTemplate from '#/assets/summary.yaml?raw'; // eslint-disable-line import/no-unresolved | ||||
| 
 | ||||
| const cookies = Cookies.withConverter({ | ||||
| 	write: (value) => value, | ||||
| }); | ||||
| 
 | ||||
| const props = defineProps({ | ||||
| 	release: { | ||||
| 		type: Object, | ||||
| 		default: null, | ||||
| 	}, | ||||
| }); | ||||
| 
 | ||||
| const storedTemplate = cookies.get('summary'); | ||||
| const template = ref(storedTemplate ? JSON.parse(storedTemplate)?.custom : defaultTemplate); | ||||
| const hasError = ref(false); | ||||
| 
 | ||||
| function getSummary() { | ||||
| 	return processSummaryTemplate(props.release, parse(template.value)); | ||||
| } | ||||
| 
 | ||||
| const summary = ref(getSummary()); | ||||
| 
 | ||||
| function update() { | ||||
| 	hasError.value = false; | ||||
| 
 | ||||
| 	try { | ||||
| 		summary.value = getSummary(); | ||||
| 	} catch (error) { | ||||
| 		hasError.value = true; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function save() { | ||||
| 	try { | ||||
| 		parse(template.value); | ||||
| 
 | ||||
| 		cookies.set('summary', JSON.stringify({ custom: template.value }), { expires: 400 }); // 100 years from now | ||||
| 
 | ||||
| 		events.emit('feedback', { | ||||
| 			type: 'success', | ||||
| 			message: 'Saved summary template', | ||||
| 		}); | ||||
| 	} catch (error) { | ||||
| 		events.emit('feedback', { | ||||
| 			type: 'error', | ||||
| 			message: `Failed to save summary template: ${error.message}`, | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function copy() { | ||||
| 	navigator.clipboard.writeText(summary.value); | ||||
| 
 | ||||
| 	events.emit('feedback', { | ||||
| 		type: 'success', | ||||
| 		message: 'Summary copied to clipboard', | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function reset() { | ||||
| 	if (confirm('Are you sure you want to reset the summary template? Your custom template will be discarded.')) { // eslint-disable-line no-restricted-globals, no-alert | ||||
| 		template.value = defaultTemplate; | ||||
| 
 | ||||
| 		update(); | ||||
| 
 | ||||
| 		events.emit('feedback', { | ||||
| 			type: 'undo', | ||||
| 			message: 'Reset summary template', | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| :deep(.dialog-container .dialog) { | ||||
| 	background: red; | ||||
| } | ||||
| 
 | ||||
| .dialog-body { | ||||
| 	display: flex; | ||||
| 	width: 50rem; | ||||
| 	max-width: 100%; | ||||
| 	height: 100vh; | ||||
| } | ||||
| 
 | ||||
| .input { | ||||
| 	resize: none; | ||||
| } | ||||
| 
 | ||||
| .edit { | ||||
| 	flex-grow: 1; | ||||
| } | ||||
| 
 | ||||
| .summary { | ||||
| 	min-height: 4rem; | ||||
| 	flex-shrink: 0; | ||||
| 	line-height: 1.5; | ||||
| 
 | ||||
| 	&.error { | ||||
| 		background: var(--background-error); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .dialog-actions { | ||||
| 	display: flex; | ||||
| 	justify-content: space-between;; | ||||
| 	gap: 1rem; | ||||
| 	padding: 1rem; | ||||
| 
 | ||||
| 	.input { | ||||
| 		flex-grow: 1; | ||||
| 		width: 0; | ||||
| 		max-width: 10rem; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .actions { | ||||
| 	display: flex; | ||||
| 	gap: 1rem; | ||||
| 
 | ||||
| 	&.save { | ||||
| 		flex-grow: 1; | ||||
| 		justify-content: flex-end; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -314,6 +314,13 @@ | |||
| 							@focus="$event.target.select()" | ||||
| 						> | ||||
| 
 | ||||
| 						<Icon | ||||
| 							v-tooltip="'Edit template'" | ||||
| 							icon="pencil5" | ||||
| 							class="edit" | ||||
| 							@click="showSummaryDialog = true" | ||||
| 						/> | ||||
| 
 | ||||
| 						<Icon | ||||
| 							v-tooltip="'Copy to clipboard'" | ||||
| 							icon="copy" | ||||
|  | @ -324,6 +331,12 @@ | |||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<EditSummary | ||||
| 			v-if="showSummaryDialog" | ||||
| 			:release="scene" | ||||
| 			@close="showSummaryDialog = false" | ||||
| 		/> | ||||
| 	</div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -331,7 +344,9 @@ | |||
| import { ref, computed, inject } from 'vue'; | ||||
| 
 | ||||
| import { formatDate, formatDuration } from '#/utils/format.js'; | ||||
| import events from '#/src/events.js'; | ||||
| import getPath from '#/src/get-path.js'; | ||||
| import processSummaryTemplate from '#/utils/process-summary-template.js'; | ||||
| 
 | ||||
| import ActorTile from '#/components/actors/tile.vue'; | ||||
| import MovieTile from '#/components/movies/tile.vue'; | ||||
|  | @ -339,8 +354,7 @@ import SerieTile from '#/components/series/tile.vue'; | |||
| import Player from '#/components/video/player.vue'; | ||||
| import Heart from '#/components/stashes/heart.vue'; | ||||
| import Campaign from '#/components/campaigns/campaign.vue'; | ||||
| 
 | ||||
| import summaryTemplate from '#/assets/summary.yaml'; | ||||
| import EditSummary from '#/components/scenes/edit-summary.vue'; | ||||
| 
 | ||||
| const { pageProps, campaigns } = inject('pageContext'); | ||||
| const { scene } = pageProps; | ||||
|  | @ -348,6 +362,8 @@ const { scene } = pageProps; | |||
| const playing = ref(false); | ||||
| const paused = ref(false); | ||||
| 
 | ||||
| const showSummaryDialog = ref(false); | ||||
| 
 | ||||
| const qualities = { | ||||
| 	2160: '4K', | ||||
| 	1440: 'Quad HD', | ||||
|  | @ -375,62 +391,24 @@ const poster = computed(() => { | |||
| 	return null; | ||||
| }); | ||||
| 
 | ||||
| const propProcessors = { | ||||
| 	channel: (sceneInfo) => sceneInfo.channel?.name || sceneInfo.network?.name, | ||||
| 	network: (sceneInfo) => sceneInfo.network?.name || sceneInfo.channel?.name, | ||||
| 	actors: (sceneInfo) => sceneInfo.actors.map((actor) => actor.name), | ||||
| 	movie: (sceneInfo) => sceneInfo.movies[0]?.title, | ||||
| 	date: (sceneInfo, format) => formatDate(sceneInfo.effectiveDate, format), | ||||
| }; | ||||
| 
 | ||||
| function processTemplate(chain, delimit = ' ', wrapOpen = '', wrapClose = '') { | ||||
| 	const results = chain.reduce((result, item) => { | ||||
| 		if (typeof item === 'string') { | ||||
| 			const [prop, format] = item.split('|'); | ||||
| 			const value = propProcessors[prop]?.(scene, format) || scene[prop]; | ||||
| 
 | ||||
| 			if (Array.isArray(value)) { | ||||
| 				return result.concat(value.join(format || ', ')); | ||||
| 			} | ||||
| 
 | ||||
| 			return result.concat(value); | ||||
| 		} | ||||
| 
 | ||||
| 		if (Array.isArray(item)) { | ||||
| 			const values = processTemplate(item, ', '); | ||||
| 
 | ||||
| 			return result.concat(values); | ||||
| 		} | ||||
| 
 | ||||
| 		if (typeof item === 'object') { | ||||
| 			const [meta, items] = Object.entries(item)[0]; | ||||
| 			const [delimiter, wrapStart, wrapEnd] = meta.split('|'); | ||||
| 			const values = processTemplate(items, delimiter, wrapStart, wrapEnd); | ||||
| 
 | ||||
| 			return result.concat(values); | ||||
| 		} | ||||
| 
 | ||||
| 		return []; | ||||
| 	}, []); | ||||
| 
 | ||||
| 	if (results.length > 0) { | ||||
| 		return `${wrapOpen}${results.filter(Boolean).join(delimit)}${wrapClose}`; | ||||
| 	} | ||||
| 
 | ||||
| 	return ''; | ||||
| } | ||||
| 
 | ||||
| const summary = (() => { | ||||
| 	try { | ||||
| 		return processTemplate(summaryTemplate); | ||||
| 		const result = processSummaryTemplate(scene); | ||||
| 
 | ||||
| 		return result; | ||||
| 	} catch (error) { | ||||
| 		console.error(`Failed to process template: ${error.message}`); | ||||
| 		console.error(`Failed to process summary template: ${error.message}`); | ||||
| 		return null; | ||||
| 	} | ||||
| })(); | ||||
| 
 | ||||
| function copySummary() { | ||||
| 	navigator.clipboard.writeText(summary); | ||||
| 
 | ||||
| 	events.emit('feedback', { | ||||
| 		type: 'success', | ||||
| 		message: 'Summary copied to clipboard', | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -97,6 +97,7 @@ function curateScene(rawScene, assets) { | |||
| 			id: tag.id, | ||||
| 			slug: tag.slug, | ||||
| 			name: tag.name, | ||||
| 			priority: tag.priority, | ||||
| 		})), | ||||
| 		qualities: rawScene.qualities?.sort((qualityA, qualityB) => qualityB - qualityA) || [], | ||||
| 		movies: assets.movies.map((movie) => ({ | ||||
|  | @ -176,7 +177,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) { | |||
| 			.whereIn('release_id', sceneIds) | ||||
| 			.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'), | ||||
| 		tags: knex('releases_tags') | ||||
| 			.select('id', 'slug', 'name', 'release_id') | ||||
| 			.select('id', 'slug', 'name', 'priority', 'release_id') | ||||
| 			.leftJoin('tags', 'tags.id', 'releases_tags.tag_id') | ||||
| 			.whereNotNull('tags.id') | ||||
| 			.whereIn('release_id', sceneIds) | ||||
|  |  | |||
|  | @ -0,0 +1,85 @@ | |||
| import { format } from 'date-fns'; | ||||
| import Cookies from 'js-cookie'; | ||||
| import { parse } from 'yaml'; | ||||
| 
 | ||||
| import slugify from '#/utils/slugify.js'; | ||||
| 
 | ||||
| import defaultTemplate from '#/assets/summary.yaml'; | ||||
| 
 | ||||
| const cookies = Cookies.withConverter({ | ||||
| 	write: (value) => value, | ||||
| }); | ||||
| 
 | ||||
| const storedTemplate = cookies.get('summary'); | ||||
| const template = storedTemplate | ||||
| 	? parse(JSON.parse(storedTemplate)?.custom) | ||||
| 	: defaultTemplate; | ||||
| 
 | ||||
| const genderMap = { | ||||
| 	f: 'female', | ||||
| 	m: 'male', | ||||
| 	t: 'transsexual', | ||||
| 	o: 'other', | ||||
| 	u: null, | ||||
| }; | ||||
| 
 | ||||
| const propProcessors = { | ||||
| 	channel: (sceneInfo) => sceneInfo.channel?.name || sceneInfo.network?.name, | ||||
| 	network: (sceneInfo) => sceneInfo.network?.name || sceneInfo.channel?.name, | ||||
| 	actors: (sceneInfo, options) => { | ||||
| 		const genders = (options.genders || 'fmtou').split('').map((genderKey) => genderMap[genderKey]); | ||||
| 
 | ||||
| 		return sceneInfo.actors | ||||
| 			.filter((actor) => genders.includes(actor.gender)) | ||||
| 			.map((actor) => actor.name); | ||||
| 	}, | ||||
| 	tags: (sceneInfo, options) => sceneInfo.tags | ||||
| 		.filter((tag) => { | ||||
| 			if (options.include && !options.include.includes(tag.name) && !options.include.includes(tag.slug)) { | ||||
| 				return false; | ||||
| 			} | ||||
| 
 | ||||
| 			if (options.exclude?.includes(tag.name) || options.exclude?.includes(tag.slug)) { | ||||
| 				return false; | ||||
| 			} | ||||
| 
 | ||||
| 			if (options.priority && tag.priority < options.priority) { | ||||
| 				return false; | ||||
| 			} | ||||
| 
 | ||||
| 			return true; | ||||
| 		}) | ||||
| 		.map((tag) => tag.name), | ||||
| 	movie: (sceneInfo) => sceneInfo.movies[0]?.title, | ||||
| 	date: (sceneInfo, options) => format(sceneInfo.effectiveDate, options.format || 'yyyy-MM-dd'), | ||||
| }; | ||||
| 
 | ||||
| export default function processReleaseTemplate(release, chain = template, delimit = ' ', wrapOpen = '', wrapClose = '') { | ||||
| 	const results = chain.reduce((result, item) => { | ||||
| 		const key = typeof item === 'string' ? item : item.key; | ||||
| 
 | ||||
| 		if (key) { | ||||
| 			const value = propProcessors[key]?.(release, typeof item === 'string' ? { key } : item) || release[key]; | ||||
| 
 | ||||
| 			if (Array.isArray(value)) { | ||||
| 				return result.concat(value.join(item.delimit || ', ')); | ||||
| 			} | ||||
| 
 | ||||
| 			return result.concat(item.slugify ? slugify(value, item.slugify) : value); | ||||
| 		} | ||||
| 
 | ||||
| 		if (item.items) { | ||||
| 			const value = processReleaseTemplate(release, item.items, item.delimit, item.wrap?.[0] || '', item.wrap?.[1] || ''); | ||||
| 
 | ||||
| 			return result.concat(item.slugify ? slugify(value, item.slugify) : value); | ||||
| 		} | ||||
| 
 | ||||
| 		return []; | ||||
| 	}, []); | ||||
| 
 | ||||
| 	if (results.length > 0) { | ||||
| 		return `${wrapOpen}${results.filter(Boolean).join(delimit)}${wrapClose}`; | ||||
| 	} | ||||
| 
 | ||||
| 	return ''; | ||||
| } | ||||
		Loading…
	
		Reference in New Issue