Add image description support (#7518)
* recieve image descriptions under the name property * fix other components * use comment for alt and title * allow editing of file comment * allow editing of file comment in note dialog * federate note comments * use file instead of this * backend should accept comment on update * update now actually accepts comment * allow multiline descriptions * image should also have description attached * Update locales/ja-JP.yml Co-authored-by: rinsuki <428rinsuki+git@gmail.com> * Use custom component with side-by-side image * improve usability on mobile devices * revert changes * Update post-form-attaches.vue * Update drive.file.vue * Update media-caption.vue Co-authored-by: rinsuki <428rinsuki+git@gmail.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		
							parent
							
								
									db3724cf33
								
							
						
					
					
						commit
						ffb9646ce9
					
				
					 11 changed files with 315 additions and 6 deletions
				
			
		|  | @ -279,6 +279,7 @@ emptyDrive: "ドライブは空です" | |||
| emptyFolder: "フォルダーは空です" | ||||
| unableToDelete: "削除できません" | ||||
| inputNewFileName: "新しいファイル名を入力してください" | ||||
| inputNewDescription: "新しいキャプションを入力してください" | ||||
| inputNewFolderName: "新しいフォルダ名を入力してください" | ||||
| circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。" | ||||
| hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。" | ||||
|  | @ -546,6 +547,8 @@ disablePlayer: "プレイヤーを閉じる" | |||
| expandTweet: "ツイートを展開する" | ||||
| themeEditor: "テーマエディター" | ||||
| description: "説明" | ||||
| describeFile: "キャプションを付ける" | ||||
| enterFileDescription: "キャプションを入力" | ||||
| author: "作者" | ||||
| leaveConfirm: "未保存の変更があります。破棄しますか?" | ||||
| manage: "管理" | ||||
|  |  | |||
|  | @ -87,6 +87,10 @@ export default defineComponent({ | |||
| 				text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, | ||||
| 				icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', | ||||
| 				action: this.toggleSensitive | ||||
| 			}, { | ||||
| 				text: this.$ts.describeFile, | ||||
| 				icon: 'fas fa-i-cursor', | ||||
| 				action: this.describe | ||||
| 			}, null, { | ||||
| 				text: this.$ts.copyUrl, | ||||
| 				icon: 'fas fa-link', | ||||
|  | @ -150,6 +154,26 @@ export default defineComponent({ | |||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		describe() { | ||||
| 			os.popup(import('@client/components/media-caption.vue'), { | ||||
| 				title: this.$ts.describeFile, | ||||
| 				input: { | ||||
| 					placeholder: this.$ts.inputNewDescription, | ||||
| 					default: this.file.comment !== null ? this.file.comment : '', | ||||
| 				}, | ||||
| 				image: this.file | ||||
| 			}, { | ||||
| 				done: result => { | ||||
| 					if (!result || result.canceled) return; | ||||
| 					let comment = result.result; | ||||
| 					os.api('drive/files/update', { | ||||
| 						fileId: this.file.id, | ||||
| 						comment: comment.length == 0 ? null : comment | ||||
| 					}); | ||||
| 				} | ||||
| 			}, 'closed'); | ||||
| 		}, | ||||
| 
 | ||||
| 		toggleSensitive() { | ||||
| 			os.api('drive/files/update', { | ||||
| 				fileId: this.file.id, | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| <MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> | ||||
| 	<div class="xubzgfga"> | ||||
| 		<header>{{ image.name }}</header> | ||||
| 		<img :src="image.url" :alt="image.name" :title="image.name" @click="$refs.modal.close()"/> | ||||
| 		<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> | ||||
| 		<footer> | ||||
| 			<span>{{ image.type }}</span> | ||||
| 			<span>{{ bytes(image.size) }}</span> | ||||
|  |  | |||
							
								
								
									
										238
									
								
								src/client/components/media-caption.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/client/components/media-caption.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,238 @@ | |||
| <template> | ||||
| 	<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> | ||||
| 		<div class="container"> | ||||
| 			<div class="fullwidth top-caption"> | ||||
| 				<div class="mk-dialog"> | ||||
| 					<header v-if="title"><Mfm :text="title"/></header> | ||||
| 					<textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> | ||||
| 					<div class="buttons" v-if="(showOkButton || showCancelButton)"> | ||||
| 						<MkButton inline @click="ok" primary>{{ $ts.ok }}</MkButton> | ||||
| 						<MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="hdrwpsaf fullwidth"> | ||||
| 				<header>{{ image.name }}</header> | ||||
| 				<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> | ||||
| 				<footer> | ||||
| 					<span>{{ image.type }}</span> | ||||
| 					<span>{{ bytes(image.size) }}</span> | ||||
| 					<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> | ||||
| 				</footer> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</MkModal> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import MkModal from '@client/components/ui/modal.vue'; | ||||
| import MkButton from '@client/components/ui/button.vue'; | ||||
| import bytes from '@client/filters/bytes'; | ||||
| import number from '@client/filters/number'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkModal, | ||||
| 		MkButton, | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		image: { | ||||
| 			type: Object, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 		title: { | ||||
| 			type: String, | ||||
| 			required: false | ||||
| 		}, | ||||
| 		input: { | ||||
| 			required: true | ||||
| 		}, | ||||
| 		showOkButton: { | ||||
| 			type: Boolean, | ||||
| 			default: true | ||||
| 		}, | ||||
| 		showCancelButton: { | ||||
| 			type: Boolean, | ||||
| 			default: true | ||||
| 		}, | ||||
| 		cancelableByBgClick: { | ||||
| 			type: Boolean, | ||||
| 			default: true | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['done', 'closed'], | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			inputValue: this.input.default ? this.input.default : null | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	mounted() { | ||||
| 		document.addEventListener('keydown', this.onKeydown); | ||||
| 	}, | ||||
| 
 | ||||
| 	beforeUnmount() { | ||||
| 		document.removeEventListener('keydown', this.onKeydown); | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		bytes, | ||||
| 		number, | ||||
| 
 | ||||
| 		done(canceled, result?) { | ||||
| 			this.$emit('done', { canceled, result }); | ||||
| 			this.$refs.modal.close(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async ok() { | ||||
| 			if (!this.showOkButton) return; | ||||
| 
 | ||||
| 			const result = this.inputValue; | ||||
| 			this.done(false, result); | ||||
| 		}, | ||||
| 
 | ||||
| 		cancel() { | ||||
| 			this.done(true); | ||||
| 		}, | ||||
| 
 | ||||
| 		onBgClick() { | ||||
| 			if (this.cancelableByBgClick) { | ||||
| 				this.cancel(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onKeydown(e) { | ||||
| 			if (e.which === 27) { // ESC | ||||
| 				this.cancel(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onInputKeydown(e) { | ||||
| 			if (e.which === 13) { // Enter | ||||
| 				if (e.ctrlKey) { | ||||
| 					e.preventDefault(); | ||||
| 					e.stopPropagation(); | ||||
| 					this.ok(); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .container { | ||||
| 	display: flex; | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	flex-direction: row; | ||||
| } | ||||
| @media (max-width: 850px) { | ||||
| 	.container { | ||||
| 		flex-direction: column; | ||||
| 	} | ||||
| 	.top-caption { | ||||
| 		padding-bottom: 8px; | ||||
| 	} | ||||
| } | ||||
| .fullwidth { | ||||
| 	width: 100%; | ||||
| 	margin: auto; | ||||
| } | ||||
| .mk-dialog { | ||||
| 	position: relative; | ||||
| 	padding: 32px; | ||||
| 	min-width: 320px; | ||||
| 	max-width: 480px; | ||||
| 	box-sizing: border-box; | ||||
| 	text-align: center; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| 	margin: auto; | ||||
| 
 | ||||
| 	> header { | ||||
| 		margin: 0 0 8px 0; | ||||
| 		font-weight: bold; | ||||
| 		font-size: 20px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .buttons { | ||||
| 		margin-top: 16px; | ||||
| 
 | ||||
| 		> * { | ||||
| 			margin: 0 8px; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> textarea { | ||||
| 		display: block; | ||||
| 		box-sizing: border-box; | ||||
| 		padding: 0 24px; | ||||
| 		margin: 0; | ||||
| 		width: 100%; | ||||
| 		font-size: 16px; | ||||
| 		border: none; | ||||
| 		border-radius: 0; | ||||
| 		background: transparent; | ||||
| 		color: var(--fg); | ||||
| 		font-family: inherit; | ||||
| 		max-width: 100%; | ||||
| 		min-width: 100%; | ||||
| 		min-height: 90px; | ||||
| 
 | ||||
| 		&:focus { | ||||
| 			outline: none; | ||||
| 		} | ||||
| 
 | ||||
| 		&:disabled { | ||||
| 			opacity: 0.5; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| .hdrwpsaf { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	height: 100%; | ||||
| 
 | ||||
| 	> header, | ||||
| 	> footer { | ||||
| 		align-self: center; | ||||
| 		display: inline-block; | ||||
| 		padding: 6px 9px; | ||||
| 		font-size: 90%; | ||||
| 		background: rgba(0, 0, 0, 0.5); | ||||
| 		border-radius: 6px; | ||||
| 		color: #fff; | ||||
| 	} | ||||
| 
 | ||||
| 	> header { | ||||
| 		margin-bottom: 8px; | ||||
| 		opacity: 0.9; | ||||
| 	} | ||||
| 
 | ||||
| 	> img { | ||||
| 		display: block; | ||||
| 		flex: 1; | ||||
| 		min-height: 0; | ||||
| 		object-fit: contain; | ||||
| 		width: 100%; | ||||
| 		cursor: zoom-out; | ||||
| 		image-orientation: from-image; | ||||
| 	} | ||||
| 
 | ||||
| 	> footer { | ||||
| 		margin-top: 8px; | ||||
| 		opacity: 0.8; | ||||
| 
 | ||||
| 		> span + span { | ||||
| 			margin-left: 0.5em; | ||||
| 			padding-left: 0.5em; | ||||
| 			border-left: solid 1px rgba(255, 255, 255, 0.5); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -1,6 +1,6 @@ | |||
| <template> | ||||
| <div class="qjewsnkg" v-if="hide" @click="hide = false"> | ||||
| 	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.name"/> | ||||
| 	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> | ||||
| 	<div class="text"> | ||||
| 		<div> | ||||
| 			<b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> | ||||
|  | @ -14,7 +14,7 @@ | |||
| 		:title="image.name" | ||||
| 		@click.prevent="onClick" | ||||
| 	> | ||||
| 		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name" :cover="false"/> | ||||
| 		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> | ||||
| 		<div class="gif" v-if="image.type === 'image/gif'">GIF</div> | ||||
| 	</a> | ||||
| 	<i class="fas fa-eye-slash" @click="hide = true"></i> | ||||
|  |  | |||
|  | @ -89,6 +89,27 @@ export default defineComponent({ | |||
| 				file.name = result; | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async describe(file) { | ||||
| 			os.popup(import("@client/components/media-caption.vue"), { | ||||
| 				title: this.$ts.describeFile, | ||||
| 				input: { | ||||
| 					placeholder: this.$ts.inputNewDescription, | ||||
| 					default: file.comment !== null ? file.comment : "", | ||||
| 				}, | ||||
| 				image: file | ||||
| 			}, { | ||||
| 				done: result => { | ||||
| 					if (!result || result.canceled) return; | ||||
| 					let comment = result.result; | ||||
| 					os.api('drive/files/update', { | ||||
| 						fileId: file.id, | ||||
| 						comment: comment.length == 0 ? null : comment | ||||
| 					}); | ||||
| 				} | ||||
| 			}, 'closed'); | ||||
| 		}, | ||||
| 
 | ||||
| 		showFileMenu(file, ev: MouseEvent) { | ||||
| 			if (this.menu) return; | ||||
| 			this.menu = os.modalMenu([{ | ||||
|  | @ -99,6 +120,10 @@ export default defineComponent({ | |||
| 				text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, | ||||
| 				icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', | ||||
| 				action: () => { this.toggleSensitive(file) } | ||||
| 			}, { | ||||
| 				text: this.$ts.describeFile, | ||||
| 				icon: 'fas fa-i-cursor', | ||||
| 				action: () => { this.describe(file) } | ||||
| 			}, { | ||||
| 				text: this.$ts.attachCancel, | ||||
| 				icon: 'fas fa-times-circle', | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<Drive | |||
| 	const instance = await fetchMeta(); | ||||
| 	const cache = instance.cacheRemoteFiles; | ||||
| 
 | ||||
| 	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache); | ||||
| 	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache, image.name); | ||||
| 
 | ||||
| 	if (file.isLink) { | ||||
| 		// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
 | ||||
|  |  | |||
|  | @ -4,5 +4,6 @@ import { DriveFiles } from '../../../models'; | |||
| export default (file: DriveFile) => ({ | ||||
| 	type: 'Document', | ||||
| 	mediaType: file.type, | ||||
| 	url: DriveFiles.getPublicUrl(file) | ||||
| 	url: DriveFiles.getPublicUrl(file), | ||||
| 	name: file.comment, | ||||
| }); | ||||
|  |  | |||
|  | @ -4,5 +4,6 @@ import { DriveFiles } from '../../../models'; | |||
| export default (file: DriveFile) => ({ | ||||
| 	type: 'Image', | ||||
| 	url: DriveFiles.getPublicUrl(file), | ||||
| 	sensitive: file.isSensitive | ||||
| 	sensitive: file.isSensitive, | ||||
| 	name: file.comment | ||||
| }); | ||||
|  |  | |||
|  | @ -49,6 +49,14 @@ export const meta = { | |||
| 				'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか', | ||||
| 				'en-US': 'Whether this media is NSFW' | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		comment: { | ||||
| 			validator: $.optional.nullable.str, | ||||
| 			default: undefined as any, | ||||
| 			desc: { | ||||
| 				'ja-JP': 'コメント' | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -92,6 +100,8 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	if (ps.name) file.name = ps.name; | ||||
| 
 | ||||
| 	if (ps.comment !== undefined) file.comment = ps.comment; | ||||
| 
 | ||||
| 	if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; | ||||
| 
 | ||||
| 	if (ps.folderId !== undefined) { | ||||
|  | @ -113,6 +123,7 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	await DriveFiles.update(file.id, { | ||||
| 		name: file.name, | ||||
| 		comment: file.comment, | ||||
| 		folderId: file.folderId, | ||||
| 		isSensitive: file.isSensitive | ||||
| 	}); | ||||
|  |  | |||
|  | @ -25,6 +25,12 @@ export default async ( | |||
| 		name = null; | ||||
| 	} | ||||
| 
 | ||||
| 	// If the comment is same as the name, skip comment
 | ||||
| 	// (image.name is passed in when receiving attachment)
 | ||||
| 	if (comment !== null && name == comment) { | ||||
| 		comment = null; | ||||
| 	} | ||||
| 
 | ||||
| 	// Create temp file
 | ||||
| 	const [path, cleanup] = await createTemp(); | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue