From 297186e492b20c3e54f8cbfe51ec2d7694ca7068 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 26 Jan 2025 20:10:22 +0900
Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E7=B5=B5=E6=96=87?=
 =?UTF-8?q?=E5=AD=97=E7=AE=A1=E7=90=86=E7=94=BB=E9=9D=A2=CE=B2=EF=BC=88?=
 =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB=EF=BC=89=E3=81=AEUI?=
 =?UTF-8?q?=E3=83=BBUX=E6=94=B9=E5=96=84=20(#15349)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): 絵文字管理画面β(ローカル)のUI・UX改善

* fix

* :art:

* 表示件数をメニューから変更するように

* 確認ダイアログ

* fix i18n

* needWideArea: trueならwidgetの開閉ボタンを表示しないように

* fix: 検索ウィンドウは一つしか開けないように
---
 locales/index.d.ts                            |  43 +-
 locales/ja-JP.yml                             |  13 +-
 .../src/components/MkSortOrderEditor.vue      |   8 +-
 .../frontend/src/components/MkSuperMenu.vue   |   2 +-
 .../frontend/src/components/MkTagItem.vue     |   4 +-
 .../src/components/global/MkPageHeader.vue    |  12 +-
 .../src/components/grid/MkDataCell.vue        |  35 +-
 .../frontend/src/components/grid/MkGrid.vue   |  56 ++-
 .../src/components/grid/MkHeaderCell.vue      |   6 +-
 packages/frontend/src/components/grid/grid.ts |   5 +
 .../pages/admin/custom-emojis-manager.impl.ts |   3 +-
 .../custom-emojis-manager.local.list.logs.vue |  39 ++
 ...ustom-emojis-manager.local.list.search.vue | 213 ++++++++
 .../custom-emojis-manager.local.list.vue      | 471 +++++++-----------
 .../custom-emojis-manager.local.register.vue  |  16 +-
 .../admin/custom-emojis-manager.local.vue     |  39 +-
 ...der.vue => custom-emojis-manager.logs.vue} |  56 +--
 .../admin/custom-emojis-manager.remote.vue    |  26 +-
 .../pages/admin/custom-emojis-manager2.vue    |  13 +-
 packages/frontend/src/pages/admin/index.vue   |  11 +-
 .../frontend/src/pages/settings/index.vue     |   2 +-
 packages/frontend/src/ui/universal.vue        |   2 +-
 22 files changed, 659 insertions(+), 416 deletions(-)
 create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue
 create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
 rename packages/frontend/src/pages/admin/{custom-emojis-manager.logs-folder.vue => custom-emojis-manager.logs.vue} (62%)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 83159337ae..a0540fd228 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10588,7 +10588,7 @@ export interface Locale extends ILocale {
              */
             "deleteSelectionRows": string;
             /**
-             * 選択範囲の行を削除
+             * 選択範囲の値をクリア
              */
             "deleteSelectionRanges": string;
             /**
@@ -10599,6 +10599,10 @@ export interface Locale extends ILocale {
              * 検索条件を詳細に設定します。
              */
             "searchSettingCaption": string;
+            /**
+             * 表示件数
+             */
+            "searchLimit": string;
             /**
              * 並び順
              */
@@ -10611,10 +10615,6 @@ export interface Locale extends ILocale {
              * 絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。
              */
             "registrationLogsCaption": string;
-            /**
-             * エラー
-             */
-            "alertEmojisRegisterFailedTitle": string;
             /**
              * 絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。
              */
@@ -10691,21 +10691,30 @@ export interface Locale extends ILocale {
                  */
                 "alertDeleteEmojisNothingDescription": string;
                 /**
-                 * 確認
+                 * ページを移動しますか?
                  */
-                "confirmUpdateEmojisTitle": string;
+                "confirmMovePage": string;
                 /**
-                 * {count}個の絵文字を更新します。実行しますか?
+                 * 表示を変更しますか?
                  */
-                "confirmUpdateEmojisDescription": ParameterizedString<"count">;
+                "confirmChangeView": string;
                 /**
-                 * 確認
+                 * {count}個の絵文字を更新します。実行しますか?
                  */
-                "confirmDeleteEmojisTitle": string;
+                "confirmUpdateEmojisDescription": ParameterizedString<"count">;
                 /**
                  * チェックがつけられた{count}個の絵文字を削除します。実行しますか?
                  */
                 "confirmDeleteEmojisDescription": ParameterizedString<"count">;
+                /**
+                 * 今までに加えた変更がすべてリセットされます。
+                 */
+                "confirmResetDescription": string;
+                /**
+                 * このページの絵文字に変更が加えられています。
+                 * 保存せずにこのままページを移動すると、このページで加えた変更はすべて破棄されます。
+                 */
+                "confirmMovePageDesciption": string;
                 /**
                  * 絵文字に設定されたロールで検索
                  */
@@ -10744,26 +10753,14 @@ export interface Locale extends ILocale {
                  * このリンクをクリックしてドライブから選択する
                  */
                 "emojiInputAreaList3": string;
-                /**
-                 * 確認
-                 */
-                "confirmRegisterEmojisTitle": string;
                 /**
                  * リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)
                  */
                 "confirmRegisterEmojisDescription": ParameterizedString<"count">;
-                /**
-                 * 確認
-                 */
-                "confirmClearEmojisTitle": string;
                 /**
                  * 編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?
                  */
                 "confirmClearEmojisDescription": string;
-                /**
-                 * 確認
-                 */
-                "confirmUploadEmojisTitle": string;
                 /**
                  * ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?
                  */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 42c25a58a6..a578704434 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2824,13 +2824,13 @@ _customEmojisManager:
     copySelectionRows: "選択行をコピー"
     copySelectionRanges: "選択範囲をコピー"
     deleteSelectionRows: "選択行を削除"
-    deleteSelectionRanges: "選択範囲の行を削除"
+    deleteSelectionRanges: "選択範囲の値をクリア"
     searchSettings: "検索設定"
     searchSettingCaption: "検索条件を詳細に設定します。"
+    searchLimit: "表示件数"
     sortOrder: "並び順"
     registrationLogs: "登録ログ"
     registrationLogsCaption: "絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。"
-    alertEmojisRegisterFailedTitle: "エラー"
     alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。"
   _logs:
     showSuccessLogSwitch: "成功ログを表示"
@@ -2852,10 +2852,12 @@ _customEmojisManager:
       markAsDeleteTargetRanges: "選択範囲の行を削除対象にする"
       alertUpdateEmojisNothingDescription: "変更された絵文字はありません。"
       alertDeleteEmojisNothingDescription: "削除対象の絵文字はありません。"
-      confirmUpdateEmojisTitle: "確認"
+      confirmMovePage: "ページを移動しますか?"
+      confirmChangeView: "表示を変更しますか?"
       confirmUpdateEmojisDescription: "{count}個の絵文字を更新します。実行しますか?"
-      confirmDeleteEmojisTitle: "確認"
       confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除します。実行しますか?"
+      confirmResetDescription: "今までに加えた変更がすべてリセットされます。"
+      confirmMovePageDesciption: "このページの絵文字に変更が加えられています。\n保存せずにこのままページを移動すると、このページで加えた変更はすべて破棄されます。"
       dialogSelectRoleTitle: "絵文字に設定されたロールで検索"
     _register:
       uploadSettingTitle: "アップロード設定"
@@ -2866,11 +2868,8 @@ _customEmojisManager:
       emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ"
       emojiInputAreaList2: "このリンクをクリックしてPCから選択する"
       emojiInputAreaList3: "このリンクをクリックしてドライブから選択する"
-      confirmRegisterEmojisTitle: "確認"
       confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)"
-      confirmClearEmojisTitle: "確認"
       confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?"
-      confirmUploadEmojisTitle: "確認"
       confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?"
 
 _embedCodeGen:
diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue
index da08f12297..9decacc5f5 100644
--- a/packages/frontend/src/components/MkSortOrderEditor.vue
+++ b/packages/frontend/src/components/MkSortOrderEditor.vue
@@ -12,12 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 			:iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'"
 			:exButtonIconClass="'ti ti-x'"
 			:content="order.key"
+			:class="$style.sortOrderTag"
 			@click="onToggleSortOrderButtonClicked(order)"
 			@exButtonClick="onRemoveSortOrderButtonClicked(order)"
 		/>
 	</div>
 	<MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked">
-		<span class="ti ti-plus"/>
+		<span class="ti ti-plus"></span>
 	</MkButton>
 </div>
 </template>
@@ -109,4 +110,9 @@ function emitOrder(sortOrders: SortOrder<T>[]) {
 	border-radius: 9999px;
 	background-color: var(--MI_THEME-buttonBg);
 }
+
+.sortOrderTag {
+	user-select: none;
+	cursor: pointer;
+}
 </style>
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index 0caaed6f39..fa0e40d8f9 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -47,7 +47,7 @@ export type SuperMenuDef = {
 		active?: boolean;
 		action: (ev: MouseEvent) => void;
 	} | {
-		type: 'link';
+		type?: 'link';
 		to: string;
 		icon?: string;
 		text: string;
diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue
index 98f2411392..8b7460f3a3 100644
--- a/packages/frontend/src/components/MkTagItem.vue
+++ b/packages/frontend/src/components/MkTagItem.vue
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div :class="$style.root" @click="(ev) => emit('click', ev)">
-	<span v-if="iconClass" :class="[$style.icon, iconClass]"/>
+	<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
 	<span :class="$style.content">{{ content }}</span>
 	<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
-		<span :class="[$style.exButtonIcon, exButtonIconClass]"/>
+		<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
 	</MkButton>
 </div>
 </template>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index aa4be69b2c..a2e70a5cad 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -48,13 +48,16 @@ import { scrollToTop } from '@@/js/scroll.js';
 import { globalEvents } from '@/events.js';
 import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
 import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { PageHeaderItem } from '@/types/page-header.js';
+import type { PageHeaderItem } from '@/types/page-header.js';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
 
 const props = withDefaults(defineProps<{
+	overridePageMetadata?: PageMetadata;
 	tabs?: Tab[];
 	tab?: string;
 	actions?: PageHeaderItem[] | null;
 	thin?: boolean;
+	hideTitle?: boolean;
 	displayMyAvatar?: boolean;
 }>(), {
 	tabs: () => ([] as Tab[]),
@@ -64,9 +67,10 @@ const emit = defineEmits<{
 	(ev: 'update:tab', key: string);
 }>();
 
-const pageMetadata = injectReactiveMetadata();
+const injectedPageMetadata = injectReactiveMetadata();
+const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
 
-const hideTitle = inject('shouldOmitHeaderTitle', false);
+const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
 const thin_ = props.thin || inject('shouldHeaderThin', false);
 
 const el = shallowRef<HTMLElement | undefined>(undefined);
@@ -75,7 +79,7 @@ const narrow = ref(false);
 const hasTabs = computed(() => props.tabs.length > 0);
 const hasActions = computed(() => props.actions && props.actions.length > 0);
 const show = computed(() => {
-	return !hideTitle || hasTabs.value || hasActions.value;
+	return !hideTitle.value || hasTabs.value || hasActions.value;
 });
 
 const preventDrag = (ev: TouchEvent) => {
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index 0ffd42abda..e473b7c1af 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -39,13 +39,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 					{{ cell.value }}
 				</div>
 				<div v-else-if="cellType === 'boolean'">
-					<span v-if="cell.value === true" class="ti ti-check"/>
-					<span v-else class="ti"/>
+					<div :class="[$style.bool, {
+						[$style.boolTrue]: cell.value === true,
+						'ti ti-check': cell.value === true,
+					}]"></div>
 				</div>
 				<div v-else-if="cellType === 'image'">
 					<img
-						:src="cell.value as string"
-						:alt="cell.value as string"
+						:src="cell.value"
+						:alt="cell.value"
 						:class="$style.viewImage"
 						@load="emitContentSizeChanged"
 					/>
@@ -375,6 +377,31 @@ $cellHeight: 28px;
 	object-fit: cover;
 }
 
+.bool {
+	position: relative;
+	width: 18px;
+	height: 18px;
+	background: var(--MI_THEME-panel);
+	border: solid 2px var(--MI_THEME-divider);
+	border-radius: 4px;
+	box-sizing: border-box;
+
+	&.boolTrue {
+		border-color: var(--MI_THEME-accent);
+		background: var(--MI_THEME-accent);
+
+		&::before {
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			transform: translate(-50%, -50%);
+			color: var(--MI_THEME-fgOnAccent);
+			font-size: 12px;
+			line-height: 18px;
+		}
+	}
+}
+
 .editingInput {
 	padding: 0 8px;
 	width: 100%;
diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue
index 60738365fb..4dbd4ebcae 100644
--- a/packages/frontend/src/components/grid/MkGrid.vue
+++ b/packages/frontend/src/components/grid/MkGrid.vue
@@ -7,7 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div
 	ref="rootEl"
 	class="mk_grid_border"
-	:class="[$style.grid]"
+	:class="[$style.grid, {
+		[$style.noOverflowHandling]: rootSetting.noOverflowStyle,
+		'mk_grid_root_rounded': rootSetting.rounded,
+		'mk_grid_root_border': rootSetting.outerBorder,
+	}]"
 	@mousedown.prevent="onMouseDown"
 	@keydown="onKeyDown"
 	@contextmenu.prevent.stop="onContextMenu"
@@ -77,10 +81,17 @@ const emit = defineEmits<{
 }>();
 
 const props = defineProps<{
-	settings: GridSetting,
-	data: DataSource[]
+	settings: GridSetting;
+	data: DataSource[];
 }>();
 
+const rootSetting: Required<GridSetting['root']> = {
+	noOverflowStyle: false,
+	rounded: true,
+	outerBorder: true,
+	...props.settings.root,
+};
+
 // non-reactive
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const rowSetting: Required<GridRowSetting> = {
@@ -1277,32 +1288,48 @@ onMounted(() => {
 	overflow-x: scroll;
 	// firefoxだとスクロールバーがセルに重なって見づらくなってしまうのでスペースを空けておく
 	padding-bottom: 8px;
+
+	&.noOverflowHandling {
+		overflow-x: revert;
+		padding-bottom: 0;
+	}
 }
 </style>
 
 <style lang="scss">
 $borderSetting: solid 0.5px var(--MI_THEME-divider);
-$borderRadius: var(--MI-radius);
 
 // 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない
 .mk_grid_border {
+	--rootBorderSetting: none;
+	--borderRadius: 0;
+
 	border-spacing: 0;
 
+	&.mk_grid_root_border {
+		--rootBorderSetting: #{$borderSetting};
+	}
+
+	&.mk_grid_root_rounded {
+		--borderRadius: var(--MI-radius);
+	}
+
 	.mk_grid_thead {
 		.mk_grid_tr {
 			.mk_grid_th {
 				border-left: $borderSetting;
-				border-top: $borderSetting;
+				border-top: var(--rootBorderSetting);
 
 				&:first-child {
 					// 左上セル
-					border-top-left-radius: $borderRadius;
+					border-left: var(--rootBorderSetting);
+					border-top-left-radius: var(--borderRadius);
 				}
 
 				&:last-child {
 					// 右上セル
-					border-top-right-radius: $borderRadius;
-					border-right: $borderSetting;
+					border-top-right-radius: var(--borderRadius);
+					border-right: var(--rootBorderSetting);
 				}
 			}
 		}
@@ -1314,9 +1341,14 @@ $borderRadius: var(--MI-radius);
 				border-left: $borderSetting;
 				border-top: $borderSetting;
 
+				&:first-child {
+					// 左端の列
+					border-left: var(--rootBorderSetting);
+				}
+
 				&:last-child {
 					// 一番右端の列
-					border-right: $borderSetting;
+					border-right: var(--rootBorderSetting);
 				}
 			}
 		}
@@ -1324,16 +1356,16 @@ $borderRadius: var(--MI-radius);
 		.last_row {
 			.mk_grid_td, .mk_grid_th {
 				// 一番下の行
-				border-bottom: $borderSetting;
+				border-bottom: var(--rootBorderSetting);
 
 				&:first-child {
 					// 左下セル
-					border-bottom-left-radius: $borderRadius;
+					border-bottom-left-radius: var(--borderRadius);
 				}
 
 				&:last-child {
 					// 右下セル
-					border-bottom-right-radius: $borderRadius;
+					border-bottom-right-radius: var(--borderRadius);
 				}
 			}
 		}
diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue
index 605d27c6d6..aecfe7eaa3 100644
--- a/packages/frontend/src/components/grid/MkHeaderCell.vue
+++ b/packages/frontend/src/components/grid/MkHeaderCell.vue
@@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:data-grid-cell-col="column.index"
 >
 	<div :class="$style.root">
-		<div :class="$style.left"/>
+		<div :class="$style.left"></div>
 		<div :class="$style.wrapper">
 			<div ref="contentEl" :class="$style.contentArea">
-				<span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"/>
+				<span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"></span>
 				<span v-else>{{ text }}</span>
 			</div>
 		</div>
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			:class="$style.right"
 			@mousedown="onHandleMouseDown"
 			@dblclick="onHandleDoubleClick"
-		/>
+		></div>
 	</div>
 </div>
 </template>
diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts
index 0cb3b6f28b..b82e12b304 100644
--- a/packages/frontend/src/components/grid/grid.ts
+++ b/packages/frontend/src/components/grid/grid.ts
@@ -9,6 +9,11 @@ import { GridColumnSetting } from '@/components/grid/column.js';
 import { GridRowSetting } from '@/components/grid/row.js';
 
 export type GridSetting = {
+	root?: {
+		noOverflowStyle?: boolean;
+		rounded?: boolean;
+		outerBorder?: boolean;
+	};
 	row?: GridRowSetting;
 	cols: GridColumnSetting[];
 	cells?: GridCellSetting;
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
index de2b2aca8c..141ab858d3 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
@@ -22,7 +22,8 @@ export const gridSortOrderKeys = [
 	'isSensitive',
 	'localOnly',
 	'updatedAt',
-];
+] as const satisfies string[];
+
 export type GridSortOrderKey = typeof gridSortOrderKeys[number];
 
 export function emptyStrToUndefined(value: string | null) {
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue
new file mode 100644
index 0000000000..4b145db0ed
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue
@@ -0,0 +1,39 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkWindow
+	ref="uiWindow"
+	:initialWidth="400"
+	:initialHeight="500"
+	:canResize="true"
+	@closed="emit('closed')"
+>
+	<template #header>
+		<i class="ti ti-notes" style="margin-right: 0.5em;"></i> {{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}
+	</template>
+	<MkSpacer>
+		<XRegisterLogs :logs="logs"/>
+	</MkSpacer>
+</MkWindow>
+</template>
+
+<script setup lang="ts">
+import MkWindow from '@/components/MkWindow.vue';
+import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
+
+import { i18n } from '@/i18n.js';
+
+import type { RequestLogItem } from './custom-emojis-manager.impl.js';
+
+defineProps<{
+	logs: RequestLogItem[];
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+</script>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
new file mode 100644
index 0000000000..ae43507d66
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
@@ -0,0 +1,213 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkWindow
+	ref="uiWindow"
+	:initialWidth="400"
+	:initialHeight="500"
+	:canResize="true"
+	@closed="emit('closed')"
+>
+	<template #header>
+		<i class="ti ti-search" style="margin-right: 0.5em;"></i> {{ i18n.ts.search }}
+	</template>
+	<div :class="$style.root">
+		<MkSpacer>
+			<div class="_gaps">
+				<div class="_gaps_s">
+					<MkInput
+						v-model="model.name"
+						type="search"
+						autocapitalize="off"
+					>
+						<template #label>name</template>
+					</MkInput>
+					<MkInput
+						v-model="model.category"
+						type="search"
+						autocapitalize="off"
+					>
+						<template #label>category</template>
+					</MkInput>
+					<MkInput
+						v-model="model.aliases"
+						type="search"
+						autocapitalize="off"
+					>
+						<template #label>aliases</template>
+					</MkInput>
+
+					<MkInput
+						v-model="model.type"
+						type="search"
+						autocapitalize="off"
+					>
+						<template #label>type</template>
+					</MkInput>
+					<MkInput
+						v-model="model.license"
+						type="search"
+						autocapitalize="off"
+					>
+						<template #label>license</template>
+					</MkInput>
+					<MkSelect
+						v-model="model.sensitive"
+					>
+						<template #label>sensitive</template>
+						<option :value="null">-</option>
+						<option :value="true">true</option>
+						<option :value="false">false</option>
+					</MkSelect>
+
+					<MkSelect
+						v-model="model.localOnly"
+					>
+						<template #label>localOnly</template>
+						<option :value="null">-</option>
+						<option :value="true">true</option>
+						<option :value="false">false</option>
+					</MkSelect>
+					<MkInput
+						v-model="model.updatedAtFrom"
+						type="date"
+						autocapitalize="off"
+					>
+						<template #label>updatedAt(from)</template>
+					</MkInput>
+					<MkInput
+						v-model="model.updatedAtTo"
+						type="date"
+						autocapitalize="off"
+					>
+						<template #label>updatedAt(to)</template>
+					</MkInput>
+
+					<MkInput
+						v-model="queryRolesText"
+						type="text"
+						readonly
+						autocapitalize="off"
+						@click="onQueryRolesEditClicked"
+					>
+						<template #label>role</template>
+						<template #suffix><i class="ti ti-pencil"></i></template>
+					</MkInput>
+				</div>
+				<MkFolder :spacerMax="8" :spacerMin="8">
+					<template #icon><i class="ti ti-arrows-sort"></i></template>
+					<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
+					<MkSortOrderEditor
+						:baseOrderKeyNames="gridSortOrderKeys"
+						:currentOrders="sortOrders"
+						@update="onSortOrderUpdate"
+					/>
+				</MkFolder>
+			</div>
+		</MkSpacer>
+		<div :class="$style.footerActions">
+			<MkButton primary @click="onSearchRequest">
+				{{ i18n.ts.search }}
+			</MkButton>
+			<MkButton @click="onQueryResetButtonClicked">
+				{{ i18n.ts.reset }}
+			</MkButton>
+		</div>
+	</div>
+</MkWindow>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import MkWindow from '@/components/MkWindow.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
+
+import {
+	gridSortOrderKeys,
+} from './custom-emojis-manager.impl.js';
+
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+
+import type { EmojiSearchQuery } from './custom-emojis-manager.local.list.vue';
+import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
+
+const props = defineProps<{
+	query: EmojiSearchQuery;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'queryUpdated', query: EmojiSearchQuery): void;
+	(ev: 'sortOrderUpdated', orders: SortOrder<GridSortOrderKey>[]): void;
+	(ev: 'search'): void;
+}>();
+
+const model = ref<EmojiSearchQuery>(props.query);
+const queryRolesText = computed(() => model.value.roles.map(it => it.name).join(','));
+
+watch(model, () => {
+	emit('queryUpdated', model.value);
+}, { deep: true });
+
+const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
+
+function onSortOrderUpdate(orders: SortOrder<GridSortOrderKey>[]) {
+	sortOrders.value = orders;
+	emit('sortOrderUpdated', orders);
+}
+
+function onSearchRequest() {
+	emit('search');
+}
+
+function onQueryResetButtonClicked() {
+	model.value.name = '';
+	model.value.category = '';
+	model.value.aliases = '';
+	model.value.type = '';
+	model.value.license = '';
+	model.value.sensitive = null;
+	model.value.localOnly = null;
+	model.value.updatedAtFrom = '';
+	model.value.updatedAtTo = '';
+	sortOrders.value = [];
+}
+
+async function onQueryRolesEditClicked() {
+	const result = await os.selectRole({
+		initialRoleIds: model.value.roles.map(it => it.id),
+		title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
+		publicOnly: true,
+	});
+	if (result.canceled) {
+		return;
+	}
+
+	model.value.roles = result.result;
+}
+</script>
+
+<style module>
+.root {
+	position: relative;
+}
+
+.footerActions {
+	position: sticky;
+	bottom: 0;
+	padding: var(--MI-margin);
+	background-color: var(--MI_THEME-bg);
+	display: flex;
+	gap: 8px;
+	z-index: 1;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
index 55f9632ce4..c4ea3b93e3 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -5,137 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <MkStickyContainer>
+	<template #header>
+		<MkPageHeader :overridePageMetadata="headerPageMetadata" :actions="headerActions"/>
+	</template>
 	<template #default>
-		<div class="_gaps">
-			<MkFolder>
-				<template #icon><i class="ti ti-search"></i></template>
-				<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
-				<template #caption>
-					{{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
-				</template>
-
-				<div class="_gaps">
-					<div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
-						<MkInput
-							v-model="queryName"
-							type="search"
-							autocapitalize="off"
-							:class="[$style.col1, $style.row1]"
-							@enter="onSearchRequest"
-						>
-							<template #label>name</template>
-						</MkInput>
-						<MkInput
-							v-model="queryCategory"
-							type="search"
-							autocapitalize="off"
-							:class="[$style.col2, $style.row1]"
-							@enter="onSearchRequest"
-						>
-							<template #label>category</template>
-						</MkInput>
-						<MkInput
-							v-model="queryAliases"
-							type="search"
-							autocapitalize="off"
-							:class="[$style.col3, $style.row1]"
-							@enter="onSearchRequest"
-						>
-							<template #label>aliases</template>
-						</MkInput>
-
-						<MkInput
-							v-model="queryType"
-							type="search"
-							autocapitalize="off"
-							:class="[$style.col1, $style.row2]"
-							@enter="onSearchRequest"
-						>
-							<template #label>type</template>
-						</MkInput>
-						<MkInput
-							v-model="queryLicense"
-							type="search"
-							autocapitalize="off"
-							:class="[$style.col2, $style.row2]"
-							@enter="onSearchRequest"
-						>
-							<template #label>license</template>
-						</MkInput>
-						<MkSelect
-							v-model="querySensitive"
-							:class="[$style.col3, $style.row2]"
-						>
-							<template #label>sensitive</template>
-							<option :value="null">-</option>
-							<option :value="true">true</option>
-							<option :value="false">false</option>
-						</MkSelect>
-
-						<MkSelect
-							v-model="queryLocalOnly"
-							:class="[$style.col1, $style.row3]"
-						>
-							<template #label>localOnly</template>
-							<option :value="null">-</option>
-							<option :value="true">true</option>
-							<option :value="false">false</option>
-						</MkSelect>
-						<MkInput
-							v-model="queryUpdatedAtFrom"
-							type="date"
-							autocapitalize="off"
-							:class="[$style.col2, $style.row3]"
-							@enter="onSearchRequest"
-						>
-							<template #label>updatedAt(from)</template>
-						</MkInput>
-						<MkInput
-							v-model="queryUpdatedAtTo"
-							type="date"
-							autocapitalize="off"
-							:class="[$style.col3, $style.row3]"
-							@enter="onSearchRequest"
-						>
-							<template #label>updatedAt(to)</template>
-						</MkInput>
-
-						<MkInput
-							v-model="queryRolesText"
-							type="text"
-							readonly
-							autocapitalize="off"
-							:class="[$style.col1, $style.row4]"
-							@click="onQueryRolesEditClicked"
-						>
-							<template #label>role</template>
-							<template #suffix><span class="ti ti-pencil"/></template>
-						</MkInput>
-					</div>
-
-					<MkFolder :spacerMax="8" :spacerMin="8">
-						<template #icon><i class="ti ti-arrows-sort"></i></template>
-						<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
-						<MkSortOrderEditor
-							:baseOrderKeyNames="gridSortOrderKeys"
-							:currentOrders="sortOrders"
-							@update="onSortOrderUpdate"
-						/>
-					</MkFolder>
-
-					<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
-						<MkButton primary @click="onSearchRequest">
-							{{ i18n.ts.search }}
-						</MkButton>
-						<MkButton @click="onQueryResetButtonClicked">
-							{{ i18n.ts.reset }}
-						</MkButton>
-					</div>
-				</div>
-			</MkFolder>
-
-			<XRegisterLogsFolder :logs="requestLogs"/>
-
+		<div class="_gaps" :class="$style.main">
 			<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
 			<template v-else>
 				<div v-if="gridItems.length === 0" style="text-align: center">
@@ -143,65 +17,78 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 
 				<template v-else>
-					<div :class="$style.gridArea">
+					<div :class="$style.grid">
 						<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
 					</div>
-
-					<div :class="$style.footer">
-						<div :class="$style.left">
-							<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
-								{{ i18n.ts.delete }} ({{ deleteItemsCount }})
-							</MkButton>
-						</div>
-
-						<div :class="$style.center">
-							<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
-						</div>
-
-						<div :class="$style.right">
-							<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
-								{{ i18n.ts.update }} ({{ updatedItemsCount }})
-							</MkButton>
-							<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
-						</div>
-					</div>
 				</template>
 			</template>
 		</div>
 	</template>
+
+	<template #footer>
+		<div v-if="gridItems.length > 0" :class="$style.footer">
+			<div :class="$style.left">
+				<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
+					{{ i18n.ts.delete }} ({{ deleteItemsCount }})
+				</MkButton>
+			</div>
+
+			<div :class="$style.center">
+				<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
+			</div>
+
+			<div :class="$style.right">
+				<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
+					{{ i18n.ts.update }} ({{ updatedItemsCount }})
+				</MkButton>
+				<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
+			</div>
+		</div>
+	</template>
 </MkStickyContainer>
 </template>
 
+<script lang="ts">
+import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
+
+export type EmojiSearchQuery = {
+	name: string | null;
+	category: string | null;
+	aliases: string | null;
+	type: string | null;
+	license: string | null;
+	updatedAtFrom: string | null;
+	updatedAtTo: string | null;
+	sensitive: string | null;
+	localOnly: string | null;
+	roles: { id: string, name: string }[];
+	sortOrders: SortOrder<GridSortOrderKey>[];
+	limit: number;
+};
+</script>
+
 <script setup lang="ts">
-import { computed, onMounted, ref, useCssModule } from 'vue';
+import { computed, defineAsyncComponent, onMounted, ref, nextTick, useCssModule } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
 import {
 	emptyStrToEmptyArray,
 	emptyStrToNull,
 	emptyStrToUndefined,
-	GridSortOrderKey,
-	gridSortOrderKeys,
 	RequestLogItem,
 	roleIdsParser,
 } from '@/pages/admin/custom-emojis-manager.impl.js';
 import MkGrid from '@/components/grid/MkGrid.vue';
 import { i18n } from '@/i18n.js';
-import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
 import { validators } from '@/components/grid/cell-validators.js';
 import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkPagingButtons from '@/components/MkPagingButtons.vue';
-import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkSelect from '@/components/MkSelect.vue';
-import { deviceKind } from '@/scripts/device-kind.js';
 import { GridSetting } from '@/components/grid/grid.js';
 import { selectFile } from '@/scripts/select-file.js';
 import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
-import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
-import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
 import { useLoading } from "@/components/hook/useLoading.js";
 
 type GridItem = {
@@ -230,6 +117,11 @@ function setupGrid(): GridSetting {
 	const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
 	const unique = validators.unique();
 	return {
+		root: {
+			noOverflowStyle: true,
+			rounded: false,
+			outerBorder: false,
+		},
 		row: {
 			showNumber: true,
 			selectable: true,
@@ -381,16 +273,22 @@ const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
 const allPages = ref<number>(0);
 const currentPage = ref<number>(0);
 
-const queryName = ref<string | null>(null);
-const queryCategory = ref<string | null>(null);
-const queryAliases = ref<string | null>(null);
-const queryType = ref<string | null>(null);
-const queryLicense = ref<string | null>(null);
-const queryUpdatedAtFrom = ref<string | null>(null);
-const queryUpdatedAtTo = ref<string | null>(null);
-const querySensitive = ref<string | null>(null);
-const queryLocalOnly = ref<string | null>(null);
-const queryRoles = ref<{ id: string, name: string }[]>([]);
+const searchQuery = ref<EmojiSearchQuery>({
+	name: null,
+	category: null,
+	aliases: null,
+	type: null,
+	license: null,
+	updatedAtFrom: null,
+	updatedAtTo: null,
+	sensitive: null,
+	localOnly: null,
+	roles: [],
+	sortOrders: [],
+	limit: 25,
+});
+let searchWindowOpening = false;
+
 const previousQuery = ref<string | undefined>(undefined);
 const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
 const requestLogs = ref<RequestLogItem[]>([]);
@@ -399,8 +297,6 @@ const gridItems = ref<GridItem[]>([]);
 const originGridItems = ref<GridItem[]>([]);
 const updateButtonDisabled = ref<boolean>(false);
 
-const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
-const queryRolesText = computed(() => queryRoles.value.map(it => it.name).join(','));
 const updatedItemsCount = computed(() => {
 	return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length;
 });
@@ -422,12 +318,11 @@ async function onUpdateButtonClicked() {
 		return;
 	}
 
-	const confirm = await os.confirm({
+	const { canceled } = await os.confirm({
 		type: 'info',
-		title: i18n.ts._customEmojisManager._local._list.confirmUpdateEmojisTitle,
 		text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }),
 	});
-	if (confirm.canceled) {
+	if (canceled) {
 		return;
 	}
 
@@ -458,7 +353,7 @@ async function onUpdateButtonClicked() {
 	if (failedItems.length > 0) {
 		await os.alert({
 			type: 'error',
-			title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+			title: i18n.ts.somethingHappened,
 			text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
 		});
 	}
@@ -489,12 +384,11 @@ async function onDeleteButtonClicked() {
 		return;
 	}
 
-	const confirm = await os.confirm({
+	const { canceled } = await os.confirm({
 		type: 'info',
-		title: i18n.ts._customEmojisManager._local._list.confirmDeleteEmojisTitle,
 		text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }),
 	});
-	if (confirm.canceled) {
+	if (canceled) {
 		return;
 	}
 
@@ -508,47 +402,35 @@ async function onDeleteButtonClicked() {
 	);
 }
 
-function onGridResetButtonClicked() {
-	refreshGridItems();
-}
-
-async function onQueryRolesEditClicked() {
-	const result = await os.selectRole({
-		initialRoleIds: queryRoles.value.map(it => it.id),
-		title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
-		publicOnly: true,
+async function onGridResetButtonClicked() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		title: i18n.ts.resetAreYouSure,
+		text: i18n.ts._customEmojisManager._local._list.confirmResetDescription,
 	});
-	if (result.canceled) {
-		return;
-	}
 
-	queryRoles.value = result.result;
-}
+	if (canceled) return;
 
-function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
-	sortOrders.value = _sortOrders;
+	refreshGridItems();
 }
 
 async function onSearchRequest() {
 	await refreshCustomEmojis();
 }
 
-function onQueryResetButtonClicked() {
-	queryName.value = null;
-	queryCategory.value = null;
-	queryAliases.value = null;
-	queryType.value = null;
-	queryLicense.value = null;
-	queryUpdatedAtFrom.value = null;
-	queryUpdatedAtTo.value = null;
-	querySensitive.value = null;
-	queryLocalOnly.value = null;
-	queryRoles.value = [];
-}
-
 async function onPageChanged(pageNumber: number) {
+	if (updatedItemsCount.value > 0) {
+		const { canceled } = await os.confirm({
+			type: 'warning',
+			title: i18n.ts._customEmojisManager._local._list.confirmMovePage,
+			text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
+		});
+		if (canceled) return;
+	}
+
 	currentPage.value = pageNumber;
-	await refreshCustomEmojis();
+	await nextTick();
+	refreshCustomEmojis();
 }
 
 function onGridEvent(event: GridEvent) {
@@ -574,19 +456,19 @@ function onGridCellValueChange(event: GridCellValueChangeEvent) {
 }
 
 async function refreshCustomEmojis() {
-	const limit = 100;
+	const limit = searchQuery.value.limit;
 
 	const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
-		name: emptyStrToUndefined(queryName.value),
-		type: emptyStrToUndefined(queryType.value),
-		aliases: emptyStrToUndefined(queryAliases.value),
-		category: emptyStrToUndefined(queryCategory.value),
-		license: emptyStrToUndefined(queryLicense.value),
-		isSensitive: querySensitive.value ? Boolean(querySensitive.value).valueOf() : undefined,
-		localOnly: queryLocalOnly.value ? Boolean(queryLocalOnly.value).valueOf() : undefined,
-		updatedAtFrom: emptyStrToUndefined(queryUpdatedAtFrom.value),
-		updatedAtTo: emptyStrToUndefined(queryUpdatedAtTo.value),
-		roleIds: queryRoles.value.map(it => it.id),
+		name: emptyStrToUndefined(searchQuery.value.name),
+		type: emptyStrToUndefined(searchQuery.value.type),
+		aliases: emptyStrToUndefined(searchQuery.value.aliases),
+		category: emptyStrToUndefined(searchQuery.value.category),
+		license: emptyStrToUndefined(searchQuery.value.license),
+		isSensitive: searchQuery.value.sensitive ? Boolean(searchQuery.value.sensitive).valueOf() : undefined,
+		localOnly: searchQuery.value.localOnly ? Boolean(searchQuery.value.localOnly).valueOf() : undefined,
+		updatedAtFrom: emptyStrToUndefined(searchQuery.value.updatedAtFrom),
+		updatedAtTo: emptyStrToUndefined(searchQuery.value.updatedAtTo),
+		roleIds: searchQuery.value.roles.map(it => it.id),
 		hostType: 'local',
 	};
 
@@ -635,6 +517,83 @@ onMounted(async () => {
 	await refreshCustomEmojis();
 });
 
+const headerPageMetadata = computed(() => ({
+	title: i18n.ts._customEmojisManager._local.tabTitleList,
+	icon: 'ti ti-icons',
+}));
+
+const headerActions = computed(() => [{
+	icon: 'ti ti-search',
+	text: i18n.ts.search,
+	handler: () => {
+		if (searchWindowOpening) return;
+		searchWindowOpening = true;
+		const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), {
+			query: searchQuery.value,
+		}, {
+			queryUpdated: (query: EmojiSearchQuery) => {
+				searchQuery.value = query;
+			},
+			sortOrderUpdated: (orders: SortOrder<GridSortOrderKey>[]) => {
+				sortOrders.value = orders;
+			},
+			search: () => {
+				onSearchRequest();
+			},
+			closed: () => {
+				dispose();
+				searchWindowOpening = false;
+			},
+		});
+	},
+}, {
+	icon: 'ti ti-list-numbers',
+	text: i18n.ts._customEmojisManager._gridCommon.searchLimit,
+	handler: (ev: MouseEvent) => {
+		async function changeSearchLimit(to: number) {
+			if (updatedItemsCount.value > 0) {
+				const { canceled } = await os.confirm({
+					type: 'warning',
+					title: i18n.ts._customEmojisManager._local._list.confirmChangeView,
+					text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
+				});
+				if (canceled) return;
+			}
+
+			searchQuery.value.limit = to;
+			refreshCustomEmojis();
+		}
+
+		os.popupMenu([{
+			type: 'radioOption',
+			text: '25',
+			active: computed(() => searchQuery.value.limit === 25),
+			action: () => changeSearchLimit(25),
+		}, {
+			type: 'radioOption',
+			text: '50',
+			active: computed(() => searchQuery.value.limit === 50),
+			action: () => changeSearchLimit(50),
+		}, {
+			type: 'radioOption',
+			text: '100',
+			active: computed(() => searchQuery.value.limit === 100),
+			action: () => changeSearchLimit(100),
+		}], ev.currentTarget ?? ev.target);
+	},
+}, {
+	icon: 'ti ti-notes',
+	text: i18n.ts._customEmojisManager._gridCommon.registrationLogs,
+	handler: () => {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), {
+			logs: requestLogs.value,
+		}, {
+			closed: () => {
+				dispose();
+			},
+		});
+	}
+}]);
 </script>
 
 <style module lang="scss">
@@ -650,77 +609,21 @@ onMounted(async () => {
 	background-color: var(--MI_THEME-infoBg);
 }
 
-.row1 {
-	grid-row: 1 / 2;
-}
-
-.row2 {
-	grid-row: 2 / 3;
-}
-
-.row3 {
-	grid-row: 3 / 4;
-}
-
-.row4 {
-	grid-row: 4 / 5;
-}
-
-.col1 {
-	grid-column: 1 / 2;
-}
-
-.col2 {
-	grid-column: 2 / 3;
-}
-
-.col3 {
-	grid-column: 3 / 4;
-}
-
-.searchArea {
-	display: grid;
-	grid-template-columns: 1fr 1fr 1fr;
-	gap: 16px;
-}
-
-.searchAreaSp {
-	display: flex;
-	flex-direction: column;
-	gap: 8px;
-}
-
-.searchButtons {
-	display: flex;
-	justify-content: flex-end;
-	align-items: flex-end;
-	gap: 8px;
-}
-
-.searchButtonsSp {
-	display: flex;
-	justify-content: center;
-	align-items: center;
-	gap: 8px;
+.main {
+	height: calc(100vh - var(--MI-stickyTop) - var(--MI-stickyBottom));
+	overflow: scroll;
 }
 
-.gridArea {
-	padding-top: 8px;
-	padding-bottom: 8px;
+.grid {
+	width: max-content;
+	border-bottom: 1px solid var(--MI_THEME-divider);
 }
 
 .footer {
 	background-color: var(--MI_THEME-bg);
 
-	position: sticky;
-	left:0;
-	bottom:0;
-	z-index: 1;
-	// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
-	margin-top: calc(var(--MI-margin) * -1);
-	margin-bottom: calc(var(--MI-margin) * -1);
-	padding-top: var(--MI-margin);
-	padding-bottom: var(--MI-margin);
+	padding: var(--MI-margin);
+	border-top: 1px solid var(--MI_THEME-divider);
 
 	display: grid;
 	grid-template-columns: 1fr 1fr 1fr;
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
index a3de5de569..cc8b625cd5 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
@@ -30,7 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 	</MkFolder>
 
-	<XRegisterLogsFolder :logs="requestLogs"/>
+	<MkFolder>
+		<template #icon><i class="ti ti-notes"></i></template>
+		<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
+		<template #caption>
+			{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
+		</template>
+		<XRegisterLogs :logs="requestLogs"/>
+	</MkFolder>
 
 	<div
 		:class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]"
@@ -91,7 +98,7 @@ import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'
 import { uploadFile } from '@/scripts/upload.js';
 import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
 import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
-import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
+import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import { GridSetting } from '@/components/grid/grid.js';
 import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
 import { GridRow } from '@/components/grid/row.js';
@@ -245,7 +252,6 @@ const isDragOver = ref<boolean>(false);
 async function onRegistryClicked() {
 	const dialogSelection = await os.confirm({
 		type: 'info',
-		title: i18n.ts._customEmojisManager._local._register.confirmRegisterEmojisTitle,
 		text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }),
 	});
 
@@ -279,7 +285,7 @@ async function onRegistryClicked() {
 	if (failedItems.length > 0) {
 		await os.alert({
 			type: 'error',
-			title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+			title: i18n.ts.somethingHappened,
 			text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
 		});
 	}
@@ -299,7 +305,6 @@ async function onRegistryClicked() {
 async function onClearClicked() {
 	const result = await os.confirm({
 		type: 'warning',
-		title: i18n.ts._customEmojisManager._local._register.confirmClearEmojisTitle,
 		text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription,
 	});
 
@@ -314,7 +319,6 @@ async function onDrop(ev: DragEvent) {
 	const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
 	const confirm = await os.confirm({
 		type: 'info',
-		title: i18n.ts._customEmojisManager._local._register.confirmUploadEmojisTitle,
 		text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
 	});
 	if (confirm.canceled) {
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
index ea4303f342..6e7e7e53e3 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
@@ -4,33 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="_gaps" :class="$style.root">
-	<MkTab v-model="modeTab" style="margin-bottom: var(--margin);">
-		<option value="list">{{ i18n.ts._customEmojisManager._local.tabTitleList }}</option>
-		<option value="register">{{ i18n.ts._customEmojisManager._local.tabTitleRegister }}</option>
-	</MkTab>
-
-	<div>
-		<XListComponent v-if="modeTab === 'list'"/>
-		<XRegisterComponent v-else/>
-	</div>
-</div>
+<MkStickyContainer>
+	<template #header>
+		<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs" hideTitle thin/>
+	</template>
+	<XListComponent v-if="headerTab === 'list'" key="localList"/>
+	<MkSpacer v-else key="localRegister">
+		<XRegisterComponent/>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
 import { i18n } from '@/i18n.js';
-import MkTab from '@/components/MkTab.vue';
 import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue';
 import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue';
 
 type PageMode = 'list' | 'register';
 
-const modeTab = ref<PageMode>('list');
-</script>
+const headerTab = ref<PageMode>('list');
 
-<style module lang="scss">
-.root {
-	padding: var(--MI-margin);
-}
-</style>
+const headerTabs = computed(() => [{
+	key: 'list',
+	title: i18n.ts._customEmojisManager._local.tabTitleList,
+}, {
+	key: 'register',
+	title: i18n.ts._customEmojisManager._local.tabTitleRegister,
+}]);
+</script>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue
similarity index 62%
rename from packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue
rename to packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue
index f75f6c0da5..eef55a9f7e 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue
@@ -4,47 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkFolder>
-	<template #icon><i class="ti ti-notes"></i></template>
-	<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
-	<template #caption>
-		{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
-	</template>
-
-	<div>
-		<div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
-			<MkSwitch v-model="showingSuccessLogs">
-				<template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
-			</MkSwitch>
-			<div>
-				<div v-if="filteredLogs.length > 0">
-					<MkGrid
-						:data="filteredLogs"
-						:settings="setupGrid()"
-					/>
-				</div>
-				<div v-else>
-					{{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
-				</div>
+<div>
+	<div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
+		<MkSwitch v-model="showingSuccessLogs">
+			<template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
+		</MkSwitch>
+		<div>
+			<div v-if="filteredLogs.length > 0">
+				<MkGrid
+					:data="filteredLogs"
+					:settings="setupGrid()"
+				/>
+			</div>
+			<div v-else>
+				{{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
 			</div>
-		</div>
-		<div v-else>
-			{{ i18n.ts._customEmojisManager._logs.logNothing }}
 		</div>
 	</div>
-</MkFolder>
+	<div v-else>
+		{{ i18n.ts._customEmojisManager._logs.logNothing }}
+	</div>
+</div>
 </template>
 
 <script setup lang="ts">
-
 import { computed, ref, toRefs } from 'vue';
 import { i18n } from '@/i18n.js';
-import { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
 import MkGrid from '@/components/grid/MkGrid.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { GridSetting } from '@/components/grid/grid.js';
 import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
-import MkFolder from '@/components/MkFolder.vue';
+
+import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
+import type { GridSetting } from '@/components/grid/grid.js';
 
 function setupGrid(): GridSetting {
 	return {
@@ -94,9 +85,4 @@ const filteredLogs = computed(() => {
 	const forceShowing = showingSuccessLogs.value;
 	return logs.value.filter((log) => forceShowing || log.failed);
 });
-
 </script>
-
-<style module lang="scss">
-
-</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
index 14a3b71e53..eecf8d7390 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
@@ -64,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</MkInput>
 					</div>
 
+					<hr>
+
 					<MkFolder :spacerMax="8" :spacerMin="8">
 						<template #icon><i class="ti ti-arrows-sort"></i></template>
 						<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
@@ -74,6 +76,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 						/>
 					</MkFolder>
 
+					<MkInput
+						v-model="queryLimit"
+						type="number"
+						:max="100"
+					>
+						<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchLimit }}</template>
+					</MkInput>
+
 					<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
 						<MkButton primary @click="onSearchRequest">
 							{{ i18n.ts.search }}
@@ -85,7 +95,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</MkFolder>
 
-			<XRegisterLogsFolder :logs="requestLogs"/>
+			<MkFolder>
+				<template #icon><i class="ti ti-notes"></i></template>
+				<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
+				<template #caption>
+					{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
+				</template>
+				<XRegisterLogs :logs="requestLogs"/>
+			</MkFolder>
 
 			<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
 			<template v-else>
@@ -139,7 +156,7 @@ import {
 } from '@/pages/admin/custom-emojis-manager.impl.js';
 import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
 import MkFolder from '@/components/MkFolder.vue';
-import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
+import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import * as os from '@/os.js';
 import { GridSetting } from '@/components/grid/grid.js';
 import { deviceKind } from '@/scripts/device-kind.js';
@@ -246,6 +263,7 @@ const queryHost = ref<string | null>(null);
 const queryLicense = ref<string | null>(null);
 const queryUri = ref<string | null>(null);
 const queryPublicUrl = ref<string | null>(null);
+const queryLimit = ref<number>(25);
 const previousQuery = ref<string | undefined>(undefined);
 const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
 const requestLogs = ref<RequestLogItem[]>([]);
@@ -325,7 +343,7 @@ async function importEmojis(targets: GridItem[]) {
 	if (failedItems.length > 0) {
 		await os.alert({
 			type: 'error',
-			title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+			title: i18n.ts.somethingHappened,
 			text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
 		});
 	}
@@ -355,7 +373,7 @@ async function refreshCustomEmojis() {
 	}
 
 	const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
-		limit: 100,
+		limit: queryLimit.value,
 		query: query,
 		page: currentPage.value,
 		sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[],
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
index a952a5a3d1..fb930064ff 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
@@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div>
-	<!-- コンテナが入れ子になるのでz-indexが被らないよう大きめの数値を設定する-->
-	<MkStickyContainer :headerZIndex="2000">
+	<MkStickyContainer>
 		<template #header>
 			<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
 		</template>
-		<XGridLocalComponent v-if="headerTab === 'local'"/>
+		<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
 		<XGridRemoteComponent v-else/>
 	</MkStickyContainer>
 </div>
@@ -40,5 +39,13 @@ const headerTabs = computed(() => [{
 definePageMetadata(computed(() => ({
 	title: i18n.ts.customEmojis,
 	icon: 'ti ti-icons',
+	needWideArea: true,
 })));
 </script>
+
+<style lang="css" module>
+.local {
+	height: calc(100dvh - var(--MI-stickyTop) - var(--MI-stickyBottom));
+	overflow: clip;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 969ca8b9e8..ea5fa457f2 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -34,6 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue';
 import { i18n } from '@/i18n.js';
 import MkSuperMenu from '@/components/MkSuperMenu.vue';
+import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { instance } from '@/instance.js';
 import { lookup } from '@/scripts/lookup.js';
@@ -55,7 +56,7 @@ const indexInfo = {
 
 provide('shouldOmitHeaderTitle', false);
 
-const INFO = ref(indexInfo);
+const INFO = ref<PageMetadata>(indexInfo);
 const childInfo = ref<null | PageMetadata>(null);
 const narrow = ref(false);
 const view = ref(null);
@@ -81,7 +82,7 @@ const ro = new ResizeObserver((entries, observer) => {
 	narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
 });
 
-const menuDef = computed(() => [{
+const menuDef = computed<SuperMenuDef[]>(() => [{
 	title: i18n.ts.quickAction,
 	items: [{
 		type: 'button',
@@ -89,7 +90,7 @@ const menuDef = computed(() => [{
 		text: i18n.ts.lookup,
 		action: adminLookup,
 	}, ...(instance.disableRegistration ? [{
-		type: 'button',
+		type: 'button' as const,
 		icon: 'ti ti-user-plus',
 		text: i18n.ts.createInviteCode,
 		action: invite,
@@ -333,12 +334,14 @@ defineExpose({
 		height: 100%;
 
 		> .nav {
+			position: sticky;
+			top: 0;
 			width: 32%;
 			max-width: 280px;
 			box-sizing: border-box;
 			border-right: solid 0.5px var(--MI_THEME-divider);
 			overflow: auto;
-			height: 100%;
+			height: 100dvh;
 		}
 
 		> .main {
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 96a95f1635..b5a6d719d1 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -44,7 +44,7 @@ const indexInfo = {
 	icon: 'ti ti-settings',
 	hideHeader: true,
 };
-const INFO = ref(indexInfo);
+const INFO = ref<PageMetadata>(indexInfo);
 const el = shallowRef<HTMLElement | null>(null);
 const childInfo = ref<null | PageMetadata>(null);
 
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index d739c2e1cd..94998b7be6 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<XWidgets/>
 	</div>
 
-	<button v-if="(!isDesktop || pageMetadata?.needWideArea) && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
+	<button v-if="!isDesktop && !pageMetadata?.needWideArea && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
 
 	<div v-if="isMobile" ref="navFooter" :class="$style.nav">
 		<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button>
-- 
GitLab