Merge remote-tracking branch 'misskey-mattyatea/emoji-request' into develop
# Conflicts: # locales/index.d.ts # locales/ja-JP.yml # packages/backend/src/core/UserFollowingService.ts # packages/frontend/src/components/MkEmojiPicker.vue # packages/frontend/src/pages/custom-emojis-manager.vue
This commit is contained in:
		
						commit
						c7c70c1c30
					
				|  | @ -35,6 +35,10 @@ | |||
| ## 2023.10.1 | ||||
| ### General | ||||
| - Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に | ||||
| - Feat: 絵文字申請を追加 | ||||
| 	- これによって絵文字リクエストロールが追加されました。 | ||||
|     - カスタム絵文字管理の画面に 申請されている絵文字 タブが追加されました。 | ||||
|     - カスタム絵文字のリクエストボタンが実装されました。 | ||||
| 
 | ||||
| ### Client | ||||
| - Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正 | ||||
|  | @ -78,6 +82,7 @@ | |||
| - Enhance: 動画再生時のデフォルトボリュームを30%に | ||||
| - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 | ||||
| 
 | ||||
| 
 | ||||
| ### Server | ||||
| - Enhance: drive/files/attached-notes がページネーションに対応しました | ||||
| - Enhance: タイムライン取得時のパフォーマンスを大幅に向上 | ||||
|  |  | |||
|  | @ -272,6 +272,7 @@ export interface Locale { | |||
|     "removed": string; | ||||
|     "removeAreYouSure": string; | ||||
|     "deleteAreYouSure": string; | ||||
|     "undraftAreYouSure": string; | ||||
|     "resetAreYouSure": string; | ||||
|     "saved": string; | ||||
|     "messaging": string; | ||||
|  | @ -676,6 +677,8 @@ export interface Locale { | |||
|     "regenerateLoginToken": string; | ||||
|     "regenerateLoginTokenDescription": string; | ||||
|     "setMultipleBySeparatingWithSpace": string; | ||||
|     "emojiNameValidation": string; | ||||
|     "isSensitive": string; | ||||
|     "fileIdOrUrl": string; | ||||
|     "behavior": string; | ||||
|     "sample": string; | ||||
|  | @ -849,8 +852,11 @@ export interface Locale { | |||
|     "low": string; | ||||
|     "GamingSpeedChange": string; | ||||
|     "GamingSpeedChangeInfo": string; | ||||
|     "list": string; | ||||
|     "emailNotConfiguredWarning": string; | ||||
|     "ratio": string; | ||||
|     "newEmojis": string; | ||||
|     "draftEmojis": string; | ||||
|     "showVisibilityColor": string; | ||||
|     "previewNoteText": string; | ||||
|     "customCss": string; | ||||
|  | @ -1004,6 +1010,7 @@ export interface Locale { | |||
|     "unassign": string; | ||||
|     "color": string; | ||||
|     "manageCustomEmojis": string; | ||||
|     "requestCustomEmojis": string; | ||||
|     "youCannotCreateAnymore": string; | ||||
|     "cannotPerformTemporary": string; | ||||
|     "cannotPerformTemporaryDescription": string; | ||||
|  | @ -1046,6 +1053,8 @@ export interface Locale { | |||
|     "sensitiveWordsDescription2": string; | ||||
|     "notesSearchNotAvailable": string; | ||||
|     "license": string; | ||||
|     "draft": string; | ||||
|     "undrafted": string; | ||||
|     "unfavoriteConfirm": string; | ||||
|     "myClips": string; | ||||
|     "drivecleaner": string; | ||||
|  | @ -1585,6 +1594,7 @@ export interface Locale { | |||
|             "inviteLimitCycle": string; | ||||
|             "inviteExpirationTime": string; | ||||
|             "canManageCustomEmojis": string; | ||||
|             "canRequestCustomEmojis": string; | ||||
|             "driveCapacity": string; | ||||
|             "alwaysMarkNsfw": string; | ||||
|             "pinMax": string; | ||||
|  |  | |||
|  | @ -269,6 +269,7 @@ remove: "削除" | |||
| removed: "削除しました" | ||||
| removeAreYouSure: "「{x}」を削除しますか?" | ||||
| deleteAreYouSure: "「{x}」を削除しますか?" | ||||
| undraftAreYouSure: "「{x}」をドラフト解除しますか?" | ||||
| resetAreYouSure: "リセットしますか?" | ||||
| saved: "保存しました" | ||||
| messaging: "チャット" | ||||
|  | @ -673,6 +674,8 @@ other: "その他" | |||
| regenerateLoginToken: "ログイントークンを再生成" | ||||
| regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" | ||||
| setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" | ||||
| emojiNameValidation: "名前には英数字と_が利用できます。" | ||||
| isSensitive: "センシティブ" | ||||
| fileIdOrUrl: "ファイルIDまたはURL" | ||||
| behavior: "動作" | ||||
| sample: "サンプル" | ||||
|  | @ -846,9 +849,12 @@ middle: "中" | |||
| low: "低" | ||||
| GamingSpeedChange: "ゲーミングの光るスピードの調整" | ||||
| GamingSpeedChangeInfo: "左にすれば早くなる、右にすれば遅くなる。それだけ。" | ||||
| list: "一覧" | ||||
| emailNotConfiguredWarning: "メールアドレスの設定がされていません。" | ||||
| ratio: "比率" | ||||
| showVisibilityColor: "ノートの公開範囲を色付けする" | ||||
| newEmojis: "新しい絵文字" | ||||
| draftEmojis: "申請されている絵文字" | ||||
| previewNoteText: "本文をプレビュー" | ||||
| customCss: "カスタムCSS" | ||||
| customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" | ||||
|  | @ -1001,6 +1007,7 @@ assign: "アサイン" | |||
| unassign: "アサインを解除" | ||||
| color: "色" | ||||
| manageCustomEmojis: "カスタム絵文字の管理" | ||||
| requestCustomEmojis: "カスタム絵文字のリクエスト" | ||||
| youCannotCreateAnymore: "これ以上作成することはできません。" | ||||
| cannotPerformTemporary: "一時的に利用できません" | ||||
| cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" | ||||
|  | @ -1043,6 +1050,8 @@ sensitiveWordsDescription: "設定したワードが含まれるノートの公 | |||
| sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" | ||||
| notesSearchNotAvailable: "ノート検索は利用できません。" | ||||
| license: "ライセンス" | ||||
| draft: "ドラフト" | ||||
| undrafted: "ドラフト解除" | ||||
| unfavoriteConfirm: "お気に入り解除しますか?" | ||||
| myClips: "自分のクリップ" | ||||
| drivecleaner: "ドライブクリーナー" | ||||
|  | @ -1506,6 +1515,7 @@ _role: | |||
|     inviteLimitCycle: "招待コードの発行間隔" | ||||
|     inviteExpirationTime: "招待コードの有効期限" | ||||
|     canManageCustomEmojis: "カスタム絵文字の管理" | ||||
|     canRequestCustomEmojis: "カスタム絵文字のリクエスト" | ||||
|     driveCapacity: "ドライブ容量" | ||||
|     alwaysMarkNsfw: "ファイルにNSFWを常に付与" | ||||
|     pinMax: "ノートのピン留めの最大数" | ||||
|  |  | |||
|  | @ -0,0 +1,11 @@ | |||
| export class AddEmojiDraftFlag1684236161625 { | ||||
|     name = 'AddEmojiDraftFlag1684236161625' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "emoji" ADD "draft" boolean NOT NULL DEFAULT false`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "draft"`); | ||||
|     } | ||||
| } | ||||
|  | @ -16,10 +16,8 @@ import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; | |||
| import { bindThis } from '@/decorators.js'; | ||||
| import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { query } from '@/misc/prelude/url.js'; | ||||
| import type { Serialized } from '@/types.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| 
 | ||||
| const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; | ||||
| 
 | ||||
| @Injectable() | ||||
|  | @ -66,6 +64,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | |||
| 		license: string | null; | ||||
| 		isSensitive: boolean; | ||||
| 		localOnly: boolean; | ||||
|         draft: boolean; | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; | ||||
| 	}, moderator?: MiUser): Promise<MiEmoji> { | ||||
| 		const emoji = await this.emojisRepository.insert({ | ||||
|  | @ -82,6 +81,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | |||
| 			isSensitive: data.isSensitive, | ||||
| 			localOnly: data.localOnly, | ||||
| 			roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, | ||||
| 			draft: data.draft, | ||||
| 		}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 
 | ||||
| 		if (data.host == null) { | ||||
|  | @ -111,6 +111,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | |||
| 		license?: string | null; | ||||
| 		isSensitive?: boolean; | ||||
| 		localOnly?: boolean; | ||||
| 		draft: boolean; | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; | ||||
| 	}, moderator?: MiUser): Promise<void> { | ||||
| 		const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); | ||||
|  | @ -125,6 +126,7 @@ export class CustomEmojiService implements OnApplicationShutdown { | |||
| 			license: data.license, | ||||
| 			isSensitive: data.isSensitive, | ||||
| 			localOnly: data.localOnly, | ||||
| 			draft: data.draft, | ||||
| 			originalUrl: data.driveFile != null ? data.driveFile.url : undefined, | ||||
| 			publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, | ||||
| 			type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ export type RolePolicies = { | |||
| 	inviteLimitCycle: number; | ||||
| 	inviteExpirationTime: number; | ||||
| 	canManageCustomEmojis: boolean; | ||||
| 	canRequestCustomEmojis: boolean; | ||||
| 	canSearchNotes: boolean; | ||||
| 	canUseTranslator: boolean; | ||||
| 	canHideAds: boolean; | ||||
|  | @ -59,6 +60,7 @@ export const DEFAULT_POLICIES: RolePolicies = { | |||
| 	inviteLimitCycle: 60 * 24 * 7, | ||||
| 	inviteExpirationTime: 0, | ||||
| 	canManageCustomEmojis: false, | ||||
| 	canRequestCustomEmojis: false, | ||||
| 	canSearchNotes: false, | ||||
| 	canUseTranslator: true, | ||||
| 	canHideAds: false, | ||||
|  | @ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown { | |||
| 			inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), | ||||
| 			inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), | ||||
| 			canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), | ||||
| 			canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)), | ||||
| 			canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), | ||||
| 			canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), | ||||
| 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), | ||||
|  |  | |||
|  | @ -19,7 +19,13 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; | |||
| import { WebhookService } from '@/core/WebhookService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; | ||||
| import type { | ||||
| 	FollowingsRepository, | ||||
| 	FollowRequestsRepository, | ||||
| 	InstancesRepository, | ||||
| 	UserProfilesRepository, | ||||
| 	UsersRepository, | ||||
| } from '@/models/_.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
|  | @ -53,25 +59,18 @@ export class UserFollowingService implements OnModuleInit { | |||
| 
 | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
| 
 | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
| 
 | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
| 
 | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
| 
 | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.followRequestsRepository) | ||||
