From 4c7cd47fb6e7b3d7e9db02343e3de9e8ab8a0c0f Mon Sep 17 00:00:00 2001
From: tar_bin <tar.bin.master@gmail.com>
Date: Fri, 18 Aug 2023 21:41:33 +0900
Subject: [PATCH] =?UTF-8?q?fix:=20=E7=B5=B5=E6=96=87=E5=AD=97=E7=AE=A1?=
 =?UTF-8?q?=E7=90=86=E7=94=BB=E9=9D=A2=E3=81=AE=E8=A1=A8=E7=A4=BA=E4=B8=8D?=
 =?UTF-8?q?=E5=85=B7=E5=90=88=E3=81=AE=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

(cherry picked from commit 5300ff763bb008699b35dc6e219b50ac1c4fd7af)
---
 .../src/components/MKCustomEmojiEditDraft.vue | 229 +++++++
 .../src/components/MkCustomEmojiEditLocal.vue | 225 +++++++
 .../components/MkCustomEmojiEditRemote.vue    | 110 ++++
 .../MkEmojiEditDialog.vue}                    |   0
 packages/frontend/src/pages/about.emojis.vue  |   2 +-
 .../src/pages/custom-emojis-manager.vue       | 562 +-----------------
 6 files changed, 582 insertions(+), 546 deletions(-)
 create mode 100644 packages/frontend/src/components/MKCustomEmojiEditDraft.vue
 create mode 100644 packages/frontend/src/components/MkCustomEmojiEditLocal.vue
 create mode 100644 packages/frontend/src/components/MkCustomEmojiEditRemote.vue
 rename packages/frontend/src/{pages/emoji-edit-dialog.vue => components/MkEmojiEditDialog.vue} (100%)