| 		private followRequestsRepository: FollowRequestsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
| 
 | ||||
| 		private cacheService: CacheService, | ||||
| 		private utilityService: UtilityService, | ||||
| 		private userEntityService: UserEntityService, | ||||
|  | @ -197,10 +196,18 @@ export class UserFollowingService implements OnModuleInit { | |||
| 	@bindThis | ||||
| 	private async insertFollowingDoc( | ||||
| 		followee: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox'] | ||||
| 		}, | ||||
| 		follower: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox'] | ||||
| 		}, | ||||
| 		silent = false, | ||||
| 		withReplies?: boolean, | ||||
|  | @ -247,8 +254,7 @@ export class UserFollowingService implements OnModuleInit { | |||
| 			}); | ||||
| 
 | ||||
| 			// 通知を作成
 | ||||
| 			this.notificationService.createNotification(follower.id, 'followRequestAccepted', { | ||||
| 			}, followee.id); | ||||
| 			this.notificationService.createNotification(follower.id, 'followRequestAccepted', {}, followee.id); | ||||
| 		} | ||||
| 
 | ||||
| 		if (alreadyFollowed) return; | ||||
|  | @ -322,18 +328,25 @@ export class UserFollowingService implements OnModuleInit { | |||
| 			}); | ||||
| 
 | ||||
| 			// 通知を作成
 | ||||
| 			this.notificationService.createNotification(followee.id, 'follow', { | ||||
| 			}, follower.id); | ||||
| 			this.notificationService.createNotification(followee.id, 'follow', {}, follower.id); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@bindThis | ||||
| 	public async unfollow( | ||||
| 		follower: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 		followee: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 		silent = false, | ||||
| 	): Promise<void> { | ||||
|  | @ -464,10 +477,18 @@ export class UserFollowingService implements OnModuleInit { | |||
| 	@bindThis | ||||
| 	public async createFollowRequest( | ||||
| 		follower: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 		followee: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 		requestId?: string, | ||||
| 		withReplies?: boolean, | ||||
|  | @ -560,7 +581,11 @@ export class UserFollowingService implements OnModuleInit { | |||
| 	@bindThis | ||||
| 	public async acceptFollowRequest( | ||||
| 		followee: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 		follower: MiUser, | ||||
| 	): Promise<void> { | ||||
|  | @ -588,7 +613,11 @@ export class UserFollowingService implements OnModuleInit { | |||
| 	@bindThis | ||||
| 	public async acceptAllFollowRequests( | ||||
| 		user: { | ||||
| 			id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; | ||||
| 			id: MiUser['id']; | ||||
| 			host: MiUser['host']; | ||||
| 			uri: MiUser['host']; | ||||
| 			inbox: MiUser['inbox']; | ||||
| 			sharedInbox: MiUser['sharedInbox']; | ||||
| 		}, | ||||
| 	): Promise<void> { | ||||
| 		const requests = await this.followRequestsRepository.findBy({ | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ export class EmojiEntityService { | |||
| 			url: emoji.publicUrl || emoji.originalUrl, | ||||
| 			isSensitive: emoji.isSensitive ? true : undefined, | ||||
| 			roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, | ||||
| 			draft: emoji.draft, | ||||
| 		}; | ||||
| 	} | ||||
| 
 | ||||
|  | @ -61,6 +62,7 @@ export class EmojiEntityService { | |||
| 			isSensitive: emoji.isSensitive, | ||||
| 			localOnly: emoji.localOnly, | ||||
| 			roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, | ||||
| 			draft: emoji.draft, | ||||
| 		}; | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -81,4 +81,10 @@ export class MiEmoji { | |||
| 		array: true, length: 128, default: '{}', | ||||
| 	}) | ||||
| 	public roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 		nullable: false, | ||||
| 	}) | ||||
| 	public draft: boolean; | ||||
| } | ||||
|  |  | |||
|  | @ -40,6 +40,10 @@ export const packedEmojiSimpleSchema = { | |||
| 				format: 'id', | ||||
| 			}, | ||||
| 		}, | ||||
| 		draft: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: true, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
|  | @ -81,6 +85,10 @@ export const packedEmojiDetailedSchema = { | |||
| 			type: 'string', | ||||
| 			optional: false, nullable: true, | ||||
| 		}, | ||||
| 		draft: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: true, | ||||
| 		}, | ||||
| 		isSensitive: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
|  |  | |||
|  | @ -103,6 +103,7 @@ export class ImportCustomEmojisProcessorService { | |||
| 					isSensitive: emojiInfo.isSensitive, | ||||
| 					localOnly: emojiInfo.localOnly, | ||||
| 					roleIdsThatCanBeUsedThisEmojiAsReaction: [], | ||||
| 					draft: false, | ||||
| 				}); | ||||
| 			} | ||||
| 
 | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-al | |||
| import * as ep___admin_emoji_setlocalOnlyBulk from './endpoints/admin/emoji/set-localonly-bulk.js'; | ||||
| import * as ep___admin_emoji_setisSensitiveBulk from './endpoints/admin/emoji/set-issensitive-bulk.js'; | ||||
| import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; | ||||
| import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js'; | ||||
| import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; | ||||
| import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; | ||||
| import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; | ||||
|  | @ -382,6 +383,7 @@ const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-ali | |||
| const $admin_emoji_setlocalOnlyBulk: Provider = { provide: 'ep:admin/emoji/set-localonly-bulk', useClass: ep___admin_emoji_setlocalOnlyBulk.default }; | ||||
| const $admin_emoji_setisSensitiveBulk: Provider = { provide: 'ep:admin/emoji/set-issensitive-bulk', useClass: ep___admin_emoji_setisSensitiveBulk.default }; | ||||
| const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; | ||||
| const $admin_emoji_addDraft: Provider = { provide: 'ep:admin/emoji/add-draft', useClass: ep___admin_emoji_addDraft.default }; | ||||
| const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; | ||||
| const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; | ||||
| const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; | ||||
|  | @ -741,6 +743,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |||
|         $admin_emoji_setlocalOnlyBulk, | ||||
|         $admin_emoji_setisSensitiveBulk, | ||||
| 		$admin_emoji_add, | ||||
| 		$admin_emoji_addDraft, | ||||
| 		$admin_emoji_copy, | ||||
| 		$admin_emoji_deleteBulk, | ||||
| 		$admin_emoji_delete, | ||||
|  | @ -1092,6 +1095,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |||
| 		$admin_drive_showFile, | ||||
| 		$admin_emoji_addAliasesBulk, | ||||
| 		$admin_emoji_add, | ||||
| 		$admin_emoji_addDraft, | ||||
| 		$admin_emoji_copy, | ||||
| 		$admin_emoji_deleteBulk, | ||||
| 		$admin_emoji_delete, | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; | |||
| import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; | ||||
| import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; | ||||
| import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; | ||||
| import * as ep___admin_emoji_addDraft from './endpoints/admin/emoji/add-draft.js'; | ||||
| import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; | ||||
| import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; | ||||
| import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; | ||||
|  | @ -377,6 +378,7 @@ const eps = [ | |||
| 	['admin/drive/show-file', ep___admin_drive_showFile], | ||||
| 	['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], | ||||
| 	['admin/emoji/add', ep___admin_emoji_add], | ||||
| 	['admin/emoji/add-draft', ep___admin_emoji_addDraft], | ||||
| 	['admin/emoji/copy', ep___admin_emoji_copy], | ||||
| 	['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], | ||||
| 	['admin/emoji/delete', ep___admin_emoji_delete], | ||||
|  |  | |||
|  | @ -0,0 +1,80 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { DriveFilesRepository } from '@/models/_.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { CustomEmojiService } from '@/core/CustomEmojiService.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	requireRolePolicy: 'canRequestCustomEmojis', | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchFile: { | ||||
| 			message: 'No such file.', | ||||
| 			code: 'NO_SUCH_FILE', | ||||
| 			id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, | ||||
| 		category: { | ||||
| 			type: 'string', | ||||
| 			nullable: true, | ||||
| 			description: 'Use `null` to reset the category.', | ||||
| 		}, | ||||
| 		aliases: { type: 'array', items: { | ||||
| 			type: 'string', | ||||
| 		} }, | ||||
| 		license: { type: 'string', nullable: true }, | ||||
| 		fileId: { type: 'string', format: 'misskey:id' }, | ||||
| 		isSensitive: { type: 'boolean' }, | ||||
| 		localOnly: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['name', 'fileId'], | ||||
| } as const; | ||||
| 
 | ||||
| // TODO: ロジックをサービスに切り出す
 | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
| 
 | ||||
| 		private customEmojiService: CustomEmojiService, | ||||
| 
 | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); | ||||
| 
 | ||||
| 			if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); | ||||
| 
 | ||||
| 			const emoji = await this.customEmojiService.add({ | ||||
| 				driveFile, | ||||
| 				name: ps.name, | ||||
| 				category: ps.category ?? null, | ||||
| 				aliases: ps.aliases ?? [], | ||||
| 				license: ps.license ?? null, | ||||
| 				host: null, | ||||
| 				draft: true, | ||||
| 				isSensitive: ps.isSensitive ?? false, | ||||
| 				localOnly: ps.localOnly ?? false, | ||||
| 				roleIdsThatCanBeUsedThisEmojiAsReaction: [], | ||||
| 			}); | ||||
| 
 | ||||
| 			return { | ||||
| 				id: emoji.id, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -41,17 +41,21 @@ export const paramDef = { | |||
| 			nullable: true, | ||||
| 			description: 'Use `null` to reset the category.', | ||||
| 		}, | ||||
| 		aliases: { type: 'array', items: { | ||||
| 			type: 'string', | ||||
| 		} }, | ||||
| 		aliases: { | ||||
| 			type: 'array', items: { | ||||
| 				type: 'string', | ||||
| 			}, | ||||
| 		}, | ||||
| 		license: { type: 'string', nullable: true }, | ||||
| 		isSensitive: { type: 'boolean' }, | ||||
| 		localOnly: { type: 'boolean' }, | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { | ||||
| 			type: 'string', | ||||
| 		} }, | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: { | ||||
| 			type: 'array', items: { | ||||
| 				type: 'string', | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| 	required: ['name', 'fileId'], | ||||
| 	required: ['name', 'fileId', 'draft'], | ||||
| } as const; | ||||
| 
 | ||||
| // TODO: ロジックをサービスに切り出す
 | ||||
|  | @ -61,13 +65,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
| 
 | ||||
| 		private customEmojiService: CustomEmojiService, | ||||
| 
 | ||||
| 		private emojiEntityService: EmojiEntityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); | ||||
| 
 | ||||
| 			if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); | ||||
| 			const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); | ||||
| 			if (isDuplicate) throw new ApiError(meta.errors.duplicateName); | ||||
|  | @ -81,6 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				license: ps.license ?? null, | ||||
| 				isSensitive: ps.isSensitive ?? false, | ||||
| 				localOnly: ps.localOnly ?? false, | ||||
| 				draft: false, | ||||
| 				roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], | ||||
| 			}, me); | ||||
| 
 | ||||