diff --git a/packages/frontend/src/components/MKCustomEmojiEditDraft.vue b/packages/frontend/src/components/MKCustomEmojiEditDraft.vue
new file mode 100644
index 0000000000..17576b7b06
--- /dev/null
+++ b/packages/frontend/src/components/MKCustomEmojiEditDraft.vue
@@ -0,0 +1,229 @@
+<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 _monospace">{{ 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">
+						<button class="edit _button" @click="editDraft(emoji)">
+							{{ i18n.ts.edit }}
+						</button>
+						<button class="draft _button" @click="undrafted(emoji)">
+							{{ i18n.ts.undrafted }}
+						</button>
+						<button class="delete _button" @click="deleteDraft(emoji)">
+							{{ i18n.ts.delete }}
+						</button>
+					</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';
+
+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 {
+		display: grid;
+		grid-template-rows: 40px 1fr;
+		grid-template-columns: 1fr 150px;
+		align-items: center;
+		padding: 11px;
+		text-align: left;
+		border: solid 1px var(--panel);
+		width: 100%;
+		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;
+				
+				> img {
+					max-height: 30px;
+					max-width: 100%;
+				}
+			}
+
+			> .imgDark {
+				display: grid;
+				grid-column: 2;
+				background-color: #000;
+
+				> img {
+					max-height: 30px;
+					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-row: 2;
+			grid-template-rows: 30px 30px 30px;
+
+			> .edit {
+				grid-row: 1;
+				background-color: var(--buttonBg);
+				margin: 2px;
+
+				&:hover {
+					color: var(--accent);
+				}
+			}
+
+			> .draft {
+				grid-row: 2;
+				background-color: var(--buttonBg);
+				margin: 2px;
+
+				&:hover {
+					color: var(--accent);
+				}
+			}
+
+			> .delete {
+				background-color: var(--buttonBg);
+				grid-row: 3;
+				margin: 2px;
+
+				&:hover {
+					color: var(--accent);
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkCustomEmojiEditLocal.vue b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue
new file mode 100644
index 0000000000..7112a38430
--- /dev/null
+++ b/packages/frontend/src/components/MkCustomEmojiEditLocal.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkCustomEmojiEditRemote.vue b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue
new file mode 100644
index 0000000000..26c8dd66ac
--- /dev/null
+++ b/packages/frontend/src/components/MkCustomEmojiEditRemote.vue
@@ -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>
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/components/MkEmojiEditDialog.vue
similarity index 100%
rename from packages/frontend/src/pages/emoji-edit-dialog.vue
rename to packages/frontend/src/components/MkEmojiEditDialog.vue
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue
index 805662065c..a49a70c88a 100644
--- a/packages/frontend/src/pages/about.emojis.vue
+++ b/packages/frontend/src/pages/about.emojis.vue
@@ -120,7 +120,7 @@ function toggleTag(tag) {
 }
 
 const edit = () => {
-	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
 		isRequest: true,
 	}, {
 		done: result => {
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 5dcd2009ed..6e55ffb945 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -10,106 +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="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">
-								<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/${emoji.name}.webp`" 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>
+					<MkCustomEmojiEditLocal/>
 				</div>
-
 				<div v-if="tab === 'draft'" class="draft">
-					<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 _monospace">{{ 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">
-											<button class="edit _button" @click="editDraft(emoji)">
-												{{ i18n.ts.edit }}
-											</button>
-											<button class="draft _button" @click="undrafted(emoji)">
-												{{ i18n.ts.undrafted }}
-											</button>
-											<button class="delete _button" @click="deleteDraft(emoji)">
-												{{ i18n.ts.delete }}
-											</button>
-										</div>
-									</div>
-								</template>
-							</div>
-						</template>
-					</MkPagination>
+					<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.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>
+					<MkCustomEmojiEditRemote/>
 				</div>
 			</div>
 		</MkSpacer>
@@ -118,171 +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 } from '@/scripts/select-file.js';
-import * as os from '@/os.js';
-import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-
-const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
-const emojisDraftPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+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 tab = ref('draft');
-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 paginationDraft = {
-	endpoint: 'admin/emoji/list' as const,
-	limit: 30,
-	params: computed(() => ({
-		query: (query.value && query.value !== '') ? query.value : null,
-		draft: true,
-	})),
-};
-
-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 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);
-				emojisPaginationComponent.value.reload();
-			}
+			//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,
-				}));
-				emojisPaginationComponent.value.reload();
-			} else if (result.deleted) {
-				emojisPaginationComponent.value.removeItem(emoji.id);
-			}
-		},
-	}, 'closed');
-};
-
-const editDraft = (emoji) => {
-	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.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);
-			}
-		},
-	}, '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);
-}
-
-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);
-	});
-}
-
-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',
@@ -325,78 +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 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',
@@ -425,226 +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;
-
-			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;
-					}
-				}
-			}
-		}
-	}
-	> .draft {
-		.empty {
-			margin: var(--margin);
-		}
-
-		.ldhfsamy {
-			> .emoji {
-				display: grid;
-				grid-template-rows: 40px 1fr;
-				grid-template-columns: 1fr 150px;
-				align-items: center;
-				padding: 11px;
-				text-align: left;
-				border: solid 1px var(--panel);
-				width: 100%;
-				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;
-
-						> img {
-							max-height: 30px;
-							max-width: 100%;
-						}
-					}
-
-					> .imgDark {
-						display: grid;
-						grid-column: 2;
-						background-color: #000;
-
-						> img {
-							max-height: 30px;
-							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-row: 2;
-					grid-template-rows: 30px 30px 30px;
-
-					> .edit {
-						grid-row: 1;
-						background-color: var(--buttonBg);
-						margin: 2px;
-
-						&:hover {
-							color: var(--accent);
-						}
-					}
-
-					> .draft {
-						grid-row: 2;
-						background-color: var(--buttonBg);
-						margin: 2px;
-
-						&:hover {
-							color: var(--accent);
-						}
-					}
-
-					> .delete {
-						background-color: var(--buttonBg);
-						grid-row: 3;
-						margin: 2px;
-
-						&:hover {
-							color: var(--accent);
-						}
-					}
-				}
-			}
-		}
-	}
-
-	> .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;
-					}
-				}
-			}
-		}
-	}
-}
-
-.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>