|  |  | |||
|  | @ -64,6 +64,7 @@ export const paramDef = { | |||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		query: { type: 'string', nullable: true, default: null }, | ||||
| 		draft: { type: 'boolean', nullable: true, default: null }, | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
|  | @ -86,6 +87,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 
 | ||||
| 			let emojis: MiEmoji[]; | ||||
| 
 | ||||
| 			if (ps.draft !== null) { | ||||
| 				if (ps.draft) { | ||||
| 					q.andWhere('emoji.draft = TRUE'); | ||||
| 				} else { | ||||
| 					q.andWhere('emoji.draft = FALSE'); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if (ps.query) { | ||||
| 				//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
 | ||||
| 				//const emojis = await q.limit(ps.limit).getMany();
 | ||||
|  |  | |||
|  | @ -60,8 +60,9 @@ export const paramDef = { | |||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { | ||||
| 			type: 'string', | ||||
| 		} }, | ||||
| 		draft: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['id', 'name', 'aliases'], | ||||
| 	required: ['id', 'name', 'draft', 'aliases'], | ||||
| } as const; | ||||
| 
 | ||||
| @Injectable() | ||||
|  | @ -97,6 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 				isSensitive: ps.isSensitive, | ||||
| 				localOnly: ps.localOnly, | ||||
| 				roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, | ||||
| 				draft: ps.draft, | ||||
| 			}, me); | ||||
| 		}); | ||||
| 	} | ||||
|  |  | |||
|  | @ -96,6 +96,10 @@ const emojiDb = computed(() => { | |||
| 	const customEmojiDB: EmojiDef[] = []; | ||||
| 
 | ||||
| 	for (const x of customEmojis.value) { | ||||
| 		if (x.draft) { | ||||
| 			continue; | ||||
| 		} | ||||
| 
 | ||||
| 		customEmojiDB.push({ | ||||
| 			name: x.name, | ||||
| 			emoji: `:${x.name}:`, | ||||
|  |  | |||
|  | @ -0,0 +1,214 @@ | |||
| <template> | ||||
| <MkPagination ref="emojisDraftPaginationComponent" :pagination="paginationDraft"> | ||||
| 	<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> | ||||
| 	<template #default="{items}"> | ||||
| 		<div class="ldhfsamy"> | ||||
| 			<template v-for="emoji in items" :key="emoji.id"> | ||||
| 				<div class="emoji _panel"> | ||||
| 					<div class="img"> | ||||
| 						<div class="imgLight"><img :src="emoji.url" :alt="emoji.name"/></div> | ||||
| 						<div class="imgDark"><img :src="emoji.url" :alt="emoji.name"/></div> | ||||
| 					</div> | ||||
| 					<div class="info"> | ||||
| 						<div class="name">{{ i18n.ts.name }}: {{ emoji.name }}</div> | ||||
| 						<div class="category">{{ i18n.ts.category }}:{{ emoji.category }}</div> | ||||
| 						<div class="aliases">{{ i18n.ts.tags }}:{{ emoji.aliases.join(' ') }}</div> | ||||
| 						<div class="license">{{ i18n.ts.license }}:{{ emoji.license }}</div> | ||||
| 					</div> | ||||
| 					<div class="edit-button"> | ||||
| 						<MkButton primary class="edit" @click="editDraft(emoji)"> | ||||
| 							{{ i18n.ts.edit }} | ||||
| 						</MkButton> | ||||
| 						<MkButton class="draft" @click="undrafted(emoji)"> | ||||
| 							{{ i18n.ts.undrafted }} | ||||
| 						</MkButton> | ||||
| 						<MkButton danger class="delete" @click="deleteDraft(emoji)"> | ||||
| 							{{ i18n.ts.delete }} | ||||
| 						</MkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</template> | ||||
| 		</div> | ||||
| 	</template> | ||||
| </MkPagination> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| 
 | ||||
| const emojisDraftPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); | ||||
| 
 | ||||
| const query = ref(null); | ||||
| 
 | ||||
| const paginationDraft = { | ||||
| 	endpoint: 'admin/emoji/list' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		query: (query.value && query.value !== '') ? query.value : null, | ||||
| 		draft: true, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| const editDraft = (emoji) => { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { | ||||
| 		emoji: emoji, | ||||
| 		isRequest: false, | ||||
| 	}, { | ||||
| 		done: result => { | ||||
| 			if (result.updated) { | ||||
| 				emojisDraftPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ | ||||
| 					...oldEmoji, | ||||
| 					...result.updated, | ||||
| 				})); | ||||
| 				emojisDraftPaginationComponent.value.reload(); | ||||
| 			} else if (result.deleted) { | ||||
| 				emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); | ||||
| 				emojisDraftPaginationComponent.value.reload(); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, 'closed'); | ||||
| }; | ||||
| 
 | ||||
| async function undrafted(emoji) { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('undraftAreYouSure', { x: emoji.name }), | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 
 | ||||
| 	await os.api('admin/emoji/update', { | ||||
| 		id: emoji.id, | ||||
| 		name: emoji.name, | ||||
| 		category: emoji.category, | ||||
| 		aliases: emoji.aliases, | ||||
| 		license: emoji.license, | ||||
| 		draft: false, | ||||
| 		isSensitive: emoji.isSensitive, | ||||
| 		localOnly: emoji.localOnly, | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction, | ||||
| 	}); | ||||
| 
 | ||||
| 	emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); | ||||
| 	emojisDraftPaginationComponent.value.reload(); | ||||
| } | ||||
| 
 | ||||
| async function deleteDraft(emoji) { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('removeAreYouSure', { x: emoji.name }), | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 
 | ||||
| 	os.api('admin/emoji/delete', { | ||||
| 		id: emoji.id, | ||||
| 	}).then(() => { | ||||
| 		emojisDraftPaginationComponent.value.removeItem((item) => item.id === emoji.id); | ||||
| 		emojisDraftPaginationComponent.value.reload(); | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .empty { | ||||
| 	margin: var(--margin); | ||||
| } | ||||
| 
 | ||||
| .ldhfsamy { | ||||
| 	> .emoji { | ||||
| 		align-items: center; | ||||
| 		padding: 11px; | ||||
| 		text-align: left; | ||||
| 		border: solid 1px var(--panel); | ||||
| 		margin: 10px; | ||||
| 
 | ||||
| 		> .img { | ||||
| 			display: grid; | ||||
| 			grid-row: 1; | ||||
| 			grid-column: 1/ span 2; | ||||
| 			grid-template-columns: 50% 50%; | ||||
| 			place-content: center; | ||||
| 			place-items: center; | ||||
| 
 | ||||
| 			> .imgLight { | ||||
| 				display: grid; | ||||
| 				grid-column: 1; | ||||
| 				background-color: #fff; | ||||
|                 margin-bottom: 12px; | ||||
| 				> img { | ||||
|                   max-height: 64px; | ||||
| 					max-width: 100%; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .imgDark { | ||||
| 				display: grid; | ||||
| 				grid-column: 2; | ||||
| 				background-color: #000; | ||||
|                 margin-bottom: 12px; | ||||
| 				> img { | ||||
| 					max-height: 64px; | ||||
| 					max-width: 100%; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .info { | ||||
| 			display: grid; | ||||
| 			grid-row: 2; | ||||
| 			grid-template-rows: 30px 30px 30px; | ||||
| 
 | ||||
| 			> .name { | ||||
| 				grid-row: 1; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 
 | ||||
| 			> .category { | ||||
| 				grid-row: 2; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 
 | ||||
| 			> .aliases { | ||||
| 				grid-row: 3; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 
 | ||||
| 			> .license { | ||||
| 				grid-row: 4; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .edit-button { | ||||
| 			display: grid; | ||||
| 			grid-template-rows: 42px; | ||||
|             margin-top: 6px; | ||||
| 
 | ||||
| 			> .edit { | ||||
| 				grid-row: 1; | ||||
|                 width: 100%; | ||||
|                 margin: 6px 0; | ||||
| 			} | ||||
| 
 | ||||
| 			> .draft { | ||||
| 				grid-row: 2; | ||||
|                 width: 100%; | ||||
|                 margin: 6px 0; | ||||
| 			} | ||||
| 
 | ||||
| 			> .delete { | ||||
| 				grid-row: 3; | ||||
|                 width: 100%; | ||||
|                 margin: 6px 0; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -0,0 +1,225 @@ | |||
| <template> | ||||
| <MkInput v-model="query" :debounce="true" type="search"> | ||||
| 	<template #prefix><i class="ti ti-search"></i></template> | ||||
| 	<template #label>{{ i18n.ts.search }}</template> | ||||
| </MkInput> | ||||
| <MkSwitch v-model="selectMode" style="margin: 8px 0;"> | ||||
| 	<template #label>Select mode</template> | ||||
| </MkSwitch> | ||||
| <div v-if="selectMode" class="_buttons"> | ||||
| 	<MkButton inline @click="selectAll">Select all</MkButton> | ||||
| 	<MkButton inline @click="setCategoryBulk">Set category</MkButton> | ||||
| 	<MkButton inline @click="setTagBulk">Set tag</MkButton> | ||||
| 	<MkButton inline @click="addTagBulk">Add tag</MkButton> | ||||
| 	<MkButton inline @click="removeTagBulk">Remove tag</MkButton> | ||||
| 	<MkButton inline @click="setLisenceBulk">Set Lisence</MkButton> | ||||
| 	<MkButton inline danger @click="delBulk">Delete</MkButton> | ||||
| </div> | ||||
| <MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="100"> | ||||
| 	<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> | ||||
| 	<template #default="{items}"> | ||||
| 		<div class="ldhfsamy"> | ||||
| 			<div v-for="emoji in items" :key="emoji.id"> | ||||
| 				<button v-if="emoji.draft" class="emoji _panel _button emoji-draft" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> | ||||
| 					<img :src="emoji.url" class="img" :alt="emoji.name"/> | ||||
| 					<div class="body"> | ||||
| 						<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div> | ||||
| 						<div class="info">{{ emoji.category }}</div> | ||||
| 					</div> | ||||
| 				</button> | ||||
| 				<button v-else class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> | ||||
| 					<img :src="emoji.url" class="img" :alt="emoji.name"/> | ||||
| 					<div class="body"> | ||||
| 						<div class="name _monospace">{{ emoji.name }}</div> | ||||
| 						<div class="info">{{ emoji.category }}</div> | ||||
| 					</div> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
| </MkPagination> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| 
 | ||||
| const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); | ||||
| 
 | ||||
| const query = ref(null); | ||||
| const selectMode = ref(false); | ||||
| const selectedEmojis = ref<string[]>([]); | ||||
| 
 | ||||
| const pagination = { | ||||
| 	endpoint: 'admin/emoji/list' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		query: (query.value && query.value !== '') ? query.value : null, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| const selectAll = () => { | ||||
| 	if (selectedEmojis.value.length > 0) { | ||||
| 		selectedEmojis.value = []; | ||||
| 	} else { | ||||
| 		selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| const toggleSelect = (emoji) => { | ||||
| 	if (selectedEmojis.value.includes(emoji.id)) { | ||||
| 		selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); | ||||
| 	} else { | ||||
| 		selectedEmojis.value.push(emoji.id); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| const edit = (emoji) => { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { | ||||
| 		emoji: emoji, | ||||
| 		isRequest: false, | ||||
| 	}, { | ||||
| 		done: result => { | ||||
| 			if (result.updated) { | ||||
| 				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ | ||||
| 					...oldEmoji, | ||||
| 					...result.updated, | ||||
| 				})); | ||||
| 				emojisPaginationComponent.value.reload(); | ||||
| 			} else if (result.deleted) { | ||||
| 				emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, 'closed'); | ||||
| }; | ||||
| 
 | ||||
| const setCategoryBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Category', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-category-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		category: result, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const setLisenceBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'License', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-license-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		license: result, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const addTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/add-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const removeTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const setTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const delBulk = async () => { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.ts.deleteConfirm, | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/delete-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .ldhfsamy { | ||||
| 	display: grid; | ||||
| 	grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); | ||||
| 	grid-gap: var(--margin); | ||||
| 
 | ||||
| 	div > .emoji { | ||||
| 		display: flex; | ||||
| 		align-items: center; | ||||
| 		padding: 11px; | ||||
| 		text-align: left; | ||||
| 		border: solid 1px var(--panel); | ||||
| 		width: 100%; | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			border-color: var(--inputBorderHover); | ||||
| 		} | ||||
| 
 | ||||
| 		&.selected { | ||||
| 			border-color: var(--accent); | ||||
| 		} | ||||
| 
 | ||||
| 		> .img { | ||||
| 			width: 42px; | ||||
| 			height: 42px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 			padding: 0 0 0 8px; | ||||
| 			white-space: nowrap; | ||||
| 			overflow: hidden; | ||||
| 
 | ||||
| 			> .name { | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 
 | ||||
| 			> .info { | ||||
| 				opacity: 0.5; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .emoji-draft { | ||||
| 	--c: rgb(255 196 0 / 15%);; | ||||
| 	background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%); | ||||
| 	background-size: 16px 16px; | ||||
| } | ||||
| </style> | ||||
|  | @ -0,0 +1,110 @@ | |||
| <template> | ||||
| <FormSplit> | ||||
| 	<MkInput v-model="queryRemote" :debounce="true" type="search"> | ||||
| 		<template #prefix><i class="ti ti-search"></i></template> | ||||
| 		<template #label>{{ i18n.ts.search }}</template> | ||||
| 	</MkInput> | ||||
| 	<MkInput v-model="host" :debounce="true"> | ||||
| 		<template #label>{{ i18n.ts.host }}</template> | ||||
| 	</MkInput> | ||||
| </FormSplit> | ||||
| <MkPagination :pagination="remotePagination" :displayLimit="100"> | ||||
| 	<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> | ||||
| 	<template #default="{items}"> | ||||
| 		<div class="ldhfsamy"> | ||||
| 			<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> | ||||
| 				<img :src="emoji.url" class="img" :alt="emoji.name"/> | ||||
| 				<div class="body"> | ||||
| 					<div class="name _monospace">{{ emoji.name }}</div> | ||||
| 					<div class="info">{{ emoji.host }}</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</template> | ||||
| </MkPagination> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import FormSplit from '@/components/form/split.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| 
 | ||||
| const queryRemote = ref(null); | ||||
| const host = ref(null); | ||||
| 
 | ||||
| const remotePagination = { | ||||
| 	endpoint: 'admin/emoji/list-remote' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, | ||||
| 		host: (host.value && host.value !== '') ? host.value : null, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| const im = (emoji) => { | ||||
| 	os.apiWithDialog('admin/emoji/copy', { | ||||
| 		emojiId: emoji.id, | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| const remoteMenu = (emoji, ev: MouseEvent) => { | ||||
| 	os.popupMenu([{ | ||||
| 		type: 'label', | ||||
| 		text: ':' + emoji.name + ':', | ||||
| 	}, { | ||||
| 		text: i18n.ts.import, | ||||
| 		icon: 'ti ti-plus', | ||||
| 		action: () => { im(emoji); }, | ||||
| 	}], ev.currentTarget ?? ev.target); | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .empty { | ||||
| 	margin: var(--margin); | ||||
| } | ||||
| 
 | ||||
| .ldhfsamy { | ||||
| 	display: grid; | ||||
| 	grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); | ||||
| 	grid-gap: 12px; | ||||
| 	margin: var(--margin) 0; | ||||
| 
 | ||||
| 	> .emoji { | ||||
| 		display: flex; | ||||
| 		align-items: center; | ||||
| 		padding: 12px; | ||||
| 		text-align: left; | ||||
| 
 | ||||
| 		&:hover { | ||||
| 			color: var(--accent); | ||||
| 		} | ||||
| 
 | ||||
| 		> .img { | ||||
| 			width: 32px; | ||||
| 			height: 32px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
| 			padding: 0 0 0 8px; | ||||
| 			white-space: nowrap; | ||||
| 			overflow: hidden; | ||||
| 
 | ||||
| 			> .name { | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 
 | ||||
| 			> .info { | ||||
| 				opacity: 0.5; | ||||
| 				font-size: 90%; | ||||
| 				text-overflow: ellipsis; | ||||
| 				overflow: hidden; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -7,10 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| <MkModalWindow | ||||
| 	ref="dialog" | ||||
| 	:width="400" | ||||
| 	:withOkButton="false " | ||||
| 	@close="dialog.close()" | ||||
| 	@closed="$emit('closed')" | ||||
| > | ||||
| 	<template v-if="emoji" #header>:{{ emoji.name }}:</template> | ||||
| 	<template v-else-if="isRequest" #header>{{ i18n.ts.requestCustomEmojis }}</template> | ||||
| 	<template v-else #header>New emoji</template> | ||||
| 
 | ||||
| 	<div> | ||||
|  | @ -33,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> | ||||
| 				<MkInput v-model="name" pattern="[a-z0-9_]"> | ||||
| 					<template #label>{{ i18n.ts.name }}</template> | ||||
| 					<template #caption>{{ i18n.ts.emojiNameValidation }}</template> | ||||
| 				</MkInput> | ||||
| 				<MkInput v-model="category" :datalist="customEmojiCategories"> | ||||
| 					<template #label>{{ i18n.ts.category }}</template> | ||||
|  | @ -44,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkInput v-model="license"> | ||||
| 					<template #label>{{ i18n.ts.license }}</template> | ||||
| 				</MkInput> | ||||
| 				<MkFolder> | ||||
| 				<MkFolder v-if="!isRequest"> | ||||
| 					<template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template> | ||||
| 					<template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</template> | ||||
| 
 | ||||
|  | @ -61,13 +64,19 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 						<MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo> | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
| 				<MkSwitch v-model="isSensitive">isSensitive</MkSwitch> | ||||
| 				<MkSwitch v-model="isSensitive">{{ i18n.ts.isSensitive }}</MkSwitch> | ||||
| 				<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> | ||||
| 				<MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 				<MkSwitch v-if="!isRequest" v-model="draft" :disabled="isRequest"> | ||||
| 					{{ i18n.ts.draft }} | ||||
| 				</MkSwitch> | ||||
| 			</div> | ||||
| 		</MkSpacer> | ||||
| 		<div :class="$style.footer"> | ||||
| 			<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> | ||||
| 			<div :class="$style.footerButtons"> | ||||
| 				<MkButton v-if="!isRequest" danger rounded style="margin: 0 auto;" @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 				<MkButton v-if="validation" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> | ||||
| 				<MkButton v-else rounded style="margin: 0 auto;"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </MkModalWindow> | ||||
|  | @ -76,6 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { DriveFile } from 'misskey-js/built/entities.js'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
|  | @ -90,6 +100,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue'; | |||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	emoji?: any, | ||||
| 	isRequest: boolean, | ||||
| }>(); | ||||
| 
 | ||||
| let dialog = $ref(null); | ||||
|  | @ -102,18 +113,56 @@ let localOnly = $ref(props.emoji ? props.emoji.localOnly : false); | |||
| let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); | ||||
| let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]); | ||||
| let file = $ref<Misskey.entities.DriveFile>(); | ||||
| let chooseFile: DriveFile|null = $ref(null); | ||||
| let draft = $ref(props.emoji ? props.emoji.draft : false); | ||||
| let isRequest = $ref(props.isRequest); | ||||
| let url; | ||||
| 
 | ||||
| watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => { | ||||
| 	rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); | ||||
| }, { immediate: true }); | ||||
| 
 | ||||
| const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); | ||||
| 
 | ||||
| const validation = computed(() => { | ||||
| 	return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null; | ||||
| }); | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, | ||||
| 	(ev: 'closed'): void | ||||
| }>(); | ||||
| 
 | ||||
| function ok() { | ||||
| 	if (isRequest) { | ||||
| 		if (chooseFile !== null && name.match(/^[a-zA-Z0-9_]+$/)) { | ||||
| 			add(); | ||||
| 		} | ||||
| 	} else { | ||||
| 		update(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| async function add() { | ||||
| 	const ret = await os.api('admin/emoji/add-draft', { | ||||
| 		name: name, | ||||
| 		category: category, | ||||
| 		aliases: aliases.split(' '), | ||||
| 		license: license === '' ? null : license, | ||||
| 		fileId: chooseFile.id, | ||||
| 	}); | ||||
| 
 | ||||
| 	emit('done', { | ||||
| 		updated: { | ||||
| 			id: ret.id, | ||||
| 			name, | ||||
| 			category, | ||||
| 			aliases: aliases.split(' '), | ||||
| 			license: license === '' ? null : license, | ||||
| 			draft: true, | ||||
| 		}, | ||||
| 	}); | ||||
| 
 | ||||
| 	dialog.close(); | ||||
| } | ||||
| async function changeImage(ev) { | ||||
| 	file = await selectFile(ev.currentTarget ?? ev.target, null); | ||||
| 	const candidate = file.name.replace(/\.(.+)$/, ''); | ||||
|  | @ -137,13 +186,37 @@ async function addRole() { | |||
| async function removeRole(role, ev) { | ||||
| 	rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id); | ||||
| } | ||||
| async function update() { | ||||
| 	await os.apiWithDialog('admin/emoji/update', { | ||||
| 		id: props.emoji.id, | ||||
| 		name, | ||||
| 		category, | ||||
| 		aliases: aliases.split(' '), | ||||
| 		license: license === '' ? null : license, | ||||
| 		fileId: chooseFile?.id, | ||||
| 		draft: draft, | ||||
| 	}); | ||||
| 
 | ||||
| 	emit('done', { | ||||
| 		updated: { | ||||
| 			id: props.emoji.id, | ||||
| 			name, | ||||
| 			category, | ||||
| 			aliases: aliases.split(' '), | ||||
| 			license: license === '' ? null : license, | ||||
| 			draft: draft, | ||||
| 		}, | ||||
| 	}); | ||||
| 
 | ||||
| 	dialog.close(); | ||||
| } | ||||
| async function done() { | ||||
| 	const params = { | ||||
| 		name, | ||||
| 		category: category === '' ? null : category, | ||||
| 		aliases: aliases.split(' ').filter(x => x !== ''), | ||||
| 		aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''), | ||||
| 		license: license === '' ? null : license, | ||||
| 		draft: draft, | ||||
| 		isSensitive, | ||||
| 		localOnly, | ||||
| 		roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id), | ||||
|  | @ -152,7 +225,7 @@ async function done() { | |||
| 	if (file) { | ||||
| 		params.fileId = file.id; | ||||
| 	} | ||||
| 
 | ||||
| 	console.log(props.emoji); | ||||
| 	if (props.emoji) { | ||||
| 		await os.apiWithDialog('admin/emoji/update', { | ||||
| 			id: props.emoji.id, | ||||
|  | @ -168,7 +241,9 @@ async function done() { | |||
| 
 | ||||
| 		dialog.close(); | ||||
| 	} else { | ||||
| 		const created = await os.apiWithDialog('admin/emoji/add', params); | ||||
| 		const created = isRequest | ||||
| 			? await os.apiWithDialog('admin/emoji/add-draft', params) | ||||
| 			: await os.apiWithDialog('admin/emoji/add', params); | ||||
| 
 | ||||
| 		emit('done', { | ||||
| 			created: created, | ||||
|  | @ -178,6 +253,13 @@ async function done() { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| function chooseFileFrom(ev) { | ||||
| 	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { | ||||
| 		chooseFile = files_[0]; | ||||
| 		url = chooseFile.url; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| async function del() { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
|  | @ -240,4 +322,11 @@ async function del() { | |||
| 	-webkit-backdrop-filter: var(--blur, blur(15px)); | ||||
| 	backdrop-filter: var(--blur, blur(15px)); | ||||
| } | ||||
| 
 | ||||
| .footerButtons { | ||||
| 	display: flex; | ||||
| 	gap: 8px; | ||||
| 	flex-wrap: wrap; | ||||
| 	justify-content: center; | ||||
| } | ||||
| </style> | ||||
|  | @ -72,15 +72,15 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		</div> | ||||
| 		<div v-once class="group"> | ||||
| 			<header class="_acrylic">{{ i18n.ts.customEmojis }}</header> | ||||
| 
 | ||||
| 			<XSection | ||||
| 				v-for="category in groupedData" | ||||
| 				:key="`custom:${category}`" | ||||
| 				:initialShown="false" | ||||
| 				:emojis="computed(() => customEmojis.filter(filterAvailable))" | ||||
|                 :category="category" | ||||
|                 @chosen="chosen" | ||||
| 			/> | ||||
| 				:emojis="computed(() => customEmojis.filter(emoji => !emoji.draft).filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))" | ||||
| 				@chosen="chosen" | ||||
| 			> | ||||
| 				{{ category || i18n.ts.other }} | ||||
| 			</XSection> | ||||
| 		</div> | ||||
| 		<div v-once class="group"> | ||||
| 			<header class="_acrylic">{{ i18n.ts.emoji }}</header> | ||||
|  | @ -196,7 +196,7 @@ watch(q, () => { | |||
| 
 | ||||
| 	const searchCustom = () => { | ||||
| 		const max = 100; | ||||
| 		const emojis = customEmojis.value; | ||||
| 		const emojis = customEmojis.value.filter(emoji => !emoji.draft); | ||||
| 		const matches = new Set<Misskey.entities.CustomEmoji>(); | ||||
| 
 | ||||
| 		const exactMatch = emojis.find(emoji => emoji.name === newQ); | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <span v-if="errored">:{{ customEmojiName }}:</span> | ||||
| <span v-if="errored || isDraft">:{{ customEmojiName }}:</span> | ||||
| <img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true" @load="errored = false"/> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -25,6 +25,7 @@ const props = defineProps<{ | |||
| 
 | ||||
| const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); | ||||
| const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); | ||||
| const isDraft = computed(() => customEmojisMap.get(customEmojiName.value)?.draft ?? false); | ||||
| 
 | ||||
| const rawUrl = computed(() => { | ||||
| 	if (props.url) { | ||||
|  |  | |||
|  | @ -67,6 +67,7 @@ export const ROLE_POLICIES = [ | |||
| 	'inviteLimitCycle', | ||||
| 	'inviteExpirationTime', | ||||
| 	'canManageCustomEmojis', | ||||
| 	'canRequestCustomEmojis', | ||||
| 	'canSearchNotes', | ||||
| 	'canUseTranslator', | ||||
| 	'canHideAds', | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { shallowRef, computed, markRaw, watch } from 'vue'; | ||||
| import { shallowRef, computed, markRaw, triggerRef, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { api, apiGet } from '@/os.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
|  | @ -11,6 +11,7 @@ import { get, set } from '@/scripts/idb-proxy.js'; | |||
| 
 | ||||
| const storageCache = await get('emojis'); | ||||
| export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(Array.isArray(storageCache) ? storageCache : []); | ||||
| export const customEmojisNameMap = computed(() => new Map(customEmojis.value.map(item => [item.name, item]))); | ||||
| export const customEmojiCategories = computed<[ ...string[], null ]>(() => { | ||||
| 	const categories = new Set<string>(); | ||||
| 	for (const emoji of customEmojis.value) { | ||||
|  | @ -34,16 +35,19 @@ const stream = useStream(); | |||
| 
 | ||||
| stream.on('emojiAdded', emojiData => { | ||||
| 	customEmojis.value = [emojiData.emoji, ...customEmojis.value]; | ||||
| 	triggerRef(customEmojis); | ||||
| 	set('emojis', customEmojis.value); | ||||
| }); | ||||
| 
 | ||||
| stream.on('emojiUpdated', emojiData => { | ||||
| 	customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.CustomEmoji ?? item); | ||||
| 	triggerRef(customEmojis); | ||||
| 	set('emojis', customEmojis.value); | ||||
| }); | ||||
| 
 | ||||
| stream.on('emojiDeleted', emojiData => { | ||||
| 	customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name)); | ||||
| 	triggerRef(customEmojis); | ||||
| 	set('emojis', customEmojis.value); | ||||
| }); | ||||
| 
 | ||||
|  | @ -60,6 +64,7 @@ export async function fetchCustomEmojis(force = false) { | |||
| 	} | ||||
| 
 | ||||
| 	customEmojis.value = res.emojis; | ||||
| 	triggerRef(customEmojis); | ||||
| 	set('emojis', res.emojis); | ||||
| 	set('lastEmojisFetchedAt', now); | ||||
| } | ||||
|  |  | |||
|  | @ -4,52 +4,96 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <div class="_gaps"> | ||||
| 	<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton> | ||||
| <MkStickyContainer> | ||||
| 	<template #header> | ||||
| 		<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/> | ||||
| 	</template> | ||||
| 	<MkSpacer v-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> | ||||
| 		<MkButton | ||||
| 			v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link | ||||
| 			to="/custom-emojis-manager" | ||||
| 		> | ||||
| 			{{ i18n.ts.manageCustomEmojis }} | ||||
| 		</MkButton> | ||||
| 		<MkButton | ||||
| 			v-if="$i && (!$i.isModerator || !$i.policies.canManageCustomEmojis || $i.policies.canRequestCustomEmojis)" | ||||
| 			primary style="margin-top: 8px;" @click="edit" | ||||
| 		> | ||||
| 			{{ i18n.ts.requestCustomEmojis }} | ||||
| 		</MkButton> | ||||
| 
 | ||||
| 	<div class="query"> | ||||
| 		<MkInput v-model="q" class="" :placeholder="i18n.ts.search"> | ||||
| 			<template #prefix><i class="ti ti-search"></i></template> | ||||
| 		</MkInput> | ||||
| 		<div class="query" style="margin-top: 10px;"> | ||||
| 			<MkInput v-model="q" class="" :placeholder="i18n.ts.search"> | ||||
| 				<template #prefix><i class="ti ti-search"></i></template> | ||||
| 			</MkInput> | ||||
| 
 | ||||
| 		<!-- たくさんあると邪魔 | ||||
| 		<div class="tags"> | ||||
| 			<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> | ||||
| 			<!-- たくさんあると邪魔 | ||||
| 				<div class="tags"> | ||||
| 					<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> | ||||
| 				</div> | ||||
| 				--> | ||||
| 		</div> | ||||
| 		--> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<MkFoldableSection v-if="searchEmojis"> | ||||
| 		<template #header>{{ i18n.ts.searchResult }}</template> | ||||
| 		<MkFoldableSection v-if="searchEmojis"> | ||||
| 			<template #header>{{ i18n.ts.searchResult }}</template> | ||||
| 			<div :class="$style.emojis"> | ||||
| 				<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/> | ||||
| 			</div> | ||||
| 		</MkFoldableSection> | ||||
| 
 | ||||
| 		<MkFoldableSection v-for="category in filteredCategories" v-once :key="category"> | ||||
| 			<template #header>{{ category || i18n.ts.other }}</template> | ||||
| 			<div :class="$style.emojis"> | ||||
| 				<XEmoji | ||||
| 					v-for="emoji in customEmojis.filter(e => e.category === category && !e.draft)" :key="emoji.name" | ||||
| 					:emoji="emoji" :draft="emoji.draft" | ||||
| 				/> | ||||
| 			</div> | ||||
| 		</MkFoldableSection> | ||||
| 	</MkSpacer> | ||||
| 
 | ||||
| 	<MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20"> | ||||
| 		<div :class="$style.emojis"> | ||||
| 			<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/> | ||||
| 			<XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/> | ||||
| 		</div> | ||||
| 	</MkFoldableSection> | ||||
| 
 | ||||
| 	<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category"> | ||||
| 		<template #header>{{ category || i18n.ts.other }}</template> | ||||
| 		<div :class="$style.emojis"> | ||||
| 			<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> | ||||
| 		</div> | ||||
| 	</MkFoldableSection> | ||||
| </div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { watch } from 'vue'; | ||||
| import { watch, defineAsyncComponent, ref, computed } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import XEmoji from './emojis.emoji.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkFoldableSection from '@/components/MkFoldableSection.vue'; | ||||
| import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; | ||||
| import { customEmojis, customEmojiCategories } from '@/custom-emojis.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import * as os from '@/os'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| 
 | ||||
| let tab = $ref('emojis'); | ||||
| const headerActions = $computed(() => []); | ||||
| 
 | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'emojis', | ||||
| 	title: i18n.ts.list, | ||||
| }, { | ||||
| 	key: 'draft', | ||||
| 	title: i18n.ts.draftEmojis, | ||||
| }]); | ||||
| const filteredCategories = computed(() => { | ||||
| 	return customEmojiCategories.value.filter((category: any) => { | ||||
| 		return customEmojis.value.some((em: any) => em.category === category && !em.draft); | ||||
| 	}); | ||||
| }); | ||||
| definePageMetadata(ref({})); | ||||
| 
 | ||||
| const customEmojiTags = getCustomEmojiTags(); | ||||
| let q = $ref(''); | ||||
| let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null); | ||||
| let selectedTags = $ref(new Set()); | ||||
| const draftEmojis = customEmojis.value.filter(emoji => emoji.draft); | ||||
| 
 | ||||
| function search() { | ||||
| 	if ((q === '' || q == null) && selectedTags.size === 0) { | ||||
|  | @ -80,6 +124,16 @@ function toggleTag(tag) { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| const edit = () => { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { | ||||
| 		isRequest: true, | ||||
| 	}, { | ||||
| 		done: result => { | ||||
| 			window.location.reload(); | ||||
| 		}, | ||||
| 	}, 'closed'); | ||||
| }; | ||||
| 
 | ||||
| watch($$(q), () => { | ||||
| 	search(); | ||||
| }); | ||||
|  | @ -87,6 +141,11 @@ watch($$(q), () => { | |||
| watch($$(selectedTags), () => { | ||||
| 	search(); | ||||
| }, { deep: true }); | ||||
| 
 | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.customEmojis, | ||||
| 	icon: null, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
|  |  | |||
|  | @ -42,7 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| 	<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> | ||||
| 		<div :class="$style.items"> | ||||
| 			<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`"> | ||||
| 			<MkA | ||||
| 				v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" | ||||
| 				:class="$style.item" :to="`/instance-info/${instance.host}`" | ||||
| 			> | ||||
| 				<MkInstanceCardMini :instance="instance"/> | ||||
| 			</MkA> | ||||
| 		</div> | ||||
|  |  | |||
|  | @ -89,9 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			</FormSection> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| 	<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> | ||||
| 		<XEmojis/> | ||||
| 	</MkSpacer> | ||||
| 	<XEmojis v-else-if="tab === 'emojis'"/> | ||||
| 	<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> | ||||
| 		<XFederation/> | ||||
| 	</MkSpacer> | ||||
|  |  | |||
|  | @ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<template #header> | ||||
| 		<XHeader :actions="headerActions" :tabs="headerTabs"/> | ||||
| 	</template> | ||||
| 	<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> | ||||
| 		<FormSuspense :p="init"> | ||||
| 			<MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> | ||||
|  |  | |||
|  | @ -279,6 +279,66 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> | ||||
| 				<template #suffix> | ||||
| 					<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> | ||||
| 					<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> | ||||
| 					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> | ||||
| 				</template> | ||||
| 				<div class="_gaps"> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts._role.useBaseValue }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts.enable }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> | ||||
| 						<template #label>{{ i18n.ts._role.priority }}</template> | ||||
| 					</MkRange> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> | ||||
| 				<template #suffix> | ||||
| 					<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> | ||||
| 					<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> | ||||
| 					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> | ||||
| 				</template> | ||||
| 				<div class="_gaps"> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts._role.useBaseValue }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts.enable }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> | ||||
| 						<template #label>{{ i18n.ts._role.priority }}</template> | ||||
| 					</MkRange> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> | ||||
| 				<template #suffix> | ||||
| 					<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> | ||||
| 					<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span> | ||||
| 					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span> | ||||
| 				</template> | ||||
| 				<div class="_gaps"> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts._role.useBaseValue }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts.enable }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> | ||||
| 						<template #label>{{ i18n.ts._role.priority }}</template> | ||||
| 					</MkRange> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> | ||||
| 				<template #suffix> | ||||
|  |  | |||
|  | @ -95,6 +95,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 							</MkSwitch> | ||||
| 						</MkFolder> | ||||
| 
 | ||||
| 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])"> | ||||
| 							<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template> | ||||
| 							<template #suffix>{{ policies.canRequestCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> | ||||
| 							<MkSwitch v-model="policies.canRequestCustomEmojis"> | ||||
| 								<template #label>{{ i18n.ts.enable }}</template> | ||||
| 							</MkSwitch> | ||||
| 						</MkFolder> | ||||
| 
 | ||||
| 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> | ||||
| 							<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> | ||||
| 							<template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> | ||||
|  |  | |||
|  | @ -10,64 +10,13 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<MkSpacer :contentMax="900"> | ||||
| 			<div class="ogwlenmc"> | ||||
| 				<div v-if="tab === 'local'" class="local"> | ||||
| 					<MkInput v-model="query" :debounce="true" type="search"> | ||||
| 						<template #prefix><i class="ti ti-search"></i></template> | ||||
| 						<template #label>{{ i18n.ts.search }}</template> | ||||
| 					</MkInput> | ||||
| 					<MkSwitch v-model="selectMode" style="margin: 8px 0;"> | ||||
| 						<template #label>Select mode</template> | ||||
| 					</MkSwitch> | ||||
| 					<div v-if="selectMode" class="_buttons"> | ||||
| 						<MkButton inline @click="selectAll">Select all</MkButton> | ||||
| 						<MkButton inline @click="setCategoryBulk">Set category</MkButton> | ||||
| 						<MkButton inline @click="setTagBulk">Set tag</MkButton> | ||||
|             <MkButton inline @click="setisSensitiveBulk">Set isSensitive</MkButton> | ||||
|             <MkButton inline @click="setlocalOnlyBulk">Set localOnly</MkButton> | ||||
| 						<MkButton inline @click="addTagBulk">Add tag</MkButton> | ||||
| 						<MkButton inline @click="removeTagBulk">Remove tag</MkButton> | ||||
| 						<MkButton inline @click="setLicenseBulk">Set License</MkButton> | ||||
| 						<MkButton inline danger @click="delBulk">Delete</MkButton> | ||||
| 					</div> | ||||
| 					<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> | ||||
| 						<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> | ||||
| 						<template #default="{items}"> | ||||
| 							<div class="ldhfsamy"> | ||||
| 								<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> | ||||
| 									<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/> | ||||
| 									<div class="body"> | ||||
| 										<div class="name _monospace">{{ emoji.name }}</div> | ||||
| 										<div class="info">{{ emoji.category }}</div> | ||||
| 									</div> | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</template> | ||||
| 					</MkPagination> | ||||
| 					<MkCustomEmojiEditLocal/> | ||||
| 				</div> | ||||
| 				<div v-if="tab === 'draft'" class="draft"> | ||||
| 					<MkCustomEmojiEditDraft/> | ||||
| 				</div> | ||||
| 
 | ||||
| 				<div v-else-if="tab === 'remote'" class="remote"> | ||||
| 					<FormSplit> | ||||
| 						<MkInput v-model="queryRemote" :debounce="true" type="search"> | ||||
| 							<template #prefix><i class="ti ti-search"></i></template> | ||||
| 							<template #label>{{ i18n.ts.search }}</template> | ||||
| 						</MkInput> | ||||
| 						<MkInput v-model="host" :debounce="true"> | ||||
| 							<template #label>{{ i18n.ts.host }}</template> | ||||
| 						</MkInput> | ||||
| 					</FormSplit> | ||||
| 					<MkPagination :pagination="remotePagination"> | ||||
| 						<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> | ||||
| 						<template #default="{items}"> | ||||
| 							<div class="ldhfsamy"> | ||||
| 								<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> | ||||
| 									<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/> | ||||
| 									<div class="body"> | ||||
| 										<div class="name _monospace">{{ emoji.name }}</div> | ||||
| 										<div class="info">{{ emoji.host }}</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</template> | ||||
| 					</MkPagination> | ||||
| 					<MkCustomEmojiEditRemote/> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</MkSpacer> | ||||
|  | @ -76,105 +25,30 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import FormSplit from '@/components/form/split.vue'; | ||||
| import { selectFile, selectFiles } from '@/scripts/select-file.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
| import {switch1, swtch} from "@/os.js"; | ||||
| import { computed, defineAsyncComponent, ref } from 'vue'; | ||||
| import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue'; | ||||
| import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue'; | ||||
| import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue'; | ||||
| import { selectFile } from '@/scripts/select-file'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| 
 | ||||
| const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); | ||||
| 
 | ||||
| const tab = ref('local'); | ||||
| const query = ref(null); | ||||
| const queryRemote = ref(null); | ||||
| const host = ref(null); | ||||
| const selectMode = ref(false); | ||||
| const selectedEmojis = ref<string[]>([]); | ||||
| 
 | ||||
| const pagination = { | ||||
| 	endpoint: 'admin/emoji/list' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		query: (query.value && query.value !== '') ? query.value : null, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| const remotePagination = { | ||||
| 	endpoint: 'admin/emoji/list-remote' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, | ||||
| 		host: (host.value && host.value !== '') ? host.value : null, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| const selectAll = () => { | ||||
| 	if (selectedEmojis.value.length > 0) { | ||||
| 		selectedEmojis.value = []; | ||||
| 	} else { | ||||
| 		selectedEmojis.value = Array.from(emojisPaginationComponent.value.items.values(), item => item.id); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| const toggleSelect = (emoji) => { | ||||
| 	if (selectedEmojis.value.includes(emoji.id)) { | ||||
| 		selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); | ||||
| 	} else { | ||||
| 		selectedEmojis.value.push(emoji.id); | ||||
| 	} | ||||
| }; | ||||
| const tab = ref('draft'); | ||||
| 
 | ||||
| const add = async (ev: MouseEvent) => { | ||||
| 	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), { | ||||
| 	}, { | ||||
| 		done: result => { | ||||
| 			if (result.created) { | ||||
| 				emojisPaginationComponent.value.prepend(result.created); | ||||
| 			} | ||||
| 			//TODO: emitにして追加を反映 | ||||
| 			// if (result.created) { | ||||
| 			// 	emojisPaginationComponent.value.prepend(result.created); | ||||
| 			// 	emojisPaginationComponent.value.reload(); | ||||
| 			// } | ||||
| 		}, | ||||
| 	}, 'closed'); | ||||
| }; | ||||
| 
 | ||||
| const edit = (emoji) => { | ||||
| 	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { | ||||
| 		emoji: emoji, | ||||
| 	}, { | ||||
| 		done: result => { | ||||
| 			if (result.updated) { | ||||
| 				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ | ||||
| 					...oldEmoji, | ||||
| 					...result.updated, | ||||
| 				})); | ||||
| 			} else if (result.deleted) { | ||||
| 				emojisPaginationComponent.value.removeItem(emoji.id); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, 'closed'); | ||||
| }; | ||||
| 
 | ||||
| const im = (emoji) => { | ||||
| 	os.apiWithDialog('admin/emoji/copy', { | ||||
| 		emojiId: emoji.id, | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| const remoteMenu = (emoji, ev: MouseEvent) => { | ||||
| 	os.popupMenu([{ | ||||
| 		type: 'label', | ||||
| 		text: ':' + emoji.name + ':', | ||||
| 	}, { | ||||
| 		text: i18n.ts.import, | ||||
| 		icon: 'ti ti-plus', | ||||
| 		action: () => { im(emoji); }, | ||||
| 	}], ev.currentTarget ?? ev.target); | ||||
| }; | ||||
| 
 | ||||
| const menu = (ev: MouseEvent) => { | ||||
| 	os.popupMenu([{ | ||||
| 		icon: 'ti ti-download', | ||||
|  | @ -217,101 +91,6 @@ const menu = (ev: MouseEvent) => { | |||
| 	}], ev.currentTarget ?? ev.target); | ||||
| }; | ||||
| 
 | ||||
| const setCategoryBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Category', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-category-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		category: result, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const setLicenseBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'License', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-license-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		license: result, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const addTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/add-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const removeTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const setTagBulk = async () => { | ||||
| 	const { canceled, result } = await os.inputText({ | ||||
| 		title: 'Tag', | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/set-aliases-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 		aliases: result.split(' '), | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| const setisSensitiveBulk = async () => { | ||||
|   const { canceled, result } = await os.switch1({ | ||||
|     title: 'isSensitive', | ||||
|     type: "mksw" | ||||
|   }); | ||||
|   if (canceled) return; | ||||
|   await os.apiWithDialog('admin/emoji/set-issensitive-bulk', { | ||||
|     ids: selectedEmojis.value, | ||||
|     isSensitive: result | ||||
|   }); | ||||
|   emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| const setlocalOnlyBulk = async () => { | ||||
|   const { canceled, result } = await os.switch1({ | ||||
|     title: 'localOnly', | ||||
|     type: "mksw" | ||||
|   }); | ||||
|   if (canceled) return; | ||||
|   await os.apiWithDialog('admin/emoji/set-localonly-bulk', { | ||||
|     ids: selectedEmojis.value, | ||||
|     localOnly: result | ||||
|   }); | ||||
|   emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| const delBulk = async () => { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.ts.deleteConfirm, | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	await os.apiWithDialog('admin/emoji/delete-bulk', { | ||||
| 		ids: selectedEmojis.value, | ||||
| 	}); | ||||
| 	emojisPaginationComponent.value.reload(); | ||||
| }; | ||||
| 
 | ||||
| const headerActions = $computed(() => [{ | ||||
| 	asFullButton: true, | ||||
| 	icon: 'ti ti-plus', | ||||
|  | @ -323,6 +102,9 @@ const headerActions = $computed(() => [{ | |||
| }]); | ||||
| 
 | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'draft', | ||||
| 	title: i18n.ts.draftEmojis, | ||||
| }, { | ||||
| 	key: 'local', | ||||
| 	title: i18n.ts.local, | ||||
| }, { | ||||
|  | @ -337,103 +119,4 @@ definePageMetadata(computed(() => ({ | |||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .ogwlenmc { | ||||
| 	> .local { | ||||
| 		.empty { | ||||
| 			margin: var(--margin); | ||||
| 		} | ||||
| 
 | ||||
| 		.ldhfsamy { | ||||
| 			display: grid; | ||||
| 			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); | ||||
| 			grid-gap: 12px; | ||||
| 			margin: var(--margin) 0; | ||||
| 
 | ||||
| 			> .emoji { | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
| 				padding: 11px; | ||||
| 				text-align: left; | ||||
| 				border: solid 1px var(--panel); | ||||
| 
 | ||||
| 				&:hover { | ||||
| 					border-color: var(--inputBorderHover); | ||||
| 				} | ||||
| 
 | ||||
| 				&.selected { | ||||
| 					border-color: var(--accent); | ||||
| 				} | ||||
| 
 | ||||
| 				> .img { | ||||
| 					width: 42px; | ||||
| 					height: 42px; | ||||
| 				} | ||||
| 
 | ||||
| 				> .body { | ||||
| 					padding: 0 0 0 8px; | ||||
| 					white-space: nowrap; | ||||
| 					overflow: hidden; | ||||
| 
 | ||||
| 					> .name { | ||||
| 						text-overflow: ellipsis; | ||||
| 						overflow: hidden; | ||||
| 					} | ||||
| 
 | ||||
| 					> .info { | ||||
| 						opacity: 0.5; | ||||
| 						text-overflow: ellipsis; | ||||
| 						overflow: hidden; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .remote { | ||||
| 		.empty { | ||||
| 			margin: var(--margin); | ||||
| 		} | ||||
| 
 | ||||
| 		.ldhfsamy { | ||||
| 			display: grid; | ||||
| 			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); | ||||
| 			grid-gap: 12px; | ||||
| 			margin: var(--margin) 0; | ||||
| 
 | ||||
| 			> .emoji { | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
| 				padding: 12px; | ||||
| 				text-align: left; | ||||
| 
 | ||||
| 				&:hover { | ||||
| 					color: var(--accent); | ||||
| 				} | ||||
| 
 | ||||
| 				> .img { | ||||
| 					width: 32px; | ||||
| 					height: 32px; | ||||
| 				} | ||||
| 
 | ||||
| 				> .body { | ||||
| 					padding: 0 0 0 8px; | ||||
| 					white-space: nowrap; | ||||
| 					overflow: hidden; | ||||
| 
 | ||||
| 					> .name { | ||||
| 						text-overflow: ellipsis; | ||||
| 						overflow: hidden; | ||||
| 					} | ||||
| 
 | ||||
| 					> .info { | ||||
| 						opacity: 0.5; | ||||
| 						font-size: 90%; | ||||
| 						text-overflow: ellipsis; | ||||
| 						overflow: hidden; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -4,10 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <button class="_button" :class="$style.root" @click="menu"> | ||||
| <button v-if="emoji.draft" class="zuvgdzyu _button emoji-draft" @click="menu"> | ||||
| 	<img style="max-height: 64px;object-fit: contain;" :src="emoji.url" class="img" loading="lazy"/> | ||||
| 	<div class="body"> | ||||
| 		<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div> | ||||
| 		<div class="info">{{ emoji.aliases.join(' ') }}</div> | ||||
| 	</div> | ||||
| </button> | ||||
| <button v-else class="_button" :class="$style.root" @click="menu"> | ||||
| 	<img :src="emoji.url" :class="$style.img" loading="lazy"/> | ||||
| 	<div :class="$style.body"> | ||||
| 		<div :class="$style.name" class="_monospace">{{ emoji.name }}</div> | ||||
| 		<div :class="$style.name">{{ emoji.name }}</div> | ||||
| 		<div :class="$style.info">{{ emoji.aliases.join(' ') }}</div> | ||||
| 	</div> | ||||
| </button> | ||||
|  | @ -25,6 +32,7 @@ const props = defineProps<{ | |||
| 		aliases: string[]; | ||||
| 		category: string; | ||||
| 		url: string; | ||||
| 		draft: boolean; | ||||
| 	}; | ||||
| }>(); | ||||
| 
 | ||||
|  | @ -91,4 +99,12 @@ function menu(ev) { | |||
| 	text-overflow: ellipsis; | ||||
| 	overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .emoji-draft { | ||||
| 	--c: rgb(255 196 0 / 15%);; | ||||
| 	background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%); | ||||
| 	background-size: 16px 16px; | ||||
|   max-width: 64px; | ||||
|   width: 100%; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -284,6 +284,7 @@ type CustomEmoji = { | |||
|     url: string; | ||||
|     category: string; | ||||
|     aliases: string[]; | ||||
|     draft: boolean; | ||||
| }; | ||||
| 
 | ||||
| // @public (undocumented) | ||||
|  |  | |||
|  | @ -312,6 +312,7 @@ export type CustomEmoji = { | |||
| 	url: string; | ||||
| 	category: string; | ||||
| 	aliases: string[]; | ||||
| 	draft: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type LiteInstanceMetadata = { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue