From 049373de3ba6a48153c3bd3b6c02253f2a4aa338 Mon Sep 17 00:00:00 2001
From: Kagami Sascha Rosylight <saschanaz@outlook.com>
Date: Thu, 22 Dec 2022 16:51:48 +0900
Subject: style(client): fix `vue/v-on-event-hyphenation` (#9389)

---
 packages/client/src/components/MkUserSelectDialog.vue | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'packages/client/src/components/MkUserSelectDialog.vue')

diff --git a/packages/client/src/components/MkUserSelectDialog.vue b/packages/client/src/components/MkUserSelectDialog.vue
index 07caedfe3a..1d31769c30 100644
--- a/packages/client/src/components/MkUserSelectDialog.vue
+++ b/packages/client/src/components/MkUserSelectDialog.vue
@@ -12,11 +12,11 @@
 	<div class="tbhwbxda">
 		<div class="form">
 			<FormSplit :min-width="170">
-				<MkInput v-model="username" :autofocus="true" @update:modelValue="search">
+				<MkInput v-model="username" :autofocus="true" @update:model-value="search">
 					<template #label>{{ i18n.ts.username }}</template>
 					<template #prefix>@</template>
 				</MkInput>
-				<MkInput v-model="host" @update:modelValue="search">
+				<MkInput v-model="host" @update:model-value="search">
 					<template #label>{{ i18n.ts.host }}</template>
 					<template #prefix>@</template>
 				</MkInput>
-- 
cgit v1.2.3-freya


From 9384f5399da39e53855beb8e7f8ded1aa56bf72e Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 27 Dec 2022 14:36:33 +0900
Subject: rename: client -> frontend

---
 .github/dependabot.yml                             |    2 +-
 .github/labeler.yml                                |    2 +-
 .github/workflows/lint.yml                         |    2 +-
 .gitignore                                         |    2 +-
 Dockerfile                                         |    4 +-
 gulpfile.js                                        |   16 +-
 package.json                                       |    2 +-
 .../backend/src/server/web/ClientServerService.ts  |    4 +-
 packages/client/.eslintrc.js                       |   89 -
 packages/client/.vscode/settings.json              |   11 -
 packages/client/@types/global.d.ts                 |   10 -
 packages/client/@types/theme.d.ts                  |    7 -
 packages/client/@types/vue.d.ts                    |   16 -
 packages/client/assets/about-icon.png              |  Bin 20528 -> 0 bytes
 packages/client/assets/dummy.png                   |  Bin 6285 -> 0 bytes
 packages/client/assets/fedi.jpg                    |  Bin 77752 -> 0 bytes
 packages/client/assets/label-red.svg               |    6 -
 packages/client/assets/label.svg                   |    6 -
 packages/client/assets/misskey.svg                 |    7 -
 packages/client/assets/remove.png                  |  Bin 424 -> 0 bytes
 packages/client/assets/sounds/aisha/1.mp3          |  Bin 34480 -> 0 bytes
 packages/client/assets/sounds/aisha/2.mp3          |  Bin 24031 -> 0 bytes
 packages/client/assets/sounds/aisha/3.mp3          |  Bin 29256 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba1.mp3 |  Bin 18866 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba2.mp3 |  Bin 27144 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba3.mp3 |  Bin 19853 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba4.mp3 |  Bin 19853 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba5.mp3 |  Bin 19853 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba6.mp3 |  Bin 19853 -> 0 bytes
 .../client/assets/sounds/noizenecio/kick_gaba7.mp3 |  Bin 19853 -> 0 bytes
 packages/client/assets/sounds/syuilo/down.mp3      |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/kick.mp3      |  Bin 15672 -> 0 bytes
 .../assets/sounds/syuilo/pirori-square-wet.mp3     |  Bin 139200 -> 0 bytes
 .../client/assets/sounds/syuilo/pirori-wet.mp3     |  Bin 139200 -> 0 bytes
 packages/client/assets/sounds/syuilo/pirori.mp3    |  Bin 19200 -> 0 bytes
 packages/client/assets/sounds/syuilo/poi1.mp3      |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/poi2.mp3      |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/pope1.mp3     |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/pope2.mp3     |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/popo.mp3      |  Bin 18240 -> 0 bytes
 .../client/assets/sounds/syuilo/queue-jammed.mp3   |  Bin 351466 -> 0 bytes
 packages/client/assets/sounds/syuilo/reverved.mp3  |  Bin 276480 -> 0 bytes
 packages/client/assets/sounds/syuilo/ryukyu.mp3    |  Bin 139200 -> 0 bytes
 packages/client/assets/sounds/syuilo/snare.mp3     |  Bin 26121 -> 0 bytes
 .../client/assets/sounds/syuilo/square-pico.mp3    |  Bin 19200 -> 0 bytes
 packages/client/assets/sounds/syuilo/triple.mp3    |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/up.mp3        |  Bin 18240 -> 0 bytes
 packages/client/assets/sounds/syuilo/waon.mp3      |  Bin 18240 -> 0 bytes
 packages/client/assets/tagcanvas.min.js            |   21 -
 packages/client/assets/unread.svg                  |    7 -
 packages/client/package.json                       |   94 --
 packages/client/src/account.ts                     |  238 ---
 packages/client/src/components/MkAbuseReport.vue   |  109 --
 .../client/src/components/MkAbuseReportWindow.vue  |   65 -
 .../client/src/components/MkActiveUsersHeatmap.vue |  236 ---
 packages/client/src/components/MkAnalogClock.vue   |  225 ---
 packages/client/src/components/MkAutocomplete.vue  |  476 ------
 packages/client/src/components/MkAvatars.vue       |   24 -
 packages/client/src/components/MkButton.vue        |  227 ---
 packages/client/src/components/MkCaptcha.vue       |  118 --
 .../src/components/MkChannelFollowButton.vue       |  129 --
 .../client/src/components/MkChannelPreview.vue     |  154 --
 packages/client/src/components/MkChart.vue         |  859 ----------
 packages/client/src/components/MkChartTooltip.vue  |   53 -
 packages/client/src/components/MkCode.core.vue     |   20 -
 packages/client/src/components/MkCode.vue          |   15 -
 packages/client/src/components/MkContainer.vue     |  275 ---
 packages/client/src/components/MkContextMenu.vue   |   85 -
 packages/client/src/components/MkCropperDialog.vue |  174 --
 packages/client/src/components/MkCwButton.vue      |   62 -
 .../client/src/components/MkDateSeparatedList.vue  |  189 ---
 packages/client/src/components/MkDialog.vue        |  208 ---
 packages/client/src/components/MkDigitalClock.vue  |   77 -
 packages/client/src/components/MkDrive.file.vue    |  334 ----
 packages/client/src/components/MkDrive.folder.vue  |  330 ----
 .../client/src/components/MkDrive.navFolder.vue    |  147 --
 packages/client/src/components/MkDrive.vue         |  801 ---------
 .../client/src/components/MkDriveFileThumbnail.vue |   80 -
 .../client/src/components/MkDriveSelectDialog.vue  |   58 -
 packages/client/src/components/MkDriveWindow.vue   |   30 -
 .../src/components/MkEmojiPicker.section.vue       |   36 -
 packages/client/src/components/MkEmojiPicker.vue   |  569 -------
 .../client/src/components/MkEmojiPickerDialog.vue  |   73 -
 .../client/src/components/MkEmojiPickerWindow.vue  |  180 --
 .../client/src/components/MkFeaturedPhotos.vue     |   22 -
 .../src/components/MkFileCaptionEditWindow.vue     |  175 --
 .../client/src/components/MkFileListForAdmin.vue   |  117 --
 packages/client/src/components/MkFolder.vue        |  159 --
 packages/client/src/components/MkFollowButton.vue  |  187 --
 .../client/src/components/MkForgotPassword.vue     |   80 -
 packages/client/src/components/MkFormDialog.vue    |  127 --
 packages/client/src/components/MkFormula.vue       |   24 -
 packages/client/src/components/MkFormulaCore.vue   |   34 -
 .../client/src/components/MkGalleryPostPreview.vue |  115 --
 packages/client/src/components/MkGoogle.vue        |   51 -
 packages/client/src/components/MkImageViewer.vue   |   77 -
 .../client/src/components/MkImgWithBlurhash.vue    |   76 -
 packages/client/src/components/MkInfo.vue          |   34 -
 .../client/src/components/MkInstanceCardMini.vue   |  105 --
 packages/client/src/components/MkInstanceStats.vue |  255 ---
 .../client/src/components/MkInstanceTicker.vue     |   80 -
 packages/client/src/components/MkKeyValue.vue      |   58 -
 packages/client/src/components/MkLaunchPad.vue     |  138 --
 packages/client/src/components/MkLink.vue          |   47 -
 packages/client/src/components/MkMarquee.vue       |  106 --
 packages/client/src/components/MkMediaBanner.vue   |  102 --
 packages/client/src/components/MkMediaImage.vue    |  130 --
 packages/client/src/components/MkMediaList.vue     |  189 ---
 packages/client/src/components/MkMediaVideo.vue    |   88 -
 packages/client/src/components/MkMention.vue       |   66 -
 packages/client/src/components/MkMenu.child.vue    |   65 -
 packages/client/src/components/MkMenu.vue          |  367 ----
 packages/client/src/components/MkMiniChart.vue     |   73 -
 packages/client/src/components/MkModal.vue         |  406 -----
 .../client/src/components/MkModalPageWindow.vue    |  181 --
 packages/client/src/components/MkModalWindow.vue   |  146 --
 packages/client/src/components/MkNote.vue          |  658 --------
 packages/client/src/components/MkNoteDetailed.vue  |  677 --------
 packages/client/src/components/MkNoteHeader.vue    |   75 -
 packages/client/src/components/MkNotePreview.vue   |  112 --
 packages/client/src/components/MkNoteSimple.vue    |  119 --
 packages/client/src/components/MkNoteSub.vue       |  140 --
 packages/client/src/components/MkNotes.vue         |   58 -
 packages/client/src/components/MkNotification.vue  |  323 ----
 .../src/components/MkNotificationSettingWindow.vue |   87 -
 .../client/src/components/MkNotificationToast.vue  |   68 -
 packages/client/src/components/MkNotifications.vue |  104 --
 packages/client/src/components/MkNumberDiff.vue    |   47 -
 .../client/src/components/MkObjectView.value.vue   |  160 --
 packages/client/src/components/MkObjectView.vue    |   20 -
 packages/client/src/components/MkPagePreview.vue   |  162 --
 packages/client/src/components/MkPageWindow.vue    |  140 --
 packages/client/src/components/MkPagination.vue    |  317 ----
 packages/client/src/components/MkPoll.vue          |  152 --
 packages/client/src/components/MkPollEditor.vue    |  219 ---
 packages/client/src/components/MkPopupMenu.vue     |   36 -
 packages/client/src/components/MkPostForm.vue      | 1050 ------------
 .../client/src/components/MkPostFormAttaches.vue   |  168 --
 .../client/src/components/MkPostFormDialog.vue     |   19 -
 .../components/MkPushNotificationAllowButton.vue   |  167 --
 packages/client/src/components/MkReactionIcon.vue  |   13 -
 .../client/src/components/MkReactionTooltip.vue    |   43 -
 .../src/components/MkReactionsViewer.details.vue   |   96 --
 .../src/components/MkReactionsViewer.reaction.vue  |  135 --
 .../client/src/components/MkReactionsViewer.vue    |   36 -
 packages/client/src/components/MkRemoteCaution.vue |   25 -
 packages/client/src/components/MkRenoteButton.vue  |   99 --
 packages/client/src/components/MkRipple.vue        |  116 --
 packages/client/src/components/MkSample.vue        |  116 --
 packages/client/src/components/MkSignin.vue        |  259 ---
 packages/client/src/components/MkSigninDialog.vue  |   46 -
 packages/client/src/components/MkSignup.vue        |  246 ---
 packages/client/src/components/MkSignupDialog.vue  |   46 -
 packages/client/src/components/MkSparkle.vue       |  130 --
 .../client/src/components/MkSubNoteContent.vue     |   90 -
 packages/client/src/components/MkSuperMenu.vue     |  161 --
 packages/client/src/components/MkTab.vue           |   73 -
 packages/client/src/components/MkTagCloud.vue      |   90 -
 packages/client/src/components/MkTimeline.vue      |  143 --
 packages/client/src/components/MkToast.vue         |   66 -
 .../src/components/MkTokenGenerateWindow.vue       |   90 -
 packages/client/src/components/MkTooltip.vue       |  101 --
 packages/client/src/components/MkUpdated.vue       |   51 -
 packages/client/src/components/MkUrlPreview.vue    |  383 -----
 .../client/src/components/MkUrlPreviewPopup.vue    |   45 -
 packages/client/src/components/MkUserCardMini.vue  |   99 --
 packages/client/src/components/MkUserInfo.vue      |  137 --
 packages/client/src/components/MkUserList.vue      |   39 -
 .../src/components/MkUserOnlineIndicator.vue       |   45 -
 packages/client/src/components/MkUserPreview.vue   |  184 --
 .../client/src/components/MkUserSelectDialog.vue   |  190 ---
 packages/client/src/components/MkUsersTooltip.vue  |   51 -
 packages/client/src/components/MkVisibility.vue    |   48 -
 .../client/src/components/MkVisibilityPicker.vue   |  159 --
 packages/client/src/components/MkWaitingDialog.vue |   73 -
 packages/client/src/components/MkWidgets.vue       |  165 --
 packages/client/src/components/MkWindow.vue        |  571 -------
 packages/client/src/components/MkYoutubePlayer.vue |   72 -
 packages/client/src/components/form/checkbox.vue   |  144 --
 packages/client/src/components/form/folder.vue     |  107 --
 packages/client/src/components/form/input.vue      |  263 ---
 packages/client/src/components/form/link.vue       |   95 --
 packages/client/src/components/form/radio.vue      |  132 --
 packages/client/src/components/form/radios.vue     |   83 -
 packages/client/src/components/form/range.vue      |  259 ---
 packages/client/src/components/form/section.vue    |   43 -
 packages/client/src/components/form/select.vue     |  279 ---
 packages/client/src/components/form/slot.vue       |   41 -
 packages/client/src/components/form/split.vue      |   27 -
 packages/client/src/components/form/suspense.vue   |   98 --
 packages/client/src/components/form/switch.vue     |  144 --
 packages/client/src/components/form/textarea.vue   |  260 ---
 packages/client/src/components/global/MkA.vue      |  102 --
 packages/client/src/components/global/MkAcct.vue   |   27 -
 packages/client/src/components/global/MkAd.vue     |  186 --
 packages/client/src/components/global/MkAvatar.vue |  143 --
 .../client/src/components/global/MkEllipsis.vue    |   34 -
 packages/client/src/components/global/MkEmoji.vue  |   81 -
 packages/client/src/components/global/MkError.vue  |   36 -
 .../client/src/components/global/MkLoading.vue     |  101 --
 .../global/MkMisskeyFlavoredMarkdown.vue           |  191 ---
 .../client/src/components/global/MkPageHeader.vue  |  368 ----
 packages/client/src/components/global/MkSpacer.vue |   96 --
 .../src/components/global/MkStickyContainer.vue    |   66 -
 packages/client/src/components/global/MkTime.vue   |   56 -
 packages/client/src/components/global/MkUrl.vue    |   89 -
 .../client/src/components/global/MkUserName.vue    |   15 -
 .../client/src/components/global/RouterView.vue    |   61 -
 packages/client/src/components/global/i18n.ts      |   42 -
 packages/client/src/components/index.ts            |   61 -
 packages/client/src/components/mfm.ts              |  331 ----
 packages/client/src/components/page/page.block.vue |   44 -
 .../client/src/components/page/page.button.vue     |   66 -
 .../client/src/components/page/page.canvas.vue     |   49 -
 .../client/src/components/page/page.counter.vue    |   52 -
 packages/client/src/components/page/page.if.vue    |   31 -
 packages/client/src/components/page/page.image.vue |   28 -
 packages/client/src/components/page/page.note.vue  |   47 -
 .../src/components/page/page.number-input.vue      |   55 -
 packages/client/src/components/page/page.post.vue  |  109 --
 .../src/components/page/page.radio-button.vue      |   45 -
 .../client/src/components/page/page.section.vue    |   60 -
 .../client/src/components/page/page.switch.vue     |   55 -
 .../client/src/components/page/page.text-input.vue |   55 -
 packages/client/src/components/page/page.text.vue  |   68 -
 .../src/components/page/page.textarea-input.vue    |   47 -
 .../client/src/components/page/page.textarea.vue   |   39 -
 packages/client/src/components/page/page.vue       |   85 -
 packages/client/src/config.ts                      |   15 -
 packages/client/src/const.ts                       |   45 -
 packages/client/src/directives/adaptive-border.ts  |   24 -
 packages/client/src/directives/anim.ts             |   18 -
 packages/client/src/directives/appear.ts           |   22 -
 packages/client/src/directives/click-anime.ts      |   31 -
 packages/client/src/directives/follow-append.ts    |   35 -
 packages/client/src/directives/get-size.ts         |   54 -
 packages/client/src/directives/hotkey.ts           |   24 -
 packages/client/src/directives/index.ts            |   28 -
 packages/client/src/directives/panel.ts            |   24 -
 packages/client/src/directives/ripple.ts           |   18 -
 packages/client/src/directives/size.ts             |  123 --
 packages/client/src/directives/tooltip.ts          |   93 -
 packages/client/src/directives/user-preview.ts     |  118 --
 packages/client/src/emojilist.json                 | 1785 --------------------
 packages/client/src/events.ts                      |    4 -
 packages/client/src/filters/bytes.ts               |    9 -
 packages/client/src/filters/note.ts                |    3 -
 packages/client/src/filters/number.ts              |    1 -
 packages/client/src/filters/user.ts                |   15 -
 packages/client/src/i18n.ts                        |    5 -
 packages/client/src/init.ts                        |  433 -----
 packages/client/src/instance.ts                    |   45 -
 packages/client/src/navbar.ts                      |  135 --
 packages/client/src/nirax.ts                       |  275 ---
 packages/client/src/os.ts                          |  588 -------
 packages/client/src/pages/_empty_.vue              |    7 -
 packages/client/src/pages/_error_.vue              |   89 -
 packages/client/src/pages/_loading_.vue            |    6 -
 packages/client/src/pages/about-misskey.vue        |  264 ---
 packages/client/src/pages/about.emojis.vue         |  134 --
 packages/client/src/pages/about.federation.vue     |  106 --
 packages/client/src/pages/about.vue                |  166 --
 packages/client/src/pages/admin-file.vue           |  160 --
 packages/client/src/pages/admin/_header_.vue       |  292 ----
 packages/client/src/pages/admin/abuses.vue         |   97 --
 packages/client/src/pages/admin/ads.vue            |  132 --
 packages/client/src/pages/admin/announcements.vue  |  112 --
 packages/client/src/pages/admin/bot-protection.vue |  109 --
 packages/client/src/pages/admin/database.vue       |   35 -
 packages/client/src/pages/admin/email-settings.vue |  126 --
 .../client/src/pages/admin/emoji-edit-dialog.vue   |  106 --
 packages/client/src/pages/admin/emojis.vue         |  398 -----
 packages/client/src/pages/admin/files.vue          |  120 --
 packages/client/src/pages/admin/index.vue          |  316 ----
 packages/client/src/pages/admin/instance-block.vue |   51 -
 .../src/pages/admin/integrations.discord.vue       |   60 -
 .../client/src/pages/admin/integrations.github.vue |   60 -
 .../src/pages/admin/integrations.twitter.vue       |   60 -
 packages/client/src/pages/admin/integrations.vue   |   57 -
 packages/client/src/pages/admin/metrics.vue        |  472 ------
 packages/client/src/pages/admin/object-storage.vue |  148 --
 packages/client/src/pages/admin/other-settings.vue |   44 -
 .../src/pages/admin/overview.active-users.vue      |  217 ---
 .../src/pages/admin/overview.ap-requests.vue       |  346 ----
 .../client/src/pages/admin/overview.federation.vue |  185 --
 .../client/src/pages/admin/overview.heatmap.vue    |   15 -
 .../client/src/pages/admin/overview.instances.vue  |   50 -
 .../client/src/pages/admin/overview.moderators.vue |   55 -
 packages/client/src/pages/admin/overview.pie.vue   |  110 --
 .../src/pages/admin/overview.queue.chart.vue       |  186 --
 packages/client/src/pages/admin/overview.queue.vue |  127 --
 .../client/src/pages/admin/overview.retention.vue  |   49 -
 packages/client/src/pages/admin/overview.stats.vue |  155 --
 packages/client/src/pages/admin/overview.users.vue |   57 -
 packages/client/src/pages/admin/overview.vue       |  190 ---
 packages/client/src/pages/admin/proxy-account.vue  |   62 -
 .../client/src/pages/admin/queue.chart.chart.vue   |  186 --
 packages/client/src/pages/admin/queue.chart.vue    |  149 --
 packages/client/src/pages/admin/queue.vue          |   56 -
 packages/client/src/pages/admin/relays.vue         |  103 --
 packages/client/src/pages/admin/security.vue       |  179 --
 packages/client/src/pages/admin/settings.vue       |  262 ---
 packages/client/src/pages/admin/users.vue          |  170 --
 packages/client/src/pages/announcements.vue        |   69 -
 packages/client/src/pages/antenna-timeline.vue     |  128 --
 packages/client/src/pages/api-console.vue          |   89 -
 packages/client/src/pages/auth.form.vue            |   60 -
 packages/client/src/pages/auth.vue                 |   91 -
 packages/client/src/pages/channel-editor.vue       |  122 --
 packages/client/src/pages/channel.vue              |  184 --
 packages/client/src/pages/channels.vue             |   79 -
 packages/client/src/pages/clip.vue                 |  129 --
 packages/client/src/pages/drive.vue                |   25 -
 packages/client/src/pages/emojis.emoji.vue         |   72 -
 packages/client/src/pages/explore.featured.vue     |   30 -
 packages/client/src/pages/explore.users.vue        |  148 --
 packages/client/src/pages/explore.vue              |   87 -
 packages/client/src/pages/favorites.vue            |   49 -
 packages/client/src/pages/follow-requests.vue      |  153 --
 packages/client/src/pages/follow.vue               |   62 -
 packages/client/src/pages/gallery/edit.vue         |  149 --
 packages/client/src/pages/gallery/index.vue        |  139 --
 packages/client/src/pages/gallery/post.vue         |  265 ---
 packages/client/src/pages/instance-info.vue        |  258 ---
 packages/client/src/pages/messaging/index.vue      |  327 ----
 .../src/pages/messaging/messaging-room.form.vue    |  364 ----
 .../src/pages/messaging/messaging-room.message.vue |  367 ----
 .../client/src/pages/messaging/messaging-room.vue  |  411 -----
 packages/client/src/pages/mfm-cheat-sheet.vue      |  387 -----
 packages/client/src/pages/miauth.vue               |   90 -
 packages/client/src/pages/my-antennas/create.vue   |   46 -
 packages/client/src/pages/my-antennas/edit.vue     |   43 -
 packages/client/src/pages/my-antennas/editor.vue   |  155 --
 packages/client/src/pages/my-antennas/index.vue    |   64 -
 packages/client/src/pages/my-clips/index.vue       |  100 --
 packages/client/src/pages/my-lists/index.vue       |   82 -
 packages/client/src/pages/my-lists/list.vue        |  162 --
 packages/client/src/pages/not-found.vue            |   22 -
 packages/client/src/pages/note.vue                 |  206 ---
 packages/client/src/pages/notifications.vue        |   95 --
 .../pages/page-editor/els/page-editor.el.image.vue |   63 -
 .../pages/page-editor/els/page-editor.el.note.vue  |   57 -
 .../page-editor/els/page-editor.el.section.vue     |   97 --
 .../pages/page-editor/els/page-editor.el.text.vue  |   54 -
 .../src/pages/page-editor/page-editor.blocks.vue   |   65 -
 .../pages/page-editor/page-editor.container.vue    |  155 --
 .../client/src/pages/page-editor/page-editor.vue   |  394 -----
 packages/client/src/pages/page.vue                 |  277 ---
 packages/client/src/pages/pages.vue                |   99 --
 packages/client/src/pages/preview.vue              |   27 -
 packages/client/src/pages/registry.keys.vue        |   96 --
 packages/client/src/pages/registry.value.vue       |  123 --
 packages/client/src/pages/registry.vue             |   74 -
 packages/client/src/pages/reset-password.vue       |   59 -
 packages/client/src/pages/scratchpad.vue           |  137 --
 packages/client/src/pages/search.vue               |   38 -
 packages/client/src/pages/settings/2fa.vue         |  216 ---
 .../client/src/pages/settings/account-info.vue     |  158 --
 packages/client/src/pages/settings/accounts.vue    |  143 --
 packages/client/src/pages/settings/api.vue         |   46 -
 packages/client/src/pages/settings/apps.vue        |   96 --
 packages/client/src/pages/settings/custom-css.vue  |   46 -
 packages/client/src/pages/settings/deck.vue        |   39 -
 .../client/src/pages/settings/delete-account.vue   |   52 -
 packages/client/src/pages/settings/drive.vue       |  145 --
 packages/client/src/pages/settings/email.vue       |  111 --
 packages/client/src/pages/settings/general.vue     |  196 ---
 .../client/src/pages/settings/import-export.vue    |  165 --
 packages/client/src/pages/settings/index.vue       |  291 ----
 .../client/src/pages/settings/instance-mute.vue    |   53 -
 packages/client/src/pages/settings/integration.vue |   99 --
 packages/client/src/pages/settings/mute-block.vue  |   61 -
 packages/client/src/pages/settings/navbar.vue      |   87 -
 .../client/src/pages/settings/notifications.vue    |   90 -
 packages/client/src/pages/settings/other.vue       |   47 -
 .../client/src/pages/settings/plugin.install.vue   |  124 --
 packages/client/src/pages/settings/plugin.vue      |   98 --
 .../src/pages/settings/preferences-backups.vue     |  444 -----
 packages/client/src/pages/settings/privacy.vue     |  100 --
 packages/client/src/pages/settings/profile.vue     |  220 ---
 packages/client/src/pages/settings/reaction.vue    |  154 --
 packages/client/src/pages/settings/security.vue    |  160 --
 .../client/src/pages/settings/sounds.sound.vue     |   45 -
 packages/client/src/pages/settings/sounds.vue      |   82 -
 .../src/pages/settings/statusbar.statusbar.vue     |  140 --
 packages/client/src/pages/settings/statusbar.vue   |   54 -
 .../client/src/pages/settings/theme.install.vue    |   80 -
 .../client/src/pages/settings/theme.manage.vue     |   78 -
 packages/client/src/pages/settings/theme.vue       |  409 -----
 .../client/src/pages/settings/webhook.edit.vue     |   95 --
 packages/client/src/pages/settings/webhook.new.vue |   82 -
 packages/client/src/pages/settings/webhook.vue     |   53 -
 packages/client/src/pages/settings/word-mute.vue   |  128 --
 packages/client/src/pages/share.vue                |  169 --
 packages/client/src/pages/signup-complete.vue      |   41 -
 packages/client/src/pages/tag.vue                  |   35 -
 packages/client/src/pages/theme-editor.vue         |  283 ----
 packages/client/src/pages/timeline.tutorial.vue    |  142 --
 packages/client/src/pages/timeline.vue             |  183 --
 packages/client/src/pages/user-info.vue            |  485 ------
 packages/client/src/pages/user-list-timeline.vue   |  121 --
 packages/client/src/pages/user/clips.vue           |   47 -
 packages/client/src/pages/user/follow-list.vue     |   47 -
 packages/client/src/pages/user/followers.vue       |   61 -
 packages/client/src/pages/user/following.vue       |   61 -
 packages/client/src/pages/user/gallery.vue         |   38 -
 packages/client/src/pages/user/home.vue            |  530 ------
 packages/client/src/pages/user/index.activity.vue  |   52 -
 packages/client/src/pages/user/index.photos.vue    |  102 --
 packages/client/src/pages/user/index.timeline.vue  |   45 -
 packages/client/src/pages/user/index.vue           |  113 --
 packages/client/src/pages/user/pages.vue           |   30 -
 packages/client/src/pages/user/reactions.vue       |   61 -
 packages/client/src/pages/welcome.entrance.a.vue   |  309 ----
 packages/client/src/pages/welcome.entrance.b.vue   |  237 ---
 packages/client/src/pages/welcome.entrance.c.vue   |  306 ----
 packages/client/src/pages/welcome.setup.vue        |   89 -
 packages/client/src/pages/welcome.timeline.vue     |   99 --
 packages/client/src/pages/welcome.vue              |   30 -
 packages/client/src/pizzax.ts                      |  169 --
 packages/client/src/plugin.ts                      |  123 --
 packages/client/src/router.ts                      |  501 ------
 packages/client/src/scripts/2fa.ts                 |   33 -
 packages/client/src/scripts/aiscript/api.ts        |   43 -
 packages/client/src/scripts/array.ts               |  149 --
 packages/client/src/scripts/autocomplete.ts        |  276 ---
 packages/client/src/scripts/chart-vline.ts         |   21 -
 packages/client/src/scripts/check-word-mute.ts     |   37 -
 packages/client/src/scripts/clone.ts               |   18 -
 packages/client/src/scripts/collect-page-vars.ts   |   68 -
 packages/client/src/scripts/contains.ts            |    9 -
 packages/client/src/scripts/copy-to-clipboard.ts   |   33 -
 packages/client/src/scripts/device-kind.ts         |   10 -
 packages/client/src/scripts/emoji-base.ts          |   20 -
 packages/client/src/scripts/emojilist.ts           |   17 -
 .../src/scripts/extract-avg-color-from-blurhash.ts |    9 -
 packages/client/src/scripts/extract-mentions.ts    |   11 -
 .../client/src/scripts/extract-url-from-mfm.ts     |   19 -
 packages/client/src/scripts/focus.ts               |   27 -
 packages/client/src/scripts/form.ts                |   59 -
 packages/client/src/scripts/format-time-string.ts  |   50 -
 packages/client/src/scripts/gen-search-query.ts    |   30 -
 packages/client/src/scripts/get-account-from-id.ts |    7 -
 packages/client/src/scripts/get-note-menu.ts       |  341 ----
 packages/client/src/scripts/get-note-summary.ts    |   55 -
 .../client/src/scripts/get-static-image-url.ts     |   19 -
 packages/client/src/scripts/get-user-menu.ts       |  253 ---
 packages/client/src/scripts/get-user-name.ts       |    3 -
 packages/client/src/scripts/hotkey.ts              |   90 -
 packages/client/src/scripts/hpml/block.ts          |  109 --
 packages/client/src/scripts/hpml/evaluator.ts      |  232 ---
 packages/client/src/scripts/hpml/expr.ts           |   79 -
 packages/client/src/scripts/hpml/index.ts          |  103 --
 packages/client/src/scripts/hpml/lib.ts            |  247 ---
 packages/client/src/scripts/hpml/type-checker.ts   |  191 ---
 packages/client/src/scripts/i18n.ts                |   29 -
 packages/client/src/scripts/idb-proxy.ts           |   36 -
 packages/client/src/scripts/initialize-sw.ts       |   13 -
 packages/client/src/scripts/is-device-darkmode.ts  |    3 -
 packages/client/src/scripts/keycode.ts             |   33 -
 packages/client/src/scripts/langmap.ts             |  666 --------
 packages/client/src/scripts/login-id.ts            |   11 -
 packages/client/src/scripts/lookup-user.ts         |   36 -
 packages/client/src/scripts/media-proxy.ts         |   15 -
 packages/client/src/scripts/mfm-tags.ts            |    1 -
 packages/client/src/scripts/page-metadata.ts       |   41 -
 packages/client/src/scripts/physics.ts             |  152 --
 packages/client/src/scripts/please-login.ts        |   21 -
 packages/client/src/scripts/popout.ts              |   23 -
 packages/client/src/scripts/popup-position.ts      |  158 --
 packages/client/src/scripts/reaction-picker.ts     |   41 -
 packages/client/src/scripts/safe-uri-decode.ts     |    7 -
 packages/client/src/scripts/scroll.ts              |   85 -
 packages/client/src/scripts/search.ts              |   63 -
 packages/client/src/scripts/select-file.ts         |  103 --
 .../client/src/scripts/show-suspended-dialog.ts    |   10 -
 packages/client/src/scripts/shuffle.ts             |   19 -
 packages/client/src/scripts/sound.ts               |   66 -
 packages/client/src/scripts/sticky-sidebar.ts      |   50 -
 packages/client/src/scripts/theme-editor.ts        |   81 -
 packages/client/src/scripts/theme.ts               |  148 --
 packages/client/src/scripts/time.ts                |   39 -
 packages/client/src/scripts/timezones.ts           |   49 -
 packages/client/src/scripts/touch.ts               |   23 -
 packages/client/src/scripts/unison-reload.ts       |   15 -
 packages/client/src/scripts/upload.ts              |  137 --
 .../client/src/scripts/upload/compress-config.ts   |   23 -
 packages/client/src/scripts/url.ts                 |   13 -
 packages/client/src/scripts/use-chart-tooltip.ts   |   54 -
 packages/client/src/scripts/use-interval.ts        |   24 -
 packages/client/src/scripts/use-leave-guard.ts     |   47 -
 packages/client/src/scripts/use-note-capture.ts    |  110 --
 packages/client/src/scripts/use-tooltip.ts         |   86 -
 packages/client/src/store.ts                       |  383 -----
 packages/client/src/stream.ts                      |    8 -
 packages/client/src/style.scss                     |  584 -------
 packages/client/src/theme-store.ts                 |   34 -
 packages/client/src/themes/_dark.json5             |   99 --
 packages/client/src/themes/_light.json5            |   99 --
 packages/client/src/themes/d-astro.json5           |   78 -
 packages/client/src/themes/d-botanical.json5       |   26 -
 packages/client/src/themes/d-cherry.json5          |   20 -
 packages/client/src/themes/d-dark.json5            |   26 -
 packages/client/src/themes/d-future.json5          |   27 -
 packages/client/src/themes/d-green-lime.json5      |   24 -
 packages/client/src/themes/d-green-orange.json5    |   24 -
 packages/client/src/themes/d-ice.json5             |   13 -
 packages/client/src/themes/d-persimmon.json5       |   25 -
 packages/client/src/themes/d-u0.json5              |   88 -
 packages/client/src/themes/l-apricot.json5         |   22 -
 packages/client/src/themes/l-cherry.json5          |   21 -
 packages/client/src/themes/l-coffee.json5          |   21 -
 packages/client/src/themes/l-light.json5           |   20 -
 packages/client/src/themes/l-rainy.json5           |   21 -
 packages/client/src/themes/l-sushi.json5           |   18 -
 packages/client/src/themes/l-u0.json5              |   87 -
 packages/client/src/themes/l-vivid.json5           |   82 -
 packages/client/src/types/menu.ts                  |   21 -
 packages/client/src/ui/_common_/common.vue         |  139 --
 .../client/src/ui/_common_/navbar-for-mobile.vue   |  314 ----
 packages/client/src/ui/_common_/navbar.vue         |  521 ------
 .../src/ui/_common_/statusbar-federation.vue       |  108 --
 packages/client/src/ui/_common_/statusbar-rss.vue  |   93 -
 .../client/src/ui/_common_/statusbar-user-list.vue |  113 --
 packages/client/src/ui/_common_/statusbars.vue     |   92 -
 .../client/src/ui/_common_/stream-indicator.vue    |   61 -
 packages/client/src/ui/_common_/sw-inject.ts       |   35 -
 packages/client/src/ui/_common_/upload.vue         |  129 --
 packages/client/src/ui/classic.header.vue          |  217 ---
 packages/client/src/ui/classic.sidebar.vue         |  268 ---
 packages/client/src/ui/classic.vue                 |  320 ----
 packages/client/src/ui/classic.widgets.vue         |   84 -
 packages/client/src/ui/deck.vue                    |  435 -----
 packages/client/src/ui/deck/antenna-column.vue     |   70 -
 packages/client/src/ui/deck/column-core.vue        |   34 -
 packages/client/src/ui/deck/column.vue             |  398 -----
 packages/client/src/ui/deck/deck-store.ts          |  296 ----
 packages/client/src/ui/deck/direct-column.vue      |   31 -
 packages/client/src/ui/deck/list-column.vue        |   58 -
 packages/client/src/ui/deck/main-column.vue        |   68 -
 packages/client/src/ui/deck/mentions-column.vue    |   28 -
 .../client/src/ui/deck/notifications-column.vue    |   44 -
 packages/client/src/ui/deck/tl-column.vue          |  119 --
 packages/client/src/ui/deck/widgets-column.vue     |   69 -
 packages/client/src/ui/universal.vue               |  390 -----
 packages/client/src/ui/universal.widgets.vue       |   71 -
 packages/client/src/ui/visitor.vue                 |   19 -
 packages/client/src/ui/visitor/a.vue               |  259 ---
 packages/client/src/ui/visitor/b.vue               |  248 ---
 packages/client/src/ui/visitor/header.vue          |  228 ---
 packages/client/src/ui/visitor/kanban.vue          |  257 ---
 packages/client/src/ui/zen.vue                     |   34 -
 packages/client/src/widgets/activity.calendar.vue  |   81 -
 packages/client/src/widgets/activity.chart.vue     |   92 -
 packages/client/src/widgets/activity.vue           |   90 -
 packages/client/src/widgets/aichan.vue             |   74 -
 packages/client/src/widgets/aiscript.vue           |  175 --
 packages/client/src/widgets/button.vue             |  103 --
 packages/client/src/widgets/calendar.vue           |  213 ---
 packages/client/src/widgets/clock.vue              |  203 ---
 packages/client/src/widgets/digital-clock.vue      |   92 -
 packages/client/src/widgets/federation.vue         |  147 --
 packages/client/src/widgets/index.ts               |   53 -
 packages/client/src/widgets/instance-cloud.vue     |   81 -
 packages/client/src/widgets/job-queue.vue          |  197 ---
 packages/client/src/widgets/memo.vue               |  111 --
 packages/client/src/widgets/notifications.vue      |   70 -
 packages/client/src/widgets/online-users.vue       |   78 -
 packages/client/src/widgets/photos.vue             |  123 --
 packages/client/src/widgets/post-form.vue          |   35 -
 packages/client/src/widgets/rss-ticker.vue         |  152 --
 packages/client/src/widgets/rss.vue                |   96 --
 .../client/src/widgets/server-metric/cpu-mem.vue   |  167 --
 packages/client/src/widgets/server-metric/cpu.vue  |   65 -
 packages/client/src/widgets/server-metric/disk.vue |   57 -
 .../client/src/widgets/server-metric/index.vue     |   87 -
 packages/client/src/widgets/server-metric/mem.vue  |   73 -
 packages/client/src/widgets/server-metric/net.vue  |  140 --
 packages/client/src/widgets/server-metric/pie.vue  |   52 -
 packages/client/src/widgets/slideshow.vue          |  159 --
 packages/client/src/widgets/timeline.vue           |  129 --
 packages/client/src/widgets/trends.vue             |  120 --
 packages/client/src/widgets/unix-clock.vue         |  116 --
 packages/client/src/widgets/user-list.vue          |  136 --
 packages/client/src/widgets/widget.ts              |   73 -
 packages/client/tsconfig.json                      |   47 -
 packages/client/vite.config.ts                     |   70 -
 packages/client/vite.json5.ts                      |   38 -
 packages/frontend/.eslintrc.js                     |   89 +
 packages/frontend/.vscode/settings.json            |   11 +
 packages/frontend/@types/global.d.ts               |   10 +
 packages/frontend/@types/theme.d.ts                |    7 +
 packages/frontend/@types/vue.d.ts                  |   16 +
 packages/frontend/assets/about-icon.png            |  Bin 0 -> 20528 bytes
 packages/frontend/assets/dummy.png                 |  Bin 0 -> 6285 bytes
 packages/frontend/assets/fedi.jpg                  |  Bin 0 -> 77752 bytes
 packages/frontend/assets/label-red.svg             |    6 +
 packages/frontend/assets/label.svg                 |    6 +
 packages/frontend/assets/misskey.svg               |    7 +
 packages/frontend/assets/remove.png                |  Bin 0 -> 424 bytes
 packages/frontend/assets/sounds/aisha/1.mp3        |  Bin 0 -> 34480 bytes
 packages/frontend/assets/sounds/aisha/2.mp3        |  Bin 0 -> 24031 bytes
 packages/frontend/assets/sounds/aisha/3.mp3        |  Bin 0 -> 29256 bytes
 .../assets/sounds/noizenecio/kick_gaba1.mp3        |  Bin 0 -> 18866 bytes
 .../assets/sounds/noizenecio/kick_gaba2.mp3        |  Bin 0 -> 27144 bytes
 .../assets/sounds/noizenecio/kick_gaba3.mp3        |  Bin 0 -> 19853 bytes
 .../assets/sounds/noizenecio/kick_gaba4.mp3        |  Bin 0 -> 19853 bytes
 .../assets/sounds/noizenecio/kick_gaba5.mp3        |  Bin 0 -> 19853 bytes
 .../assets/sounds/noizenecio/kick_gaba6.mp3        |  Bin 0 -> 19853 bytes
 .../assets/sounds/noizenecio/kick_gaba7.mp3        |  Bin 0 -> 19853 bytes
 packages/frontend/assets/sounds/syuilo/down.mp3    |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/kick.mp3    |  Bin 0 -> 15672 bytes
 .../assets/sounds/syuilo/pirori-square-wet.mp3     |  Bin 0 -> 139200 bytes
 .../frontend/assets/sounds/syuilo/pirori-wet.mp3   |  Bin 0 -> 139200 bytes
 packages/frontend/assets/sounds/syuilo/pirori.mp3  |  Bin 0 -> 19200 bytes
 packages/frontend/assets/sounds/syuilo/poi1.mp3    |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/poi2.mp3    |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/pope1.mp3   |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/pope2.mp3   |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/popo.mp3    |  Bin 0 -> 18240 bytes
 .../frontend/assets/sounds/syuilo/queue-jammed.mp3 |  Bin 0 -> 351466 bytes
 .../frontend/assets/sounds/syuilo/reverved.mp3     |  Bin 0 -> 276480 bytes
 packages/frontend/assets/sounds/syuilo/ryukyu.mp3  |  Bin 0 -> 139200 bytes
 packages/frontend/assets/sounds/syuilo/snare.mp3   |  Bin 0 -> 26121 bytes
 .../frontend/assets/sounds/syuilo/square-pico.mp3  |  Bin 0 -> 19200 bytes
 packages/frontend/assets/sounds/syuilo/triple.mp3  |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/up.mp3      |  Bin 0 -> 18240 bytes
 packages/frontend/assets/sounds/syuilo/waon.mp3    |  Bin 0 -> 18240 bytes
 packages/frontend/assets/tagcanvas.min.js          |   21 +
 packages/frontend/assets/unread.svg                |    7 +
 packages/frontend/package.json                     |   94 ++
 packages/frontend/src/account.ts                   |  238 +++
 packages/frontend/src/components/MkAbuseReport.vue |  109 ++
 .../src/components/MkAbuseReportWindow.vue         |   65 +
 .../src/components/MkActiveUsersHeatmap.vue        |  236 +++
 packages/frontend/src/components/MkAnalogClock.vue |  225 +++
 .../frontend/src/components/MkAutocomplete.vue     |  476 ++++++
 packages/frontend/src/components/MkAvatars.vue     |   24 +
 packages/frontend/src/components/MkButton.vue      |  227 +++
 packages/frontend/src/components/MkCaptcha.vue     |  118 ++
 .../src/components/MkChannelFollowButton.vue       |  129 ++
 .../frontend/src/components/MkChannelPreview.vue   |  154 ++
 packages/frontend/src/components/MkChart.vue       |  859 ++++++++++
 .../frontend/src/components/MkChartTooltip.vue     |   53 +
 packages/frontend/src/components/MkCode.core.vue   |   20 +
 packages/frontend/src/components/MkCode.vue        |   15 +
 packages/frontend/src/components/MkContainer.vue   |  275 +++
 packages/frontend/src/components/MkContextMenu.vue |   85 +
 .../frontend/src/components/MkCropperDialog.vue    |  174 ++
 packages/frontend/src/components/MkCwButton.vue    |   62 +
 .../src/components/MkDateSeparatedList.vue         |  189 +++
 packages/frontend/src/components/MkDialog.vue      |  208 +++
 .../frontend/src/components/MkDigitalClock.vue     |   77 +
 packages/frontend/src/components/MkDrive.file.vue  |  334 ++++
 .../frontend/src/components/MkDrive.folder.vue     |  330 ++++
 .../frontend/src/components/MkDrive.navFolder.vue  |  147 ++
 packages/frontend/src/components/MkDrive.vue       |  801 +++++++++
 .../src/components/MkDriveFileThumbnail.vue        |   80 +
 .../src/components/MkDriveSelectDialog.vue         |   58 +
 packages/frontend/src/components/MkDriveWindow.vue |   30 +
 .../src/components/MkEmojiPicker.section.vue       |   36 +
 packages/frontend/src/components/MkEmojiPicker.vue |  569 +++++++
 .../src/components/MkEmojiPickerDialog.vue         |   73 +
 .../src/components/MkEmojiPickerWindow.vue         |  180 ++
 .../frontend/src/components/MkFeaturedPhotos.vue   |   22 +
 .../src/components/MkFileCaptionEditWindow.vue     |  175 ++
 .../frontend/src/components/MkFileListForAdmin.vue |  117 ++
 packages/frontend/src/components/MkFolder.vue      |  159 ++
 .../frontend/src/components/MkFollowButton.vue     |  187 ++
 .../frontend/src/components/MkForgotPassword.vue   |   80 +
 packages/frontend/src/components/MkFormDialog.vue  |  127 ++
 packages/frontend/src/components/MkFormula.vue     |   24 +
 packages/frontend/src/components/MkFormulaCore.vue |   34 +
 .../src/components/MkGalleryPostPreview.vue        |  115 ++
 packages/frontend/src/components/MkGoogle.vue      |   51 +
 packages/frontend/src/components/MkImageViewer.vue |   77 +
 .../frontend/src/components/MkImgWithBlurhash.vue  |   76 +
 packages/frontend/src/components/MkInfo.vue        |   34 +
 .../frontend/src/components/MkInstanceCardMini.vue |  105 ++
 .../frontend/src/components/MkInstanceStats.vue    |  255 +++
 .../frontend/src/components/MkInstanceTicker.vue   |   80 +
 packages/frontend/src/components/MkKeyValue.vue    |   58 +
 packages/frontend/src/components/MkLaunchPad.vue   |  138 ++
 packages/frontend/src/components/MkLink.vue        |   47 +
 packages/frontend/src/components/MkMarquee.vue     |  106 ++
 packages/frontend/src/components/MkMediaBanner.vue |  102 ++
 packages/frontend/src/components/MkMediaImage.vue  |  130 ++
 packages/frontend/src/components/MkMediaList.vue   |  189 +++
 packages/frontend/src/components/MkMediaVideo.vue  |   88 +
 packages/frontend/src/components/MkMention.vue     |   66 +
 packages/frontend/src/components/MkMenu.child.vue  |   65 +
 packages/frontend/src/components/MkMenu.vue        |  367 ++++
 packages/frontend/src/components/MkMiniChart.vue   |   73 +
 packages/frontend/src/components/MkModal.vue       |  406 +++++
 .../frontend/src/components/MkModalPageWindow.vue  |  181 ++
 packages/frontend/src/components/MkModalWindow.vue |  146 ++
 packages/frontend/src/components/MkNote.vue        |  658 ++++++++
 .../frontend/src/components/MkNoteDetailed.vue     |  677 ++++++++
 packages/frontend/src/components/MkNoteHeader.vue  |   75 +
 packages/frontend/src/components/MkNotePreview.vue |  112 ++
 packages/frontend/src/components/MkNoteSimple.vue  |  119 ++
 packages/frontend/src/components/MkNoteSub.vue     |  140 ++
 packages/frontend/src/components/MkNotes.vue       |   58 +
 .../frontend/src/components/MkNotification.vue     |  323 ++++
 .../src/components/MkNotificationSettingWindow.vue |   87 +
 .../src/components/MkNotificationToast.vue         |   68 +
 .../frontend/src/components/MkNotifications.vue    |  104 ++
 packages/frontend/src/components/MkNumberDiff.vue  |   47 +
 .../frontend/src/components/MkObjectView.value.vue |  160 ++
 packages/frontend/src/components/MkObjectView.vue  |   20 +
 packages/frontend/src/components/MkPagePreview.vue |  162 ++
 packages/frontend/src/components/MkPageWindow.vue  |  140 ++
 packages/frontend/src/components/MkPagination.vue  |  317 ++++
 packages/frontend/src/components/MkPoll.vue        |  152 ++
 packages/frontend/src/components/MkPollEditor.vue  |  219 +++
 packages/frontend/src/components/MkPopupMenu.vue   |   36 +
 packages/frontend/src/components/MkPostForm.vue    | 1050 ++++++++++++
 .../frontend/src/components/MkPostFormAttaches.vue |  168 ++
 .../frontend/src/components/MkPostFormDialog.vue   |   19 +
 .../components/MkPushNotificationAllowButton.vue   |  167 ++
 .../frontend/src/components/MkReactionIcon.vue     |   13 +
 .../frontend/src/components/MkReactionTooltip.vue  |   43 +
 .../src/components/MkReactionsViewer.details.vue   |   96 ++
 .../src/components/MkReactionsViewer.reaction.vue  |  135 ++
 .../frontend/src/components/MkReactionsViewer.vue  |   36 +
 .../frontend/src/components/MkRemoteCaution.vue    |   25 +
 .../frontend/src/components/MkRenoteButton.vue     |   99 ++
 packages/frontend/src/components/MkRipple.vue      |  116 ++
 packages/frontend/src/components/MkSample.vue      |  116 ++
 packages/frontend/src/components/MkSignin.vue      |  259 +++
 .../frontend/src/components/MkSigninDialog.vue     |   46 +
 packages/frontend/src/components/MkSignup.vue      |  246 +++
 .../frontend/src/components/MkSignupDialog.vue     |   46 +
 packages/frontend/src/components/MkSparkle.vue     |  130 ++
 .../frontend/src/components/MkSubNoteContent.vue   |   90 +
 packages/frontend/src/components/MkSuperMenu.vue   |  161 ++
 packages/frontend/src/components/MkTab.vue         |   73 +
 packages/frontend/src/components/MkTagCloud.vue    |   90 +
 packages/frontend/src/components/MkTimeline.vue    |  143 ++
 packages/frontend/src/components/MkToast.vue       |   66 +
 .../src/components/MkTokenGenerateWindow.vue       |   90 +
 packages/frontend/src/components/MkTooltip.vue     |  101 ++
 packages/frontend/src/components/MkUpdated.vue     |   51 +
 packages/frontend/src/components/MkUrlPreview.vue  |  383 +++++
 .../frontend/src/components/MkUrlPreviewPopup.vue  |   45 +
 .../frontend/src/components/MkUserCardMini.vue     |   99 ++
 packages/frontend/src/components/MkUserInfo.vue    |  137 ++
 packages/frontend/src/components/MkUserList.vue    |   39 +
 .../src/components/MkUserOnlineIndicator.vue       |   45 +
 packages/frontend/src/components/MkUserPreview.vue |  184 ++
 .../frontend/src/components/MkUserSelectDialog.vue |  190 +++
 .../frontend/src/components/MkUsersTooltip.vue     |   51 +
 packages/frontend/src/components/MkVisibility.vue  |   48 +
 .../frontend/src/components/MkVisibilityPicker.vue |  159 ++
 .../frontend/src/components/MkWaitingDialog.vue    |   73 +
 packages/frontend/src/components/MkWidgets.vue     |  165 ++
 packages/frontend/src/components/MkWindow.vue      |  571 +++++++
 .../frontend/src/components/MkYoutubePlayer.vue    |   72 +
 packages/frontend/src/components/form/checkbox.vue |  144 ++
 packages/frontend/src/components/form/folder.vue   |  107 ++
 packages/frontend/src/components/form/input.vue    |  263 +++
 packages/frontend/src/components/form/link.vue     |   95 ++
 packages/frontend/src/components/form/radio.vue    |  132 ++
 packages/frontend/src/components/form/radios.vue   |   83 +
 packages/frontend/src/components/form/range.vue    |  259 +++
 packages/frontend/src/components/form/section.vue  |   43 +
 packages/frontend/src/components/form/select.vue   |  279 +++
 packages/frontend/src/components/form/slot.vue     |   41 +
 packages/frontend/src/components/form/split.vue    |   27 +
 packages/frontend/src/components/form/suspense.vue |   98 ++
 packages/frontend/src/components/form/switch.vue   |  144 ++
 packages/frontend/src/components/form/textarea.vue |  260 +++
 packages/frontend/src/components/global/MkA.vue    |  102 ++
 packages/frontend/src/components/global/MkAcct.vue |   27 +
 packages/frontend/src/components/global/MkAd.vue   |  186 ++
 .../frontend/src/components/global/MkAvatar.vue    |  143 ++
 .../frontend/src/components/global/MkEllipsis.vue  |   34 +
 .../frontend/src/components/global/MkEmoji.vue     |   81 +
 .../frontend/src/components/global/MkError.vue     |   36 +
 .../frontend/src/components/global/MkLoading.vue   |  101 ++
 .../global/MkMisskeyFlavoredMarkdown.vue           |  191 +++
 .../src/components/global/MkPageHeader.vue         |  368 ++++
 .../frontend/src/components/global/MkSpacer.vue    |   96 ++
 .../src/components/global/MkStickyContainer.vue    |   66 +
 packages/frontend/src/components/global/MkTime.vue |   56 +
 packages/frontend/src/components/global/MkUrl.vue  |   89 +
 .../frontend/src/components/global/MkUserName.vue  |   15 +
 .../frontend/src/components/global/RouterView.vue  |   61 +
 packages/frontend/src/components/global/i18n.ts    |   42 +
 packages/frontend/src/components/index.ts          |   61 +
 packages/frontend/src/components/mfm.ts            |  331 ++++
 .../frontend/src/components/page/page.block.vue    |   44 +
 .../frontend/src/components/page/page.button.vue   |   66 +
 .../frontend/src/components/page/page.canvas.vue   |   49 +
 .../frontend/src/components/page/page.counter.vue  |   52 +
 packages/frontend/src/components/page/page.if.vue  |   31 +
 .../frontend/src/components/page/page.image.vue    |   28 +
 .../frontend/src/components/page/page.note.vue     |   47 +
 .../src/components/page/page.number-input.vue      |   55 +
 .../frontend/src/components/page/page.post.vue     |  109 ++
 .../src/components/page/page.radio-button.vue      |   45 +
 .../frontend/src/components/page/page.section.vue  |   60 +
 .../frontend/src/components/page/page.switch.vue   |   55 +
 .../src/components/page/page.text-input.vue        |   55 +
 .../frontend/src/components/page/page.text.vue     |   68 +
 .../src/components/page/page.textarea-input.vue    |   47 +
 .../frontend/src/components/page/page.textarea.vue |   39 +
 packages/frontend/src/components/page/page.vue     |   85 +
 packages/frontend/src/config.ts                    |   15 +
 packages/frontend/src/const.ts                     |   45 +
 .../frontend/src/directives/adaptive-border.ts     |   24 +
 packages/frontend/src/directives/anim.ts           |   18 +
 packages/frontend/src/directives/appear.ts         |   22 +
 packages/frontend/src/directives/click-anime.ts    |   31 +
 packages/frontend/src/directives/follow-append.ts  |   35 +
 packages/frontend/src/directives/get-size.ts       |   54 +
 packages/frontend/src/directives/hotkey.ts         |   24 +
 packages/frontend/src/directives/index.ts          |   28 +
 packages/frontend/src/directives/panel.ts          |   24 +
 packages/frontend/src/directives/ripple.ts         |   18 +
 packages/frontend/src/directives/size.ts           |  123 ++
 packages/frontend/src/directives/tooltip.ts        |   93 +
 packages/frontend/src/directives/user-preview.ts   |  118 ++
 packages/frontend/src/emojilist.json               | 1785 ++++++++++++++++++++
 packages/frontend/src/events.ts                    |    4 +
 packages/frontend/src/filters/bytes.ts             |    9 +
 packages/frontend/src/filters/note.ts              |    3 +
 packages/frontend/src/filters/number.ts            |    1 +
 packages/frontend/src/filters/user.ts              |   15 +
 packages/frontend/src/i18n.ts                      |    5 +
 packages/frontend/src/init.ts                      |  433 +++++
 packages/frontend/src/instance.ts                  |   45 +
 packages/frontend/src/navbar.ts                    |  135 ++
 packages/frontend/src/nirax.ts                     |  275 +++
 packages/frontend/src/os.ts                        |  588 +++++++
 packages/frontend/src/pages/_empty_.vue            |    7 +
 packages/frontend/src/pages/_error_.vue            |   89 +
 packages/frontend/src/pages/_loading_.vue          |    6 +
 packages/frontend/src/pages/about-misskey.vue      |  264 +++
 packages/frontend/src/pages/about.emojis.vue       |  134 ++
 packages/frontend/src/pages/about.federation.vue   |  106 ++
 packages/frontend/src/pages/about.vue              |  166 ++
 packages/frontend/src/pages/admin-file.vue         |  160 ++
 packages/frontend/src/pages/admin/_header_.vue     |  292 ++++
 packages/frontend/src/pages/admin/abuses.vue       |   97 ++
 packages/frontend/src/pages/admin/ads.vue          |  132 ++
 .../frontend/src/pages/admin/announcements.vue     |  112 ++
 .../frontend/src/pages/admin/bot-protection.vue    |  109 ++
 packages/frontend/src/pages/admin/database.vue     |   35 +
 .../frontend/src/pages/admin/email-settings.vue    |  126 ++
 .../frontend/src/pages/admin/emoji-edit-dialog.vue |  106 ++
 packages/frontend/src/pages/admin/emojis.vue       |  398 +++++
 packages/frontend/src/pages/admin/files.vue        |  120 ++
 packages/frontend/src/pages/admin/index.vue        |  316 ++++
 .../frontend/src/pages/admin/instance-block.vue    |   51 +
 .../src/pages/admin/integrations.discord.vue       |   60 +
 .../src/pages/admin/integrations.github.vue        |   60 +
 .../src/pages/admin/integrations.twitter.vue       |   60 +
 packages/frontend/src/pages/admin/integrations.vue |   57 +
 packages/frontend/src/pages/admin/metrics.vue      |  472 ++++++
 .../frontend/src/pages/admin/object-storage.vue    |  148 ++
 .../frontend/src/pages/admin/other-settings.vue    |   44 +
 .../src/pages/admin/overview.active-users.vue      |  217 +++
 .../src/pages/admin/overview.ap-requests.vue       |  346 ++++
 .../src/pages/admin/overview.federation.vue        |  185 ++
 .../frontend/src/pages/admin/overview.heatmap.vue  |   15 +
 .../src/pages/admin/overview.instances.vue         |   50 +
 .../src/pages/admin/overview.moderators.vue        |   55 +
 packages/frontend/src/pages/admin/overview.pie.vue |  110 ++
 .../src/pages/admin/overview.queue.chart.vue       |  186 ++
 .../frontend/src/pages/admin/overview.queue.vue    |  127 ++
 .../src/pages/admin/overview.retention.vue         |   49 +
 .../frontend/src/pages/admin/overview.stats.vue    |  155 ++
 .../frontend/src/pages/admin/overview.users.vue    |   57 +
 packages/frontend/src/pages/admin/overview.vue     |  190 +++
 .../frontend/src/pages/admin/proxy-account.vue     |   62 +
 .../frontend/src/pages/admin/queue.chart.chart.vue |  186 ++
 packages/frontend/src/pages/admin/queue.chart.vue  |  149 ++
 packages/frontend/src/pages/admin/queue.vue        |   56 +
 packages/frontend/src/pages/admin/relays.vue       |  103 ++
 packages/frontend/src/pages/admin/security.vue     |  179 ++
 packages/frontend/src/pages/admin/settings.vue     |  262 +++
 packages/frontend/src/pages/admin/users.vue        |  170 ++
 packages/frontend/src/pages/announcements.vue      |   69 +
 packages/frontend/src/pages/antenna-timeline.vue   |  128 ++
 packages/frontend/src/pages/api-console.vue        |   89 +
 packages/frontend/src/pages/auth.form.vue          |   60 +
 packages/frontend/src/pages/auth.vue               |   91 +
 packages/frontend/src/pages/channel-editor.vue     |  122 ++
 packages/frontend/src/pages/channel.vue            |  184 ++
 packages/frontend/src/pages/channels.vue           |   79 +
 packages/frontend/src/pages/clip.vue               |  129 ++
 packages/frontend/src/pages/drive.vue              |   25 +
 packages/frontend/src/pages/emojis.emoji.vue       |   72 +
 packages/frontend/src/pages/explore.featured.vue   |   30 +
 packages/frontend/src/pages/explore.users.vue      |  148 ++
 packages/frontend/src/pages/explore.vue            |   87 +
 packages/frontend/src/pages/favorites.vue          |   49 +
 packages/frontend/src/pages/follow-requests.vue    |  153 ++
 packages/frontend/src/pages/follow.vue             |   62 +
 packages/frontend/src/pages/gallery/edit.vue       |  149 ++
 packages/frontend/src/pages/gallery/index.vue      |  139 ++
 packages/frontend/src/pages/gallery/post.vue       |  265 +++
 packages/frontend/src/pages/instance-info.vue      |  258 +++
 packages/frontend/src/pages/messaging/index.vue    |  327 ++++
 .../src/pages/messaging/messaging-room.form.vue    |  364 ++++
 .../src/pages/messaging/messaging-room.message.vue |  367 ++++
 .../src/pages/messaging/messaging-room.vue         |  411 +++++
 packages/frontend/src/pages/mfm-cheat-sheet.vue    |  387 +++++
 packages/frontend/src/pages/miauth.vue             |   90 +
 packages/frontend/src/pages/my-antennas/create.vue |   46 +
 packages/frontend/src/pages/my-antennas/edit.vue   |   43 +
 packages/frontend/src/pages/my-antennas/editor.vue |  155 ++
 packages/frontend/src/pages/my-antennas/index.vue  |   64 +
 packages/frontend/src/pages/my-clips/index.vue     |  100 ++
 packages/frontend/src/pages/my-lists/index.vue     |   82 +
 packages/frontend/src/pages/my-lists/list.vue      |  162 ++
 packages/frontend/src/pages/not-found.vue          |   22 +
 packages/frontend/src/pages/note.vue               |  206 +++
 packages/frontend/src/pages/notifications.vue      |   95 ++
 .../pages/page-editor/els/page-editor.el.image.vue |   63 +
 .../pages/page-editor/els/page-editor.el.note.vue  |   57 +
 .../page-editor/els/page-editor.el.section.vue     |   97 ++
 .../pages/page-editor/els/page-editor.el.text.vue  |   54 +
 .../src/pages/page-editor/page-editor.blocks.vue   |   65 +
 .../pages/page-editor/page-editor.container.vue    |  155 ++
 .../frontend/src/pages/page-editor/page-editor.vue |  394 +++++
 packages/frontend/src/pages/page.vue               |  277 +++
 packages/frontend/src/pages/pages.vue              |   99 ++
 packages/frontend/src/pages/preview.vue            |   27 +
 packages/frontend/src/pages/registry.keys.vue      |   96 ++
 packages/frontend/src/pages/registry.value.vue     |  123 ++
 packages/frontend/src/pages/registry.vue           |   74 +
 packages/frontend/src/pages/reset-password.vue     |   59 +
 packages/frontend/src/pages/scratchpad.vue         |  137 ++
 packages/frontend/src/pages/search.vue             |   38 +
 packages/frontend/src/pages/settings/2fa.vue       |  216 +++
 .../frontend/src/pages/settings/account-info.vue   |  158 ++
 packages/frontend/src/pages/settings/accounts.vue  |  143 ++
 packages/frontend/src/pages/settings/api.vue       |   46 +
 packages/frontend/src/pages/settings/apps.vue      |   96 ++
 .../frontend/src/pages/settings/custom-css.vue     |   46 +
 packages/frontend/src/pages/settings/deck.vue      |   39 +
 .../frontend/src/pages/settings/delete-account.vue |   52 +
 packages/frontend/src/pages/settings/drive.vue     |  145 ++
 packages/frontend/src/pages/settings/email.vue     |  111 ++
 packages/frontend/src/pages/settings/general.vue   |  196 +++
 .../frontend/src/pages/settings/import-export.vue  |  165 ++
 packages/frontend/src/pages/settings/index.vue     |  291 ++++
 .../frontend/src/pages/settings/instance-mute.vue  |   53 +
 .../frontend/src/pages/settings/integration.vue    |   99 ++
 .../frontend/src/pages/settings/mute-block.vue     |   61 +
 packages/frontend/src/pages/settings/navbar.vue    |   87 +
 .../frontend/src/pages/settings/notifications.vue  |   90 +
 packages/frontend/src/pages/settings/other.vue     |   47 +
 .../frontend/src/pages/settings/plugin.install.vue |  124 ++
 packages/frontend/src/pages/settings/plugin.vue    |   98 ++
 .../src/pages/settings/preferences-backups.vue     |  444 +++++
 packages/frontend/src/pages/settings/privacy.vue   |  100 ++
 packages/frontend/src/pages/settings/profile.vue   |  220 +++
 packages/frontend/src/pages/settings/reaction.vue  |  154 ++
 packages/frontend/src/pages/settings/security.vue  |  160 ++
 .../frontend/src/pages/settings/sounds.sound.vue   |   45 +
 packages/frontend/src/pages/settings/sounds.vue    |   82 +
 .../src/pages/settings/statusbar.statusbar.vue     |  140 ++
 packages/frontend/src/pages/settings/statusbar.vue |   54 +
 .../frontend/src/pages/settings/theme.install.vue  |   80 +
 .../frontend/src/pages/settings/theme.manage.vue   |   78 +
 packages/frontend/src/pages/settings/theme.vue     |  409 +++++
 .../frontend/src/pages/settings/webhook.edit.vue   |   95 ++
 .../frontend/src/pages/settings/webhook.new.vue    |   82 +
 packages/frontend/src/pages/settings/webhook.vue   |   53 +
 packages/frontend/src/pages/settings/word-mute.vue |  128 ++
 packages/frontend/src/pages/share.vue              |  169 ++
 packages/frontend/src/pages/signup-complete.vue    |   41 +
 packages/frontend/src/pages/tag.vue                |   35 +
 packages/frontend/src/pages/theme-editor.vue       |  283 ++++
 packages/frontend/src/pages/timeline.tutorial.vue  |  142 ++
 packages/frontend/src/pages/timeline.vue           |  183 ++
 packages/frontend/src/pages/user-info.vue          |  485 ++++++
 packages/frontend/src/pages/user-list-timeline.vue |  121 ++
 packages/frontend/src/pages/user/clips.vue         |   47 +
 packages/frontend/src/pages/user/follow-list.vue   |   47 +
 packages/frontend/src/pages/user/followers.vue     |   61 +
 packages/frontend/src/pages/user/following.vue     |   61 +
 packages/frontend/src/pages/user/gallery.vue       |   38 +
 packages/frontend/src/pages/user/home.vue          |  530 ++++++
 .../frontend/src/pages/user/index.activity.vue     |   52 +
 packages/frontend/src/pages/user/index.photos.vue  |  102 ++
 .../frontend/src/pages/user/index.timeline.vue     |   45 +
 packages/frontend/src/pages/user/index.vue         |  113 ++
 packages/frontend/src/pages/user/pages.vue         |   30 +
 packages/frontend/src/pages/user/reactions.vue     |   61 +
 packages/frontend/src/pages/welcome.entrance.a.vue |  309 ++++
 packages/frontend/src/pages/welcome.entrance.b.vue |  237 +++
 packages/frontend/src/pages/welcome.entrance.c.vue |  306 ++++
 packages/frontend/src/pages/welcome.setup.vue      |   89 +
 packages/frontend/src/pages/welcome.timeline.vue   |   99 ++
 packages/frontend/src/pages/welcome.vue            |   30 +
 packages/frontend/src/pizzax.ts                    |  169 ++
 packages/frontend/src/plugin.ts                    |  123 ++
 packages/frontend/src/router.ts                    |  501 ++++++
 packages/frontend/src/scripts/2fa.ts               |   33 +
 packages/frontend/src/scripts/aiscript/api.ts      |   43 +
 packages/frontend/src/scripts/array.ts             |  149 ++
 packages/frontend/src/scripts/autocomplete.ts      |  276 +++
 packages/frontend/src/scripts/chart-vline.ts       |   21 +
 packages/frontend/src/scripts/check-word-mute.ts   |   37 +
 packages/frontend/src/scripts/clone.ts             |   18 +
 packages/frontend/src/scripts/collect-page-vars.ts |   68 +
 packages/frontend/src/scripts/contains.ts          |    9 +
 packages/frontend/src/scripts/copy-to-clipboard.ts |   33 +
 packages/frontend/src/scripts/device-kind.ts       |   10 +
 packages/frontend/src/scripts/emoji-base.ts        |   20 +
 packages/frontend/src/scripts/emojilist.ts         |   17 +
 .../src/scripts/extract-avg-color-from-blurhash.ts |    9 +
 packages/frontend/src/scripts/extract-mentions.ts  |   11 +
 .../frontend/src/scripts/extract-url-from-mfm.ts   |   19 +
 packages/frontend/src/scripts/focus.ts             |   27 +
 packages/frontend/src/scripts/form.ts              |   59 +
 .../frontend/src/scripts/format-time-string.ts     |   50 +
 packages/frontend/src/scripts/gen-search-query.ts  |   30 +
 .../frontend/src/scripts/get-account-from-id.ts    |    7 +
 packages/frontend/src/scripts/get-note-menu.ts     |  341 ++++
 packages/frontend/src/scripts/get-note-summary.ts  |   55 +
 .../frontend/src/scripts/get-static-image-url.ts   |   19 +
 packages/frontend/src/scripts/get-user-menu.ts     |  253 +++
 packages/frontend/src/scripts/get-user-name.ts     |    3 +
 packages/frontend/src/scripts/hotkey.ts            |   90 +
 packages/frontend/src/scripts/hpml/block.ts        |  109 ++
 packages/frontend/src/scripts/hpml/evaluator.ts    |  232 +++
 packages/frontend/src/scripts/hpml/expr.ts         |   79 +
 packages/frontend/src/scripts/hpml/index.ts        |  103 ++
 packages/frontend/src/scripts/hpml/lib.ts          |  247 +++
 packages/frontend/src/scripts/hpml/type-checker.ts |  191 +++
 packages/frontend/src/scripts/i18n.ts              |   29 +
 packages/frontend/src/scripts/idb-proxy.ts         |   36 +
 packages/frontend/src/scripts/initialize-sw.ts     |   13 +
 .../frontend/src/scripts/is-device-darkmode.ts     |    3 +
 packages/frontend/src/scripts/keycode.ts           |   33 +
 packages/frontend/src/scripts/langmap.ts           |  666 ++++++++
 packages/frontend/src/scripts/login-id.ts          |   11 +
 packages/frontend/src/scripts/lookup-user.ts       |   36 +
 packages/frontend/src/scripts/media-proxy.ts       |   15 +
 packages/frontend/src/scripts/mfm-tags.ts          |    1 +
 packages/frontend/src/scripts/page-metadata.ts     |   41 +
 packages/frontend/src/scripts/physics.ts           |  152 ++
 packages/frontend/src/scripts/please-login.ts      |   21 +
 packages/frontend/src/scripts/popout.ts            |   23 +
 packages/frontend/src/scripts/popup-position.ts    |  158 ++
 packages/frontend/src/scripts/reaction-picker.ts   |   41 +
 packages/frontend/src/scripts/safe-uri-decode.ts   |    7 +
 packages/frontend/src/scripts/scroll.ts            |   85 +
 packages/frontend/src/scripts/search.ts            |   63 +
 packages/frontend/src/scripts/select-file.ts       |  103 ++
 .../frontend/src/scripts/show-suspended-dialog.ts  |   10 +
 packages/frontend/src/scripts/shuffle.ts           |   19 +
 packages/frontend/src/scripts/sound.ts             |   66 +
 packages/frontend/src/scripts/sticky-sidebar.ts    |   50 +
 packages/frontend/src/scripts/theme-editor.ts      |   81 +
 packages/frontend/src/scripts/theme.ts             |  148 ++
 packages/frontend/src/scripts/time.ts              |   39 +
 packages/frontend/src/scripts/timezones.ts         |   49 +
 packages/frontend/src/scripts/touch.ts             |   23 +
 packages/frontend/src/scripts/unison-reload.ts     |   15 +
 packages/frontend/src/scripts/upload.ts            |  137 ++
 .../frontend/src/scripts/upload/compress-config.ts |   23 +
 packages/frontend/src/scripts/url.ts               |   13 +
 packages/frontend/src/scripts/use-chart-tooltip.ts |   54 +
 packages/frontend/src/scripts/use-interval.ts      |   24 +
 packages/frontend/src/scripts/use-leave-guard.ts   |   47 +
 packages/frontend/src/scripts/use-note-capture.ts  |  110 ++
 packages/frontend/src/scripts/use-tooltip.ts       |   86 +
 packages/frontend/src/store.ts                     |  383 +++++
 packages/frontend/src/stream.ts                    |    8 +
 packages/frontend/src/style.scss                   |  584 +++++++
 packages/frontend/src/theme-store.ts               |   34 +
 packages/frontend/src/themes/_dark.json5           |   99 ++
 packages/frontend/src/themes/_light.json5          |   99 ++
 packages/frontend/src/themes/d-astro.json5         |   78 +
 packages/frontend/src/themes/d-botanical.json5     |   26 +
 packages/frontend/src/themes/d-cherry.json5        |   20 +
 packages/frontend/src/themes/d-dark.json5          |   26 +
 packages/frontend/src/themes/d-future.json5        |   27 +
 packages/frontend/src/themes/d-green-lime.json5    |   24 +
 packages/frontend/src/themes/d-green-orange.json5  |   24 +
 packages/frontend/src/themes/d-ice.json5           |   13 +
 packages/frontend/src/themes/d-persimmon.json5     |   25 +
 packages/frontend/src/themes/d-u0.json5            |   88 +
 packages/frontend/src/themes/l-apricot.json5       |   22 +
 packages/frontend/src/themes/l-cherry.json5        |   21 +
 packages/frontend/src/themes/l-coffee.json5        |   21 +
 packages/frontend/src/themes/l-light.json5         |   20 +
 packages/frontend/src/themes/l-rainy.json5         |   21 +
 packages/frontend/src/themes/l-sushi.json5         |   18 +
 packages/frontend/src/themes/l-u0.json5            |   87 +
 packages/frontend/src/themes/l-vivid.json5         |   82 +
 packages/frontend/src/types/menu.ts                |   21 +
 packages/frontend/src/ui/_common_/common.vue       |  139 ++
 .../frontend/src/ui/_common_/navbar-for-mobile.vue |  314 ++++
 packages/frontend/src/ui/_common_/navbar.vue       |  521 ++++++
 .../src/ui/_common_/statusbar-federation.vue       |  108 ++
 .../frontend/src/ui/_common_/statusbar-rss.vue     |   93 +
 .../src/ui/_common_/statusbar-user-list.vue        |  113 ++
 packages/frontend/src/ui/_common_/statusbars.vue   |   92 +
 .../frontend/src/ui/_common_/stream-indicator.vue  |   61 +
 packages/frontend/src/ui/_common_/sw-inject.ts     |   35 +
 packages/frontend/src/ui/_common_/upload.vue       |  129 ++
 packages/frontend/src/ui/classic.header.vue        |  217 +++
 packages/frontend/src/ui/classic.sidebar.vue       |  268 +++
 packages/frontend/src/ui/classic.vue               |  320 ++++
 packages/frontend/src/ui/classic.widgets.vue       |   84 +
 packages/frontend/src/ui/deck.vue                  |  435 +++++
 packages/frontend/src/ui/deck/antenna-column.vue   |   70 +
 packages/frontend/src/ui/deck/column-core.vue      |   34 +
 packages/frontend/src/ui/deck/column.vue           |  398 +++++
 packages/frontend/src/ui/deck/deck-store.ts        |  296 ++++
 packages/frontend/src/ui/deck/direct-column.vue    |   31 +
 packages/frontend/src/ui/deck/list-column.vue      |   58 +
 packages/frontend/src/ui/deck/main-column.vue      |   68 +
 packages/frontend/src/ui/deck/mentions-column.vue  |   28 +
 .../frontend/src/ui/deck/notifications-column.vue  |   44 +
 packages/frontend/src/ui/deck/tl-column.vue        |  119 ++
 packages/frontend/src/ui/deck/widgets-column.vue   |   69 +
 packages/frontend/src/ui/universal.vue             |  390 +++++
 packages/frontend/src/ui/universal.widgets.vue     |   71 +
 packages/frontend/src/ui/visitor.vue               |   19 +
 packages/frontend/src/ui/visitor/a.vue             |  259 +++
 packages/frontend/src/ui/visitor/b.vue             |  248 +++
 packages/frontend/src/ui/visitor/header.vue        |  228 +++
 packages/frontend/src/ui/visitor/kanban.vue        |  257 +++
 packages/frontend/src/ui/zen.vue                   |   34 +
 .../frontend/src/widgets/activity.calendar.vue     |   81 +
 packages/frontend/src/widgets/activity.chart.vue   |   92 +
 packages/frontend/src/widgets/activity.vue         |   90 +
 packages/frontend/src/widgets/aichan.vue           |   74 +
 packages/frontend/src/widgets/aiscript.vue         |  175 ++
 packages/frontend/src/widgets/button.vue           |  103 ++
 packages/frontend/src/widgets/calendar.vue         |  213 +++
 packages/frontend/src/widgets/clock.vue            |  203 +++
 packages/frontend/src/widgets/digital-clock.vue    |   92 +
 packages/frontend/src/widgets/federation.vue       |  147 ++
 packages/frontend/src/widgets/index.ts             |   53 +
 packages/frontend/src/widgets/instance-cloud.vue   |   81 +
 packages/frontend/src/widgets/job-queue.vue        |  197 +++
 packages/frontend/src/widgets/memo.vue             |  111 ++
 packages/frontend/src/widgets/notifications.vue    |   70 +
 packages/frontend/src/widgets/online-users.vue     |   78 +
 packages/frontend/src/widgets/photos.vue           |  123 ++
 packages/frontend/src/widgets/post-form.vue        |   35 +
 packages/frontend/src/widgets/rss-ticker.vue       |  152 ++
 packages/frontend/src/widgets/rss.vue              |   96 ++
 .../frontend/src/widgets/server-metric/cpu-mem.vue |  167 ++
 .../frontend/src/widgets/server-metric/cpu.vue     |   65 +
 .../frontend/src/widgets/server-metric/disk.vue    |   57 +
 .../frontend/src/widgets/server-metric/index.vue   |   87 +
 .../frontend/src/widgets/server-metric/mem.vue     |   73 +
 .../frontend/src/widgets/server-metric/net.vue     |  140 ++
 .../frontend/src/widgets/server-metric/pie.vue     |   52 +
 packages/frontend/src/widgets/slideshow.vue        |  159 ++
 packages/frontend/src/widgets/timeline.vue         |  129 ++
 packages/frontend/src/widgets/trends.vue           |  120 ++
 packages/frontend/src/widgets/unix-clock.vue       |  116 ++
 packages/frontend/src/widgets/user-list.vue        |  136 ++
 packages/frontend/src/widgets/widget.ts            |   73 +
 packages/frontend/tsconfig.json                    |   47 +
 packages/frontend/vite.config.ts                   |   70 +
 packages/frontend/vite.json5.ts                    |   38 +
 scripts/clean-all.js                               |    4 +-
 scripts/clean.js                                   |    2 +-
 scripts/dev.js                                     |    2 +-
 yarn.lock                                          |  176 +-
 1172 files changed, 68966 insertions(+), 68966 deletions(-)
 delete mode 100644 packages/client/.eslintrc.js
 delete mode 100644 packages/client/.vscode/settings.json
 delete mode 100644 packages/client/@types/global.d.ts
 delete mode 100644 packages/client/@types/theme.d.ts
 delete mode 100644 packages/client/@types/vue.d.ts
 delete mode 100644 packages/client/assets/about-icon.png
 delete mode 100644 packages/client/assets/dummy.png
 delete mode 100644 packages/client/assets/fedi.jpg
 delete mode 100644 packages/client/assets/label-red.svg
 delete mode 100644 packages/client/assets/label.svg
 delete mode 100644 packages/client/assets/misskey.svg
 delete mode 100644 packages/client/assets/remove.png
 delete mode 100644 packages/client/assets/sounds/aisha/1.mp3
 delete mode 100644 packages/client/assets/sounds/aisha/2.mp3
 delete mode 100644 packages/client/assets/sounds/aisha/3.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba1.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba2.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba3.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba4.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba5.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba6.mp3
 delete mode 100644 packages/client/assets/sounds/noizenecio/kick_gaba7.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/down.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/kick.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/pirori-square-wet.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/pirori-wet.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/pirori.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/poi1.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/poi2.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/pope1.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/pope2.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/popo.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/queue-jammed.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/reverved.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/ryukyu.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/snare.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/square-pico.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/triple.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/up.mp3
 delete mode 100644 packages/client/assets/sounds/syuilo/waon.mp3
 delete mode 100644 packages/client/assets/tagcanvas.min.js
 delete mode 100644 packages/client/assets/unread.svg
 delete mode 100644 packages/client/package.json
 delete mode 100644 packages/client/src/account.ts
 delete mode 100644 packages/client/src/components/MkAbuseReport.vue
 delete mode 100644 packages/client/src/components/MkAbuseReportWindow.vue
 delete mode 100644 packages/client/src/components/MkActiveUsersHeatmap.vue
 delete mode 100644 packages/client/src/components/MkAnalogClock.vue
 delete mode 100644 packages/client/src/components/MkAutocomplete.vue
 delete mode 100644 packages/client/src/components/MkAvatars.vue
 delete mode 100644 packages/client/src/components/MkButton.vue
 delete mode 100644 packages/client/src/components/MkCaptcha.vue
 delete mode 100644 packages/client/src/components/MkChannelFollowButton.vue
 delete mode 100644 packages/client/src/components/MkChannelPreview.vue
 delete mode 100644 packages/client/src/components/MkChart.vue
 delete mode 100644 packages/client/src/components/MkChartTooltip.vue
 delete mode 100644 packages/client/src/components/MkCode.core.vue
 delete mode 100644 packages/client/src/components/MkCode.vue
 delete mode 100644 packages/client/src/components/MkContainer.vue
 delete mode 100644 packages/client/src/components/MkContextMenu.vue
 delete mode 100644 packages/client/src/components/MkCropperDialog.vue
 delete mode 100644 packages/client/src/components/MkCwButton.vue
 delete mode 100644 packages/client/src/components/MkDateSeparatedList.vue
 delete mode 100644 packages/client/src/components/MkDialog.vue
 delete mode 100644 packages/client/src/components/MkDigitalClock.vue
 delete mode 100644 packages/client/src/components/MkDrive.file.vue
 delete mode 100644 packages/client/src/components/MkDrive.folder.vue
 delete mode 100644 packages/client/src/components/MkDrive.navFolder.vue
 delete mode 100644 packages/client/src/components/MkDrive.vue
 delete mode 100644 packages/client/src/components/MkDriveFileThumbnail.vue
 delete mode 100644 packages/client/src/components/MkDriveSelectDialog.vue
 delete mode 100644 packages/client/src/components/MkDriveWindow.vue
 delete mode 100644 packages/client/src/components/MkEmojiPicker.section.vue
 delete mode 100644 packages/client/src/components/MkEmojiPicker.vue
 delete mode 100644 packages/client/src/components/MkEmojiPickerDialog.vue
 delete mode 100644 packages/client/src/components/MkEmojiPickerWindow.vue
 delete mode 100644 packages/client/src/components/MkFeaturedPhotos.vue
 delete mode 100644 packages/client/src/components/MkFileCaptionEditWindow.vue
 delete mode 100644 packages/client/src/components/MkFileListForAdmin.vue
 delete mode 100644 packages/client/src/components/MkFolder.vue
 delete mode 100644 packages/client/src/components/MkFollowButton.vue
 delete mode 100644 packages/client/src/components/MkForgotPassword.vue
 delete mode 100644 packages/client/src/components/MkFormDialog.vue
 delete mode 100644 packages/client/src/components/MkFormula.vue
 delete mode 100644 packages/client/src/components/MkFormulaCore.vue
 delete mode 100644 packages/client/src/components/MkGalleryPostPreview.vue
 delete mode 100644 packages/client/src/components/MkGoogle.vue
 delete mode 100644 packages/client/src/components/MkImageViewer.vue
 delete mode 100644 packages/client/src/components/MkImgWithBlurhash.vue
 delete mode 100644 packages/client/src/components/MkInfo.vue
 delete mode 100644 packages/client/src/components/MkInstanceCardMini.vue
 delete mode 100644 packages/client/src/components/MkInstanceStats.vue
 delete mode 100644 packages/client/src/components/MkInstanceTicker.vue
 delete mode 100644 packages/client/src/components/MkKeyValue.vue
 delete mode 100644 packages/client/src/components/MkLaunchPad.vue
 delete mode 100644 packages/client/src/components/MkLink.vue
 delete mode 100644 packages/client/src/components/MkMarquee.vue
 delete mode 100644 packages/client/src/components/MkMediaBanner.vue
 delete mode 100644 packages/client/src/components/MkMediaImage.vue
 delete mode 100644 packages/client/src/components/MkMediaList.vue
 delete mode 100644 packages/client/src/components/MkMediaVideo.vue
 delete mode 100644 packages/client/src/components/MkMention.vue
 delete mode 100644 packages/client/src/components/MkMenu.child.vue
 delete mode 100644 packages/client/src/components/MkMenu.vue
 delete mode 100644 packages/client/src/components/MkMiniChart.vue
 delete mode 100644 packages/client/src/components/MkModal.vue
 delete mode 100644 packages/client/src/components/MkModalPageWindow.vue
 delete mode 100644 packages/client/src/components/MkModalWindow.vue
 delete mode 100644 packages/client/src/components/MkNote.vue
 delete mode 100644 packages/client/src/components/MkNoteDetailed.vue
 delete mode 100644 packages/client/src/components/MkNoteHeader.vue
 delete mode 100644 packages/client/src/components/MkNotePreview.vue
 delete mode 100644 packages/client/src/components/MkNoteSimple.vue
 delete mode 100644 packages/client/src/components/MkNoteSub.vue
 delete mode 100644 packages/client/src/components/MkNotes.vue
 delete mode 100644 packages/client/src/components/MkNotification.vue
 delete mode 100644 packages/client/src/components/MkNotificationSettingWindow.vue
 delete mode 100644 packages/client/src/components/MkNotificationToast.vue
 delete mode 100644 packages/client/src/components/MkNotifications.vue
 delete mode 100644 packages/client/src/components/MkNumberDiff.vue
 delete mode 100644 packages/client/src/components/MkObjectView.value.vue
 delete mode 100644 packages/client/src/components/MkObjectView.vue
 delete mode 100644 packages/client/src/components/MkPagePreview.vue
 delete mode 100644 packages/client/src/components/MkPageWindow.vue
 delete mode 100644 packages/client/src/components/MkPagination.vue
 delete mode 100644 packages/client/src/components/MkPoll.vue
 delete mode 100644 packages/client/src/components/MkPollEditor.vue
 delete mode 100644 packages/client/src/components/MkPopupMenu.vue
 delete mode 100644 packages/client/src/components/MkPostForm.vue
 delete mode 100644 packages/client/src/components/MkPostFormAttaches.vue
 delete mode 100644 packages/client/src/components/MkPostFormDialog.vue
 delete mode 100644 packages/client/src/components/MkPushNotificationAllowButton.vue
 delete mode 100644 packages/client/src/components/MkReactionIcon.vue
 delete mode 100644 packages/client/src/components/MkReactionTooltip.vue
 delete mode 100644 packages/client/src/components/MkReactionsViewer.details.vue
 delete mode 100644 packages/client/src/components/MkReactionsViewer.reaction.vue
 delete mode 100644 packages/client/src/components/MkReactionsViewer.vue
 delete mode 100644 packages/client/src/components/MkRemoteCaution.vue
 delete mode 100644 packages/client/src/components/MkRenoteButton.vue
 delete mode 100644 packages/client/src/components/MkRipple.vue
 delete mode 100644 packages/client/src/components/MkSample.vue
 delete mode 100644 packages/client/src/components/MkSignin.vue
 delete mode 100644 packages/client/src/components/MkSigninDialog.vue
 delete mode 100644 packages/client/src/components/MkSignup.vue
 delete mode 100644 packages/client/src/components/MkSignupDialog.vue
 delete mode 100644 packages/client/src/components/MkSparkle.vue
 delete mode 100644 packages/client/src/components/MkSubNoteContent.vue
 delete mode 100644 packages/client/src/components/MkSuperMenu.vue
 delete mode 100644 packages/client/src/components/MkTab.vue
 delete mode 100644 packages/client/src/components/MkTagCloud.vue
 delete mode 100644 packages/client/src/components/MkTimeline.vue
 delete mode 100644 packages/client/src/components/MkToast.vue
 delete mode 100644 packages/client/src/components/MkTokenGenerateWindow.vue
 delete mode 100644 packages/client/src/components/MkTooltip.vue
 delete mode 100644 packages/client/src/components/MkUpdated.vue
 delete mode 100644 packages/client/src/components/MkUrlPreview.vue
 delete mode 100644 packages/client/src/components/MkUrlPreviewPopup.vue
 delete mode 100644 packages/client/src/components/MkUserCardMini.vue
 delete mode 100644 packages/client/src/components/MkUserInfo.vue
 delete mode 100644 packages/client/src/components/MkUserList.vue
 delete mode 100644 packages/client/src/components/MkUserOnlineIndicator.vue
 delete mode 100644 packages/client/src/components/MkUserPreview.vue
 delete mode 100644 packages/client/src/components/MkUserSelectDialog.vue
 delete mode 100644 packages/client/src/components/MkUsersTooltip.vue
 delete mode 100644 packages/client/src/components/MkVisibility.vue
 delete mode 100644 packages/client/src/components/MkVisibilityPicker.vue
 delete mode 100644 packages/client/src/components/MkWaitingDialog.vue
 delete mode 100644 packages/client/src/components/MkWidgets.vue
 delete mode 100644 packages/client/src/components/MkWindow.vue
 delete mode 100644 packages/client/src/components/MkYoutubePlayer.vue
 delete mode 100644 packages/client/src/components/form/checkbox.vue
 delete mode 100644 packages/client/src/components/form/folder.vue
 delete mode 100644 packages/client/src/components/form/input.vue
 delete mode 100644 packages/client/src/components/form/link.vue
 delete mode 100644 packages/client/src/components/form/radio.vue
 delete mode 100644 packages/client/src/components/form/radios.vue
 delete mode 100644 packages/client/src/components/form/range.vue
 delete mode 100644 packages/client/src/components/form/section.vue
 delete mode 100644 packages/client/src/components/form/select.vue
 delete mode 100644 packages/client/src/components/form/slot.vue
 delete mode 100644 packages/client/src/components/form/split.vue
 delete mode 100644 packages/client/src/components/form/suspense.vue
 delete mode 100644 packages/client/src/components/form/switch.vue
 delete mode 100644 packages/client/src/components/form/textarea.vue
 delete mode 100644 packages/client/src/components/global/MkA.vue
 delete mode 100644 packages/client/src/components/global/MkAcct.vue
 delete mode 100644 packages/client/src/components/global/MkAd.vue
 delete mode 100644 packages/client/src/components/global/MkAvatar.vue
 delete mode 100644 packages/client/src/components/global/MkEllipsis.vue
 delete mode 100644 packages/client/src/components/global/MkEmoji.vue
 delete mode 100644 packages/client/src/components/global/MkError.vue
 delete mode 100644 packages/client/src/components/global/MkLoading.vue
 delete mode 100644 packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue
 delete mode 100644 packages/client/src/components/global/MkPageHeader.vue
 delete mode 100644 packages/client/src/components/global/MkSpacer.vue
 delete mode 100644 packages/client/src/components/global/MkStickyContainer.vue
 delete mode 100644 packages/client/src/components/global/MkTime.vue
 delete mode 100644 packages/client/src/components/global/MkUrl.vue
 delete mode 100644 packages/client/src/components/global/MkUserName.vue
 delete mode 100644 packages/client/src/components/global/RouterView.vue
 delete mode 100644 packages/client/src/components/global/i18n.ts
 delete mode 100644 packages/client/src/components/index.ts
 delete mode 100644 packages/client/src/components/mfm.ts
 delete mode 100644 packages/client/src/components/page/page.block.vue
 delete mode 100644 packages/client/src/components/page/page.button.vue
 delete mode 100644 packages/client/src/components/page/page.canvas.vue
 delete mode 100644 packages/client/src/components/page/page.counter.vue
 delete mode 100644 packages/client/src/components/page/page.if.vue
 delete mode 100644 packages/client/src/components/page/page.image.vue
 delete mode 100644 packages/client/src/components/page/page.note.vue
 delete mode 100644 packages/client/src/components/page/page.number-input.vue
 delete mode 100644 packages/client/src/components/page/page.post.vue
 delete mode 100644 packages/client/src/components/page/page.radio-button.vue
 delete mode 100644 packages/client/src/components/page/page.section.vue
 delete mode 100644 packages/client/src/components/page/page.switch.vue
 delete mode 100644 packages/client/src/components/page/page.text-input.vue
 delete mode 100644 packages/client/src/components/page/page.text.vue
 delete mode 100644 packages/client/src/components/page/page.textarea-input.vue
 delete mode 100644 packages/client/src/components/page/page.textarea.vue
 delete mode 100644 packages/client/src/components/page/page.vue
 delete mode 100644 packages/client/src/config.ts
 delete mode 100644 packages/client/src/const.ts
 delete mode 100644 packages/client/src/directives/adaptive-border.ts
 delete mode 100644 packages/client/src/directives/anim.ts
 delete mode 100644 packages/client/src/directives/appear.ts
 delete mode 100644 packages/client/src/directives/click-anime.ts
 delete mode 100644 packages/client/src/directives/follow-append.ts
 delete mode 100644 packages/client/src/directives/get-size.ts
 delete mode 100644 packages/client/src/directives/hotkey.ts
 delete mode 100644 packages/client/src/directives/index.ts
 delete mode 100644 packages/client/src/directives/panel.ts
 delete mode 100644 packages/client/src/directives/ripple.ts
 delete mode 100644 packages/client/src/directives/size.ts
 delete mode 100644 packages/client/src/directives/tooltip.ts
 delete mode 100644 packages/client/src/directives/user-preview.ts
 delete mode 100644 packages/client/src/emojilist.json
 delete mode 100644 packages/client/src/events.ts
 delete mode 100644 packages/client/src/filters/bytes.ts
 delete mode 100644 packages/client/src/filters/note.ts
 delete mode 100644 packages/client/src/filters/number.ts
 delete mode 100644 packages/client/src/filters/user.ts
 delete mode 100644 packages/client/src/i18n.ts
 delete mode 100644 packages/client/src/init.ts
 delete mode 100644 packages/client/src/instance.ts
 delete mode 100644 packages/client/src/navbar.ts
 delete mode 100644 packages/client/src/nirax.ts
 delete mode 100644 packages/client/src/os.ts
 delete mode 100644 packages/client/src/pages/_empty_.vue
 delete mode 100644 packages/client/src/pages/_error_.vue
 delete mode 100644 packages/client/src/pages/_loading_.vue
 delete mode 100644 packages/client/src/pages/about-misskey.vue
 delete mode 100644 packages/client/src/pages/about.emojis.vue
 delete mode 100644 packages/client/src/pages/about.federation.vue
 delete mode 100644 packages/client/src/pages/about.vue
 delete mode 100644 packages/client/src/pages/admin-file.vue
 delete mode 100644 packages/client/src/pages/admin/_header_.vue
 delete mode 100644 packages/client/src/pages/admin/abuses.vue
 delete mode 100644 packages/client/src/pages/admin/ads.vue
 delete mode 100644 packages/client/src/pages/admin/announcements.vue
 delete mode 100644 packages/client/src/pages/admin/bot-protection.vue
 delete mode 100644 packages/client/src/pages/admin/database.vue
 delete mode 100644 packages/client/src/pages/admin/email-settings.vue
 delete mode 100644 packages/client/src/pages/admin/emoji-edit-dialog.vue
 delete mode 100644 packages/client/src/pages/admin/emojis.vue
 delete mode 100644 packages/client/src/pages/admin/files.vue
 delete mode 100644 packages/client/src/pages/admin/index.vue
 delete mode 100644 packages/client/src/pages/admin/instance-block.vue
 delete mode 100644 packages/client/src/pages/admin/integrations.discord.vue
 delete mode 100644 packages/client/src/pages/admin/integrations.github.vue
 delete mode 100644 packages/client/src/pages/admin/integrations.twitter.vue
 delete mode 100644 packages/client/src/pages/admin/integrations.vue
 delete mode 100644 packages/client/src/pages/admin/metrics.vue
 delete mode 100644 packages/client/src/pages/admin/object-storage.vue
 delete mode 100644 packages/client/src/pages/admin/other-settings.vue
 delete mode 100644 packages/client/src/pages/admin/overview.active-users.vue
 delete mode 100644 packages/client/src/pages/admin/overview.ap-requests.vue
 delete mode 100644 packages/client/src/pages/admin/overview.federation.vue
 delete mode 100644 packages/client/src/pages/admin/overview.heatmap.vue
 delete mode 100644 packages/client/src/pages/admin/overview.instances.vue
 delete mode 100644 packages/client/src/pages/admin/overview.moderators.vue
 delete mode 100644 packages/client/src/pages/admin/overview.pie.vue
 delete mode 100644 packages/client/src/pages/admin/overview.queue.chart.vue
 delete mode 100644 packages/client/src/pages/admin/overview.queue.vue
 delete mode 100644 packages/client/src/pages/admin/overview.retention.vue
 delete mode 100644 packages/client/src/pages/admin/overview.stats.vue
 delete mode 100644 packages/client/src/pages/admin/overview.users.vue
 delete mode 100644 packages/client/src/pages/admin/overview.vue
 delete mode 100644 packages/client/src/pages/admin/proxy-account.vue
 delete mode 100644 packages/client/src/pages/admin/queue.chart.chart.vue
 delete mode 100644 packages/client/src/pages/admin/queue.chart.vue
 delete mode 100644 packages/client/src/pages/admin/queue.vue
 delete mode 100644 packages/client/src/pages/admin/relays.vue
 delete mode 100644 packages/client/src/pages/admin/security.vue
 delete mode 100644 packages/client/src/pages/admin/settings.vue
 delete mode 100644 packages/client/src/pages/admin/users.vue
 delete mode 100644 packages/client/src/pages/announcements.vue
 delete mode 100644 packages/client/src/pages/antenna-timeline.vue
 delete mode 100644 packages/client/src/pages/api-console.vue
 delete mode 100644 packages/client/src/pages/auth.form.vue
 delete mode 100644 packages/client/src/pages/auth.vue
 delete mode 100644 packages/client/src/pages/channel-editor.vue
 delete mode 100644 packages/client/src/pages/channel.vue
 delete mode 100644 packages/client/src/pages/channels.vue
 delete mode 100644 packages/client/src/pages/clip.vue
 delete mode 100644 packages/client/src/pages/drive.vue
 delete mode 100644 packages/client/src/pages/emojis.emoji.vue
 delete mode 100644 packages/client/src/pages/explore.featured.vue
 delete mode 100644 packages/client/src/pages/explore.users.vue
 delete mode 100644 packages/client/src/pages/explore.vue
 delete mode 100644 packages/client/src/pages/favorites.vue
 delete mode 100644 packages/client/src/pages/follow-requests.vue
 delete mode 100644 packages/client/src/pages/follow.vue
 delete mode 100644 packages/client/src/pages/gallery/edit.vue
 delete mode 100644 packages/client/src/pages/gallery/index.vue
 delete mode 100644 packages/client/src/pages/gallery/post.vue
 delete mode 100644 packages/client/src/pages/instance-info.vue
 delete mode 100644 packages/client/src/pages/messaging/index.vue
 delete mode 100644 packages/client/src/pages/messaging/messaging-room.form.vue
 delete mode 100644 packages/client/src/pages/messaging/messaging-room.message.vue
 delete mode 100644 packages/client/src/pages/messaging/messaging-room.vue
 delete mode 100644 packages/client/src/pages/mfm-cheat-sheet.vue
 delete mode 100644 packages/client/src/pages/miauth.vue
 delete mode 100644 packages/client/src/pages/my-antennas/create.vue
 delete mode 100644 packages/client/src/pages/my-antennas/edit.vue
 delete mode 100644 packages/client/src/pages/my-antennas/editor.vue
 delete mode 100644 packages/client/src/pages/my-antennas/index.vue
 delete mode 100644 packages/client/src/pages/my-clips/index.vue
 delete mode 100644 packages/client/src/pages/my-lists/index.vue
 delete mode 100644 packages/client/src/pages/my-lists/list.vue
 delete mode 100644 packages/client/src/pages/not-found.vue
 delete mode 100644 packages/client/src/pages/note.vue
 delete mode 100644 packages/client/src/pages/notifications.vue
 delete mode 100644 packages/client/src/pages/page-editor/els/page-editor.el.image.vue
 delete mode 100644 packages/client/src/pages/page-editor/els/page-editor.el.note.vue
 delete mode 100644 packages/client/src/pages/page-editor/els/page-editor.el.section.vue
 delete mode 100644 packages/client/src/pages/page-editor/els/page-editor.el.text.vue
 delete mode 100644 packages/client/src/pages/page-editor/page-editor.blocks.vue
 delete mode 100644 packages/client/src/pages/page-editor/page-editor.container.vue
 delete mode 100644 packages/client/src/pages/page-editor/page-editor.vue
 delete mode 100644 packages/client/src/pages/page.vue
 delete mode 100644 packages/client/src/pages/pages.vue
 delete mode 100644 packages/client/src/pages/preview.vue
 delete mode 100644 packages/client/src/pages/registry.keys.vue
 delete mode 100644 packages/client/src/pages/registry.value.vue
 delete mode 100644 packages/client/src/pages/registry.vue
 delete mode 100644 packages/client/src/pages/reset-password.vue
 delete mode 100644 packages/client/src/pages/scratchpad.vue
 delete mode 100644 packages/client/src/pages/search.vue
 delete mode 100644 packages/client/src/pages/settings/2fa.vue
 delete mode 100644 packages/client/src/pages/settings/account-info.vue
 delete mode 100644 packages/client/src/pages/settings/accounts.vue
 delete mode 100644 packages/client/src/pages/settings/api.vue
 delete mode 100644 packages/client/src/pages/settings/apps.vue
 delete mode 100644 packages/client/src/pages/settings/custom-css.vue
 delete mode 100644 packages/client/src/pages/settings/deck.vue
 delete mode 100644 packages/client/src/pages/settings/delete-account.vue
 delete mode 100644 packages/client/src/pages/settings/drive.vue
 delete mode 100644 packages/client/src/pages/settings/email.vue
 delete mode 100644 packages/client/src/pages/settings/general.vue
 delete mode 100644 packages/client/src/pages/settings/import-export.vue
 delete mode 100644 packages/client/src/pages/settings/index.vue
 delete mode 100644 packages/client/src/pages/settings/instance-mute.vue
 delete mode 100644 packages/client/src/pages/settings/integration.vue
 delete mode 100644 packages/client/src/pages/settings/mute-block.vue
 delete mode 100644 packages/client/src/pages/settings/navbar.vue
 delete mode 100644 packages/client/src/pages/settings/notifications.vue
 delete mode 100644 packages/client/src/pages/settings/other.vue
 delete mode 100644 packages/client/src/pages/settings/plugin.install.vue
 delete mode 100644 packages/client/src/pages/settings/plugin.vue
 delete mode 100644 packages/client/src/pages/settings/preferences-backups.vue
 delete mode 100644 packages/client/src/pages/settings/privacy.vue
 delete mode 100644 packages/client/src/pages/settings/profile.vue
 delete mode 100644 packages/client/src/pages/settings/reaction.vue
 delete mode 100644 packages/client/src/pages/settings/security.vue
 delete mode 100644 packages/client/src/pages/settings/sounds.sound.vue
 delete mode 100644 packages/client/src/pages/settings/sounds.vue
 delete mode 100644 packages/client/src/pages/settings/statusbar.statusbar.vue
 delete mode 100644 packages/client/src/pages/settings/statusbar.vue
 delete mode 100644 packages/client/src/pages/settings/theme.install.vue
 delete mode 100644 packages/client/src/pages/settings/theme.manage.vue
 delete mode 100644 packages/client/src/pages/settings/theme.vue
 delete mode 100644 packages/client/src/pages/settings/webhook.edit.vue
 delete mode 100644 packages/client/src/pages/settings/webhook.new.vue
 delete mode 100644 packages/client/src/pages/settings/webhook.vue
 delete mode 100644 packages/client/src/pages/settings/word-mute.vue
 delete mode 100644 packages/client/src/pages/share.vue
 delete mode 100644 packages/client/src/pages/signup-complete.vue
 delete mode 100644 packages/client/src/pages/tag.vue
 delete mode 100644 packages/client/src/pages/theme-editor.vue
 delete mode 100644 packages/client/src/pages/timeline.tutorial.vue
 delete mode 100644 packages/client/src/pages/timeline.vue
 delete mode 100644 packages/client/src/pages/user-info.vue
 delete mode 100644 packages/client/src/pages/user-list-timeline.vue
 delete mode 100644 packages/client/src/pages/user/clips.vue
 delete mode 100644 packages/client/src/pages/user/follow-list.vue
 delete mode 100644 packages/client/src/pages/user/followers.vue
 delete mode 100644 packages/client/src/pages/user/following.vue
 delete mode 100644 packages/client/src/pages/user/gallery.vue
 delete mode 100644 packages/client/src/pages/user/home.vue
 delete mode 100644 packages/client/src/pages/user/index.activity.vue
 delete mode 100644 packages/client/src/pages/user/index.photos.vue
 delete mode 100644 packages/client/src/pages/user/index.timeline.vue
 delete mode 100644 packages/client/src/pages/user/index.vue
 delete mode 100644 packages/client/src/pages/user/pages.vue
 delete mode 100644 packages/client/src/pages/user/reactions.vue
 delete mode 100644 packages/client/src/pages/welcome.entrance.a.vue
 delete mode 100644 packages/client/src/pages/welcome.entrance.b.vue
 delete mode 100644 packages/client/src/pages/welcome.entrance.c.vue
 delete mode 100644 packages/client/src/pages/welcome.setup.vue
 delete mode 100644 packages/client/src/pages/welcome.timeline.vue
 delete mode 100644 packages/client/src/pages/welcome.vue
 delete mode 100644 packages/client/src/pizzax.ts
 delete mode 100644 packages/client/src/plugin.ts
 delete mode 100644 packages/client/src/router.ts
 delete mode 100644 packages/client/src/scripts/2fa.ts
 delete mode 100644 packages/client/src/scripts/aiscript/api.ts
 delete mode 100644 packages/client/src/scripts/array.ts
 delete mode 100644 packages/client/src/scripts/autocomplete.ts
 delete mode 100644 packages/client/src/scripts/chart-vline.ts
 delete mode 100644 packages/client/src/scripts/check-word-mute.ts
 delete mode 100644 packages/client/src/scripts/clone.ts
 delete mode 100644 packages/client/src/scripts/collect-page-vars.ts
 delete mode 100644 packages/client/src/scripts/contains.ts
 delete mode 100644 packages/client/src/scripts/copy-to-clipboard.ts
 delete mode 100644 packages/client/src/scripts/device-kind.ts
 delete mode 100644 packages/client/src/scripts/emoji-base.ts
 delete mode 100644 packages/client/src/scripts/emojilist.ts
 delete mode 100644 packages/client/src/scripts/extract-avg-color-from-blurhash.ts
 delete mode 100644 packages/client/src/scripts/extract-mentions.ts
 delete mode 100644 packages/client/src/scripts/extract-url-from-mfm.ts
 delete mode 100644 packages/client/src/scripts/focus.ts
 delete mode 100644 packages/client/src/scripts/form.ts
 delete mode 100644 packages/client/src/scripts/format-time-string.ts
 delete mode 100644 packages/client/src/scripts/gen-search-query.ts
 delete mode 100644 packages/client/src/scripts/get-account-from-id.ts
 delete mode 100644 packages/client/src/scripts/get-note-menu.ts
 delete mode 100644 packages/client/src/scripts/get-note-summary.ts
 delete mode 100644 packages/client/src/scripts/get-static-image-url.ts
 delete mode 100644 packages/client/src/scripts/get-user-menu.ts
 delete mode 100644 packages/client/src/scripts/get-user-name.ts
 delete mode 100644 packages/client/src/scripts/hotkey.ts
 delete mode 100644 packages/client/src/scripts/hpml/block.ts
 delete mode 100644 packages/client/src/scripts/hpml/evaluator.ts
 delete mode 100644 packages/client/src/scripts/hpml/expr.ts
 delete mode 100644 packages/client/src/scripts/hpml/index.ts
 delete mode 100644 packages/client/src/scripts/hpml/lib.ts
 delete mode 100644 packages/client/src/scripts/hpml/type-checker.ts
 delete mode 100644 packages/client/src/scripts/i18n.ts
 delete mode 100644 packages/client/src/scripts/idb-proxy.ts
 delete mode 100644 packages/client/src/scripts/initialize-sw.ts
 delete mode 100644 packages/client/src/scripts/is-device-darkmode.ts
 delete mode 100644 packages/client/src/scripts/keycode.ts
 delete mode 100644 packages/client/src/scripts/langmap.ts
 delete mode 100644 packages/client/src/scripts/login-id.ts
 delete mode 100644 packages/client/src/scripts/lookup-user.ts
 delete mode 100644 packages/client/src/scripts/media-proxy.ts
 delete mode 100644 packages/client/src/scripts/mfm-tags.ts
 delete mode 100644 packages/client/src/scripts/page-metadata.ts
 delete mode 100644 packages/client/src/scripts/physics.ts
 delete mode 100644 packages/client/src/scripts/please-login.ts
 delete mode 100644 packages/client/src/scripts/popout.ts
 delete mode 100644 packages/client/src/scripts/popup-position.ts
 delete mode 100644 packages/client/src/scripts/reaction-picker.ts
 delete mode 100644 packages/client/src/scripts/safe-uri-decode.ts
 delete mode 100644 packages/client/src/scripts/scroll.ts
 delete mode 100644 packages/client/src/scripts/search.ts
 delete mode 100644 packages/client/src/scripts/select-file.ts
 delete mode 100644 packages/client/src/scripts/show-suspended-dialog.ts
 delete mode 100644 packages/client/src/scripts/shuffle.ts
 delete mode 100644 packages/client/src/scripts/sound.ts
 delete mode 100644 packages/client/src/scripts/sticky-sidebar.ts
 delete mode 100644 packages/client/src/scripts/theme-editor.ts
 delete mode 100644 packages/client/src/scripts/theme.ts
 delete mode 100644 packages/client/src/scripts/time.ts
 delete mode 100644 packages/client/src/scripts/timezones.ts
 delete mode 100644 packages/client/src/scripts/touch.ts
 delete mode 100644 packages/client/src/scripts/unison-reload.ts
 delete mode 100644 packages/client/src/scripts/upload.ts
 delete mode 100644 packages/client/src/scripts/upload/compress-config.ts
 delete mode 100644 packages/client/src/scripts/url.ts
 delete mode 100644 packages/client/src/scripts/use-chart-tooltip.ts
 delete mode 100644 packages/client/src/scripts/use-interval.ts
 delete mode 100644 packages/client/src/scripts/use-leave-guard.ts
 delete mode 100644 packages/client/src/scripts/use-note-capture.ts
 delete mode 100644 packages/client/src/scripts/use-tooltip.ts
 delete mode 100644 packages/client/src/store.ts
 delete mode 100644 packages/client/src/stream.ts
 delete mode 100644 packages/client/src/style.scss
 delete mode 100644 packages/client/src/theme-store.ts
 delete mode 100644 packages/client/src/themes/_dark.json5
 delete mode 100644 packages/client/src/themes/_light.json5
 delete mode 100644 packages/client/src/themes/d-astro.json5
 delete mode 100644 packages/client/src/themes/d-botanical.json5
 delete mode 100644 packages/client/src/themes/d-cherry.json5
 delete mode 100644 packages/client/src/themes/d-dark.json5
 delete mode 100644 packages/client/src/themes/d-future.json5
 delete mode 100644 packages/client/src/themes/d-green-lime.json5
 delete mode 100644 packages/client/src/themes/d-green-orange.json5
 delete mode 100644 packages/client/src/themes/d-ice.json5
 delete mode 100644 packages/client/src/themes/d-persimmon.json5
 delete mode 100644 packages/client/src/themes/d-u0.json5
 delete mode 100644 packages/client/src/themes/l-apricot.json5
 delete mode 100644 packages/client/src/themes/l-cherry.json5
 delete mode 100644 packages/client/src/themes/l-coffee.json5
 delete mode 100644 packages/client/src/themes/l-light.json5
 delete mode 100644 packages/client/src/themes/l-rainy.json5
 delete mode 100644 packages/client/src/themes/l-sushi.json5
 delete mode 100644 packages/client/src/themes/l-u0.json5
 delete mode 100644 packages/client/src/themes/l-vivid.json5
 delete mode 100644 packages/client/src/types/menu.ts
 delete mode 100644 packages/client/src/ui/_common_/common.vue
 delete mode 100644 packages/client/src/ui/_common_/navbar-for-mobile.vue
 delete mode 100644 packages/client/src/ui/_common_/navbar.vue
 delete mode 100644 packages/client/src/ui/_common_/statusbar-federation.vue
 delete mode 100644 packages/client/src/ui/_common_/statusbar-rss.vue
 delete mode 100644 packages/client/src/ui/_common_/statusbar-user-list.vue
 delete mode 100644 packages/client/src/ui/_common_/statusbars.vue
 delete mode 100644 packages/client/src/ui/_common_/stream-indicator.vue
 delete mode 100644 packages/client/src/ui/_common_/sw-inject.ts
 delete mode 100644 packages/client/src/ui/_common_/upload.vue
 delete mode 100644 packages/client/src/ui/classic.header.vue
 delete mode 100644 packages/client/src/ui/classic.sidebar.vue
 delete mode 100644 packages/client/src/ui/classic.vue
 delete mode 100644 packages/client/src/ui/classic.widgets.vue
 delete mode 100644 packages/client/src/ui/deck.vue
 delete mode 100644 packages/client/src/ui/deck/antenna-column.vue
 delete mode 100644 packages/client/src/ui/deck/column-core.vue
 delete mode 100644 packages/client/src/ui/deck/column.vue
 delete mode 100644 packages/client/src/ui/deck/deck-store.ts
 delete mode 100644 packages/client/src/ui/deck/direct-column.vue
 delete mode 100644 packages/client/src/ui/deck/list-column.vue
 delete mode 100644 packages/client/src/ui/deck/main-column.vue
 delete mode 100644 packages/client/src/ui/deck/mentions-column.vue
 delete mode 100644 packages/client/src/ui/deck/notifications-column.vue
 delete mode 100644 packages/client/src/ui/deck/tl-column.vue
 delete mode 100644 packages/client/src/ui/deck/widgets-column.vue
 delete mode 100644 packages/client/src/ui/universal.vue
 delete mode 100644 packages/client/src/ui/universal.widgets.vue
 delete mode 100644 packages/client/src/ui/visitor.vue
 delete mode 100644 packages/client/src/ui/visitor/a.vue
 delete mode 100644 packages/client/src/ui/visitor/b.vue
 delete mode 100644 packages/client/src/ui/visitor/header.vue
 delete mode 100644 packages/client/src/ui/visitor/kanban.vue
 delete mode 100644 packages/client/src/ui/zen.vue
 delete mode 100644 packages/client/src/widgets/activity.calendar.vue
 delete mode 100644 packages/client/src/widgets/activity.chart.vue
 delete mode 100644 packages/client/src/widgets/activity.vue
 delete mode 100644 packages/client/src/widgets/aichan.vue
 delete mode 100644 packages/client/src/widgets/aiscript.vue
 delete mode 100644 packages/client/src/widgets/button.vue
 delete mode 100644 packages/client/src/widgets/calendar.vue
 delete mode 100644 packages/client/src/widgets/clock.vue
 delete mode 100644 packages/client/src/widgets/digital-clock.vue
 delete mode 100644 packages/client/src/widgets/federation.vue
 delete mode 100644 packages/client/src/widgets/index.ts
 delete mode 100644 packages/client/src/widgets/instance-cloud.vue
 delete mode 100644 packages/client/src/widgets/job-queue.vue
 delete mode 100644 packages/client/src/widgets/memo.vue
 delete mode 100644 packages/client/src/widgets/notifications.vue
 delete mode 100644 packages/client/src/widgets/online-users.vue
 delete mode 100644 packages/client/src/widgets/photos.vue
 delete mode 100644 packages/client/src/widgets/post-form.vue
 delete mode 100644 packages/client/src/widgets/rss-ticker.vue
 delete mode 100644 packages/client/src/widgets/rss.vue
 delete mode 100644 packages/client/src/widgets/server-metric/cpu-mem.vue
 delete mode 100644 packages/client/src/widgets/server-metric/cpu.vue
 delete mode 100644 packages/client/src/widgets/server-metric/disk.vue
 delete mode 100644 packages/client/src/widgets/server-metric/index.vue
 delete mode 100644 packages/client/src/widgets/server-metric/mem.vue
 delete mode 100644 packages/client/src/widgets/server-metric/net.vue
 delete mode 100644 packages/client/src/widgets/server-metric/pie.vue
 delete mode 100644 packages/client/src/widgets/slideshow.vue
 delete mode 100644 packages/client/src/widgets/timeline.vue
 delete mode 100644 packages/client/src/widgets/trends.vue
 delete mode 100644 packages/client/src/widgets/unix-clock.vue
 delete mode 100644 packages/client/src/widgets/user-list.vue
 delete mode 100644 packages/client/src/widgets/widget.ts
 delete mode 100644 packages/client/tsconfig.json
 delete mode 100644 packages/client/vite.config.ts
 delete mode 100644 packages/client/vite.json5.ts
 create mode 100644 packages/frontend/.eslintrc.js
 create mode 100644 packages/frontend/.vscode/settings.json
 create mode 100644 packages/frontend/@types/global.d.ts
 create mode 100644 packages/frontend/@types/theme.d.ts
 create mode 100644 packages/frontend/@types/vue.d.ts
 create mode 100644 packages/frontend/assets/about-icon.png
 create mode 100644 packages/frontend/assets/dummy.png
 create mode 100644 packages/frontend/assets/fedi.jpg
 create mode 100644 packages/frontend/assets/label-red.svg
 create mode 100644 packages/frontend/assets/label.svg
 create mode 100644 packages/frontend/assets/misskey.svg
 create mode 100644 packages/frontend/assets/remove.png
 create mode 100644 packages/frontend/assets/sounds/aisha/1.mp3
 create mode 100644 packages/frontend/assets/sounds/aisha/2.mp3
 create mode 100644 packages/frontend/assets/sounds/aisha/3.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3
 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/down.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/kick.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori-wet.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/poi1.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/poi2.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/pope1.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/pope2.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/popo.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/queue-jammed.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/reverved.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/ryukyu.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/snare.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/square-pico.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/triple.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/up.mp3
 create mode 100644 packages/frontend/assets/sounds/syuilo/waon.mp3
 create mode 100644 packages/frontend/assets/tagcanvas.min.js
 create mode 100644 packages/frontend/assets/unread.svg
 create mode 100644 packages/frontend/package.json
 create mode 100644 packages/frontend/src/account.ts
 create mode 100644 packages/frontend/src/components/MkAbuseReport.vue
 create mode 100644 packages/frontend/src/components/MkAbuseReportWindow.vue
 create mode 100644 packages/frontend/src/components/MkActiveUsersHeatmap.vue
 create mode 100644 packages/frontend/src/components/MkAnalogClock.vue
 create mode 100644 packages/frontend/src/components/MkAutocomplete.vue
 create mode 100644 packages/frontend/src/components/MkAvatars.vue
 create mode 100644 packages/frontend/src/components/MkButton.vue
 create mode 100644 packages/frontend/src/components/MkCaptcha.vue
 create mode 100644 packages/frontend/src/components/MkChannelFollowButton.vue
 create mode 100644 packages/frontend/src/components/MkChannelPreview.vue
 create mode 100644 packages/frontend/src/components/MkChart.vue
 create mode 100644 packages/frontend/src/components/MkChartTooltip.vue
 create mode 100644 packages/frontend/src/components/MkCode.core.vue
 create mode 100644 packages/frontend/src/components/MkCode.vue
 create mode 100644 packages/frontend/src/components/MkContainer.vue
 create mode 100644 packages/frontend/src/components/MkContextMenu.vue
 create mode 100644 packages/frontend/src/components/MkCropperDialog.vue
 create mode 100644 packages/frontend/src/components/MkCwButton.vue
 create mode 100644 packages/frontend/src/components/MkDateSeparatedList.vue
 create mode 100644 packages/frontend/src/components/MkDialog.vue
 create mode 100644 packages/frontend/src/components/MkDigitalClock.vue
 create mode 100644 packages/frontend/src/components/MkDrive.file.vue
 create mode 100644 packages/frontend/src/components/MkDrive.folder.vue
 create mode 100644 packages/frontend/src/components/MkDrive.navFolder.vue
 create mode 100644 packages/frontend/src/components/MkDrive.vue
 create mode 100644 packages/frontend/src/components/MkDriveFileThumbnail.vue
 create mode 100644 packages/frontend/src/components/MkDriveSelectDialog.vue
 create mode 100644 packages/frontend/src/components/MkDriveWindow.vue
 create mode 100644 packages/frontend/src/components/MkEmojiPicker.section.vue
 create mode 100644 packages/frontend/src/components/MkEmojiPicker.vue
 create mode 100644 packages/frontend/src/components/MkEmojiPickerDialog.vue
 create mode 100644 packages/frontend/src/components/MkEmojiPickerWindow.vue
 create mode 100644 packages/frontend/src/components/MkFeaturedPhotos.vue
 create mode 100644 packages/frontend/src/components/MkFileCaptionEditWindow.vue
 create mode 100644 packages/frontend/src/components/MkFileListForAdmin.vue
 create mode 100644 packages/frontend/src/components/MkFolder.vue
 create mode 100644 packages/frontend/src/components/MkFollowButton.vue
 create mode 100644 packages/frontend/src/components/MkForgotPassword.vue
 create mode 100644 packages/frontend/src/components/MkFormDialog.vue
 create mode 100644 packages/frontend/src/components/MkFormula.vue
 create mode 100644 packages/frontend/src/components/MkFormulaCore.vue
 create mode 100644 packages/frontend/src/components/MkGalleryPostPreview.vue
 create mode 100644 packages/frontend/src/components/MkGoogle.vue
 create mode 100644 packages/frontend/src/components/MkImageViewer.vue
 create mode 100644 packages/frontend/src/components/MkImgWithBlurhash.vue
 create mode 100644 packages/frontend/src/components/MkInfo.vue
 create mode 100644 packages/frontend/src/components/MkInstanceCardMini.vue
 create mode 100644 packages/frontend/src/components/MkInstanceStats.vue
 create mode 100644 packages/frontend/src/components/MkInstanceTicker.vue
 create mode 100644 packages/frontend/src/components/MkKeyValue.vue
 create mode 100644 packages/frontend/src/components/MkLaunchPad.vue
 create mode 100644 packages/frontend/src/components/MkLink.vue
 create mode 100644 packages/frontend/src/components/MkMarquee.vue
 create mode 100644 packages/frontend/src/components/MkMediaBanner.vue
 create mode 100644 packages/frontend/src/components/MkMediaImage.vue
 create mode 100644 packages/frontend/src/components/MkMediaList.vue
 create mode 100644 packages/frontend/src/components/MkMediaVideo.vue
 create mode 100644 packages/frontend/src/components/MkMention.vue
 create mode 100644 packages/frontend/src/components/MkMenu.child.vue
 create mode 100644 packages/frontend/src/components/MkMenu.vue
 create mode 100644 packages/frontend/src/components/MkMiniChart.vue
 create mode 100644 packages/frontend/src/components/MkModal.vue
 create mode 100644 packages/frontend/src/components/MkModalPageWindow.vue
 create mode 100644 packages/frontend/src/components/MkModalWindow.vue
 create mode 100644 packages/frontend/src/components/MkNote.vue
 create mode 100644 packages/frontend/src/components/MkNoteDetailed.vue
 create mode 100644 packages/frontend/src/components/MkNoteHeader.vue
 create mode 100644 packages/frontend/src/components/MkNotePreview.vue
 create mode 100644 packages/frontend/src/components/MkNoteSimple.vue
 create mode 100644 packages/frontend/src/components/MkNoteSub.vue
 create mode 100644 packages/frontend/src/components/MkNotes.vue
 create mode 100644 packages/frontend/src/components/MkNotification.vue
 create mode 100644 packages/frontend/src/components/MkNotificationSettingWindow.vue
 create mode 100644 packages/frontend/src/components/MkNotificationToast.vue
 create mode 100644 packages/frontend/src/components/MkNotifications.vue
 create mode 100644 packages/frontend/src/components/MkNumberDiff.vue
 create mode 100644 packages/frontend/src/components/MkObjectView.value.vue
 create mode 100644 packages/frontend/src/components/MkObjectView.vue
 create mode 100644 packages/frontend/src/components/MkPagePreview.vue
 create mode 100644 packages/frontend/src/components/MkPageWindow.vue
 create mode 100644 packages/frontend/src/components/MkPagination.vue
 create mode 100644 packages/frontend/src/components/MkPoll.vue
 create mode 100644 packages/frontend/src/components/MkPollEditor.vue
 create mode 100644 packages/frontend/src/components/MkPopupMenu.vue
 create mode 100644 packages/frontend/src/components/MkPostForm.vue
 create mode 100644 packages/frontend/src/components/MkPostFormAttaches.vue
 create mode 100644 packages/frontend/src/components/MkPostFormDialog.vue
 create mode 100644 packages/frontend/src/components/MkPushNotificationAllowButton.vue
 create mode 100644 packages/frontend/src/components/MkReactionIcon.vue
 create mode 100644 packages/frontend/src/components/MkReactionTooltip.vue
 create mode 100644 packages/frontend/src/components/MkReactionsViewer.details.vue
 create mode 100644 packages/frontend/src/components/MkReactionsViewer.reaction.vue
 create mode 100644 packages/frontend/src/components/MkReactionsViewer.vue
 create mode 100644 packages/frontend/src/components/MkRemoteCaution.vue
 create mode 100644 packages/frontend/src/components/MkRenoteButton.vue
 create mode 100644 packages/frontend/src/components/MkRipple.vue
 create mode 100644 packages/frontend/src/components/MkSample.vue
 create mode 100644 packages/frontend/src/components/MkSignin.vue
 create mode 100644 packages/frontend/src/components/MkSigninDialog.vue
 create mode 100644 packages/frontend/src/components/MkSignup.vue
 create mode 100644 packages/frontend/src/components/MkSignupDialog.vue
 create mode 100644 packages/frontend/src/components/MkSparkle.vue
 create mode 100644 packages/frontend/src/components/MkSubNoteContent.vue
 create mode 100644 packages/frontend/src/components/MkSuperMenu.vue
 create mode 100644 packages/frontend/src/components/MkTab.vue
 create mode 100644 packages/frontend/src/components/MkTagCloud.vue
 create mode 100644 packages/frontend/src/components/MkTimeline.vue
 create mode 100644 packages/frontend/src/components/MkToast.vue
 create mode 100644 packages/frontend/src/components/MkTokenGenerateWindow.vue
 create mode 100644 packages/frontend/src/components/MkTooltip.vue
 create mode 100644 packages/frontend/src/components/MkUpdated.vue
 create mode 100644 packages/frontend/src/components/MkUrlPreview.vue
 create mode 100644 packages/frontend/src/components/MkUrlPreviewPopup.vue
 create mode 100644 packages/frontend/src/components/MkUserCardMini.vue
 create mode 100644 packages/frontend/src/components/MkUserInfo.vue
 create mode 100644 packages/frontend/src/components/MkUserList.vue
 create mode 100644 packages/frontend/src/components/MkUserOnlineIndicator.vue
 create mode 100644 packages/frontend/src/components/MkUserPreview.vue
 create mode 100644 packages/frontend/src/components/MkUserSelectDialog.vue
 create mode 100644 packages/frontend/src/components/MkUsersTooltip.vue
 create mode 100644 packages/frontend/src/components/MkVisibility.vue
 create mode 100644 packages/frontend/src/components/MkVisibilityPicker.vue
 create mode 100644 packages/frontend/src/components/MkWaitingDialog.vue
 create mode 100644 packages/frontend/src/components/MkWidgets.vue
 create mode 100644 packages/frontend/src/components/MkWindow.vue
 create mode 100644 packages/frontend/src/components/MkYoutubePlayer.vue
 create mode 100644 packages/frontend/src/components/form/checkbox.vue
 create mode 100644 packages/frontend/src/components/form/folder.vue
 create mode 100644 packages/frontend/src/components/form/input.vue
 create mode 100644 packages/frontend/src/components/form/link.vue
 create mode 100644 packages/frontend/src/components/form/radio.vue
 create mode 100644 packages/frontend/src/components/form/radios.vue
 create mode 100644 packages/frontend/src/components/form/range.vue
 create mode 100644 packages/frontend/src/components/form/section.vue
 create mode 100644 packages/frontend/src/components/form/select.vue
 create mode 100644 packages/frontend/src/components/form/slot.vue
 create mode 100644 packages/frontend/src/components/form/split.vue
 create mode 100644 packages/frontend/src/components/form/suspense.vue
 create mode 100644 packages/frontend/src/components/form/switch.vue
 create mode 100644 packages/frontend/src/components/form/textarea.vue
 create mode 100644 packages/frontend/src/components/global/MkA.vue
 create mode 100644 packages/frontend/src/components/global/MkAcct.vue
 create mode 100644 packages/frontend/src/components/global/MkAd.vue
 create mode 100644 packages/frontend/src/components/global/MkAvatar.vue
 create mode 100644 packages/frontend/src/components/global/MkEllipsis.vue
 create mode 100644 packages/frontend/src/components/global/MkEmoji.vue
 create mode 100644 packages/frontend/src/components/global/MkError.vue
 create mode 100644 packages/frontend/src/components/global/MkLoading.vue
 create mode 100644 packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
 create mode 100644 packages/frontend/src/components/global/MkPageHeader.vue
 create mode 100644 packages/frontend/src/components/global/MkSpacer.vue
 create mode 100644 packages/frontend/src/components/global/MkStickyContainer.vue
 create mode 100644 packages/frontend/src/components/global/MkTime.vue
 create mode 100644 packages/frontend/src/components/global/MkUrl.vue
 create mode 100644 packages/frontend/src/components/global/MkUserName.vue
 create mode 100644 packages/frontend/src/components/global/RouterView.vue
 create mode 100644 packages/frontend/src/components/global/i18n.ts
 create mode 100644 packages/frontend/src/components/index.ts
 create mode 100644 packages/frontend/src/components/mfm.ts
 create mode 100644 packages/frontend/src/components/page/page.block.vue
 create mode 100644 packages/frontend/src/components/page/page.button.vue
 create mode 100644 packages/frontend/src/components/page/page.canvas.vue
 create mode 100644 packages/frontend/src/components/page/page.counter.vue
 create mode 100644 packages/frontend/src/components/page/page.if.vue
 create mode 100644 packages/frontend/src/components/page/page.image.vue
 create mode 100644 packages/frontend/src/components/page/page.note.vue
 create mode 100644 packages/frontend/src/components/page/page.number-input.vue
 create mode 100644 packages/frontend/src/components/page/page.post.vue
 create mode 100644 packages/frontend/src/components/page/page.radio-button.vue
 create mode 100644 packages/frontend/src/components/page/page.section.vue
 create mode 100644 packages/frontend/src/components/page/page.switch.vue
 create mode 100644 packages/frontend/src/components/page/page.text-input.vue
 create mode 100644 packages/frontend/src/components/page/page.text.vue
 create mode 100644 packages/frontend/src/components/page/page.textarea-input.vue
 create mode 100644 packages/frontend/src/components/page/page.textarea.vue
 create mode 100644 packages/frontend/src/components/page/page.vue
 create mode 100644 packages/frontend/src/config.ts
 create mode 100644 packages/frontend/src/const.ts
 create mode 100644 packages/frontend/src/directives/adaptive-border.ts
 create mode 100644 packages/frontend/src/directives/anim.ts
 create mode 100644 packages/frontend/src/directives/appear.ts
 create mode 100644 packages/frontend/src/directives/click-anime.ts
 create mode 100644 packages/frontend/src/directives/follow-append.ts
 create mode 100644 packages/frontend/src/directives/get-size.ts
 create mode 100644 packages/frontend/src/directives/hotkey.ts
 create mode 100644 packages/frontend/src/directives/index.ts
 create mode 100644 packages/frontend/src/directives/panel.ts
 create mode 100644 packages/frontend/src/directives/ripple.ts
 create mode 100644 packages/frontend/src/directives/size.ts
 create mode 100644 packages/frontend/src/directives/tooltip.ts
 create mode 100644 packages/frontend/src/directives/user-preview.ts
 create mode 100644 packages/frontend/src/emojilist.json
 create mode 100644 packages/frontend/src/events.ts
 create mode 100644 packages/frontend/src/filters/bytes.ts
 create mode 100644 packages/frontend/src/filters/note.ts
 create mode 100644 packages/frontend/src/filters/number.ts
 create mode 100644 packages/frontend/src/filters/user.ts
 create mode 100644 packages/frontend/src/i18n.ts
 create mode 100644 packages/frontend/src/init.ts
 create mode 100644 packages/frontend/src/instance.ts
 create mode 100644 packages/frontend/src/navbar.ts
 create mode 100644 packages/frontend/src/nirax.ts
 create mode 100644 packages/frontend/src/os.ts
 create mode 100644 packages/frontend/src/pages/_empty_.vue
 create mode 100644 packages/frontend/src/pages/_error_.vue
 create mode 100644 packages/frontend/src/pages/_loading_.vue
 create mode 100644 packages/frontend/src/pages/about-misskey.vue
 create mode 100644 packages/frontend/src/pages/about.emojis.vue
 create mode 100644 packages/frontend/src/pages/about.federation.vue
 create mode 100644 packages/frontend/src/pages/about.vue
 create mode 100644 packages/frontend/src/pages/admin-file.vue
 create mode 100644 packages/frontend/src/pages/admin/_header_.vue
 create mode 100644 packages/frontend/src/pages/admin/abuses.vue
 create mode 100644 packages/frontend/src/pages/admin/ads.vue
 create mode 100644 packages/frontend/src/pages/admin/announcements.vue
 create mode 100644 packages/frontend/src/pages/admin/bot-protection.vue
 create mode 100644 packages/frontend/src/pages/admin/database.vue
 create mode 100644 packages/frontend/src/pages/admin/email-settings.vue
 create mode 100644 packages/frontend/src/pages/admin/emoji-edit-dialog.vue
 create mode 100644 packages/frontend/src/pages/admin/emojis.vue
 create mode 100644 packages/frontend/src/pages/admin/files.vue
 create mode 100644 packages/frontend/src/pages/admin/index.vue
 create mode 100644 packages/frontend/src/pages/admin/instance-block.vue
 create mode 100644 packages/frontend/src/pages/admin/integrations.discord.vue
 create mode 100644 packages/frontend/src/pages/admin/integrations.github.vue
 create mode 100644 packages/frontend/src/pages/admin/integrations.twitter.vue
 create mode 100644 packages/frontend/src/pages/admin/integrations.vue
 create mode 100644 packages/frontend/src/pages/admin/metrics.vue
 create mode 100644 packages/frontend/src/pages/admin/object-storage.vue
 create mode 100644 packages/frontend/src/pages/admin/other-settings.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.active-users.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.ap-requests.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.federation.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.heatmap.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.instances.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.moderators.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.pie.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.queue.chart.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.queue.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.retention.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.stats.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.users.vue
 create mode 100644 packages/frontend/src/pages/admin/overview.vue
 create mode 100644 packages/frontend/src/pages/admin/proxy-account.vue
 create mode 100644 packages/frontend/src/pages/admin/queue.chart.chart.vue
 create mode 100644 packages/frontend/src/pages/admin/queue.chart.vue
 create mode 100644 packages/frontend/src/pages/admin/queue.vue
 create mode 100644 packages/frontend/src/pages/admin/relays.vue
 create mode 100644 packages/frontend/src/pages/admin/security.vue
 create mode 100644 packages/frontend/src/pages/admin/settings.vue
 create mode 100644 packages/frontend/src/pages/admin/users.vue
 create mode 100644 packages/frontend/src/pages/announcements.vue
 create mode 100644 packages/frontend/src/pages/antenna-timeline.vue
 create mode 100644 packages/frontend/src/pages/api-console.vue
 create mode 100644 packages/frontend/src/pages/auth.form.vue
 create mode 100644 packages/frontend/src/pages/auth.vue
 create mode 100644 packages/frontend/src/pages/channel-editor.vue
 create mode 100644 packages/frontend/src/pages/channel.vue
 create mode 100644 packages/frontend/src/pages/channels.vue
 create mode 100644 packages/frontend/src/pages/clip.vue
 create mode 100644 packages/frontend/src/pages/drive.vue
 create mode 100644 packages/frontend/src/pages/emojis.emoji.vue
 create mode 100644 packages/frontend/src/pages/explore.featured.vue
 create mode 100644 packages/frontend/src/pages/explore.users.vue
 create mode 100644 packages/frontend/src/pages/explore.vue
 create mode 100644 packages/frontend/src/pages/favorites.vue
 create mode 100644 packages/frontend/src/pages/follow-requests.vue
 create mode 100644 packages/frontend/src/pages/follow.vue
 create mode 100644 packages/frontend/src/pages/gallery/edit.vue
 create mode 100644 packages/frontend/src/pages/gallery/index.vue
 create mode 100644 packages/frontend/src/pages/gallery/post.vue
 create mode 100644 packages/frontend/src/pages/instance-info.vue
 create mode 100644 packages/frontend/src/pages/messaging/index.vue
 create mode 100644 packages/frontend/src/pages/messaging/messaging-room.form.vue
 create mode 100644 packages/frontend/src/pages/messaging/messaging-room.message.vue
 create mode 100644 packages/frontend/src/pages/messaging/messaging-room.vue
 create mode 100644 packages/frontend/src/pages/mfm-cheat-sheet.vue
 create mode 100644 packages/frontend/src/pages/miauth.vue
 create mode 100644 packages/frontend/src/pages/my-antennas/create.vue
 create mode 100644 packages/frontend/src/pages/my-antennas/edit.vue
 create mode 100644 packages/frontend/src/pages/my-antennas/editor.vue
 create mode 100644 packages/frontend/src/pages/my-antennas/index.vue
 create mode 100644 packages/frontend/src/pages/my-clips/index.vue
 create mode 100644 packages/frontend/src/pages/my-lists/index.vue
 create mode 100644 packages/frontend/src/pages/my-lists/list.vue
 create mode 100644 packages/frontend/src/pages/not-found.vue
 create mode 100644 packages/frontend/src/pages/note.vue
 create mode 100644 packages/frontend/src/pages/notifications.vue
 create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
 create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
 create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
 create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
 create mode 100644 packages/frontend/src/pages/page-editor/page-editor.blocks.vue
 create mode 100644 packages/frontend/src/pages/page-editor/page-editor.container.vue
 create mode 100644 packages/frontend/src/pages/page-editor/page-editor.vue
 create mode 100644 packages/frontend/src/pages/page.vue
 create mode 100644 packages/frontend/src/pages/pages.vue
 create mode 100644 packages/frontend/src/pages/preview.vue
 create mode 100644 packages/frontend/src/pages/registry.keys.vue
 create mode 100644 packages/frontend/src/pages/registry.value.vue
 create mode 100644 packages/frontend/src/pages/registry.vue
 create mode 100644 packages/frontend/src/pages/reset-password.vue
 create mode 100644 packages/frontend/src/pages/scratchpad.vue
 create mode 100644 packages/frontend/src/pages/search.vue
 create mode 100644 packages/frontend/src/pages/settings/2fa.vue
 create mode 100644 packages/frontend/src/pages/settings/account-info.vue
 create mode 100644 packages/frontend/src/pages/settings/accounts.vue
 create mode 100644 packages/frontend/src/pages/settings/api.vue
 create mode 100644 packages/frontend/src/pages/settings/apps.vue
 create mode 100644 packages/frontend/src/pages/settings/custom-css.vue
 create mode 100644 packages/frontend/src/pages/settings/deck.vue
 create mode 100644 packages/frontend/src/pages/settings/delete-account.vue
 create mode 100644 packages/frontend/src/pages/settings/drive.vue
 create mode 100644 packages/frontend/src/pages/settings/email.vue
 create mode 100644 packages/frontend/src/pages/settings/general.vue
 create mode 100644 packages/frontend/src/pages/settings/import-export.vue
 create mode 100644 packages/frontend/src/pages/settings/index.vue
 create mode 100644 packages/frontend/src/pages/settings/instance-mute.vue
 create mode 100644 packages/frontend/src/pages/settings/integration.vue
 create mode 100644 packages/frontend/src/pages/settings/mute-block.vue
 create mode 100644 packages/frontend/src/pages/settings/navbar.vue
 create mode 100644 packages/frontend/src/pages/settings/notifications.vue
 create mode 100644 packages/frontend/src/pages/settings/other.vue
 create mode 100644 packages/frontend/src/pages/settings/plugin.install.vue
 create mode 100644 packages/frontend/src/pages/settings/plugin.vue
 create mode 100644 packages/frontend/src/pages/settings/preferences-backups.vue
 create mode 100644 packages/frontend/src/pages/settings/privacy.vue
 create mode 100644 packages/frontend/src/pages/settings/profile.vue
 create mode 100644 packages/frontend/src/pages/settings/reaction.vue
 create mode 100644 packages/frontend/src/pages/settings/security.vue
 create mode 100644 packages/frontend/src/pages/settings/sounds.sound.vue
 create mode 100644 packages/frontend/src/pages/settings/sounds.vue
 create mode 100644 packages/frontend/src/pages/settings/statusbar.statusbar.vue
 create mode 100644 packages/frontend/src/pages/settings/statusbar.vue
 create mode 100644 packages/frontend/src/pages/settings/theme.install.vue
 create mode 100644 packages/frontend/src/pages/settings/theme.manage.vue
 create mode 100644 packages/frontend/src/pages/settings/theme.vue
 create mode 100644 packages/frontend/src/pages/settings/webhook.edit.vue
 create mode 100644 packages/frontend/src/pages/settings/webhook.new.vue
 create mode 100644 packages/frontend/src/pages/settings/webhook.vue
 create mode 100644 packages/frontend/src/pages/settings/word-mute.vue
 create mode 100644 packages/frontend/src/pages/share.vue
 create mode 100644 packages/frontend/src/pages/signup-complete.vue
 create mode 100644 packages/frontend/src/pages/tag.vue
 create mode 100644 packages/frontend/src/pages/theme-editor.vue
 create mode 100644 packages/frontend/src/pages/timeline.tutorial.vue
 create mode 100644 packages/frontend/src/pages/timeline.vue
 create mode 100644 packages/frontend/src/pages/user-info.vue
 create mode 100644 packages/frontend/src/pages/user-list-timeline.vue
 create mode 100644 packages/frontend/src/pages/user/clips.vue
 create mode 100644 packages/frontend/src/pages/user/follow-list.vue
 create mode 100644 packages/frontend/src/pages/user/followers.vue
 create mode 100644 packages/frontend/src/pages/user/following.vue
 create mode 100644 packages/frontend/src/pages/user/gallery.vue
 create mode 100644 packages/frontend/src/pages/user/home.vue
 create mode 100644 packages/frontend/src/pages/user/index.activity.vue
 create mode 100644 packages/frontend/src/pages/user/index.photos.vue
 create mode 100644 packages/frontend/src/pages/user/index.timeline.vue
 create mode 100644 packages/frontend/src/pages/user/index.vue
 create mode 100644 packages/frontend/src/pages/user/pages.vue
 create mode 100644 packages/frontend/src/pages/user/reactions.vue
 create mode 100644 packages/frontend/src/pages/welcome.entrance.a.vue
 create mode 100644 packages/frontend/src/pages/welcome.entrance.b.vue
 create mode 100644 packages/frontend/src/pages/welcome.entrance.c.vue
 create mode 100644 packages/frontend/src/pages/welcome.setup.vue
 create mode 100644 packages/frontend/src/pages/welcome.timeline.vue
 create mode 100644 packages/frontend/src/pages/welcome.vue
 create mode 100644 packages/frontend/src/pizzax.ts
 create mode 100644 packages/frontend/src/plugin.ts
 create mode 100644 packages/frontend/src/router.ts
 create mode 100644 packages/frontend/src/scripts/2fa.ts
 create mode 100644 packages/frontend/src/scripts/aiscript/api.ts
 create mode 100644 packages/frontend/src/scripts/array.ts
 create mode 100644 packages/frontend/src/scripts/autocomplete.ts
 create mode 100644 packages/frontend/src/scripts/chart-vline.ts
 create mode 100644 packages/frontend/src/scripts/check-word-mute.ts
 create mode 100644 packages/frontend/src/scripts/clone.ts
 create mode 100644 packages/frontend/src/scripts/collect-page-vars.ts
 create mode 100644 packages/frontend/src/scripts/contains.ts
 create mode 100644 packages/frontend/src/scripts/copy-to-clipboard.ts
 create mode 100644 packages/frontend/src/scripts/device-kind.ts
 create mode 100644 packages/frontend/src/scripts/emoji-base.ts
 create mode 100644 packages/frontend/src/scripts/emojilist.ts
 create mode 100644 packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts
 create mode 100644 packages/frontend/src/scripts/extract-mentions.ts
 create mode 100644 packages/frontend/src/scripts/extract-url-from-mfm.ts
 create mode 100644 packages/frontend/src/scripts/focus.ts
 create mode 100644 packages/frontend/src/scripts/form.ts
 create mode 100644 packages/frontend/src/scripts/format-time-string.ts
 create mode 100644 packages/frontend/src/scripts/gen-search-query.ts
 create mode 100644 packages/frontend/src/scripts/get-account-from-id.ts
 create mode 100644 packages/frontend/src/scripts/get-note-menu.ts
 create mode 100644 packages/frontend/src/scripts/get-note-summary.ts
 create mode 100644 packages/frontend/src/scripts/get-static-image-url.ts
 create mode 100644 packages/frontend/src/scripts/get-user-menu.ts
 create mode 100644 packages/frontend/src/scripts/get-user-name.ts
 create mode 100644 packages/frontend/src/scripts/hotkey.ts
 create mode 100644 packages/frontend/src/scripts/hpml/block.ts
 create mode 100644 packages/frontend/src/scripts/hpml/evaluator.ts
 create mode 100644 packages/frontend/src/scripts/hpml/expr.ts
 create mode 100644 packages/frontend/src/scripts/hpml/index.ts
 create mode 100644 packages/frontend/src/scripts/hpml/lib.ts
 create mode 100644 packages/frontend/src/scripts/hpml/type-checker.ts
 create mode 100644 packages/frontend/src/scripts/i18n.ts
 create mode 100644 packages/frontend/src/scripts/idb-proxy.ts
 create mode 100644 packages/frontend/src/scripts/initialize-sw.ts
 create mode 100644 packages/frontend/src/scripts/is-device-darkmode.ts
 create mode 100644 packages/frontend/src/scripts/keycode.ts
 create mode 100644 packages/frontend/src/scripts/langmap.ts
 create mode 100644 packages/frontend/src/scripts/login-id.ts
 create mode 100644 packages/frontend/src/scripts/lookup-user.ts
 create mode 100644 packages/frontend/src/scripts/media-proxy.ts
 create mode 100644 packages/frontend/src/scripts/mfm-tags.ts
 create mode 100644 packages/frontend/src/scripts/page-metadata.ts
 create mode 100644 packages/frontend/src/scripts/physics.ts
 create mode 100644 packages/frontend/src/scripts/please-login.ts
 create mode 100644 packages/frontend/src/scripts/popout.ts
 create mode 100644 packages/frontend/src/scripts/popup-position.ts
 create mode 100644 packages/frontend/src/scripts/reaction-picker.ts
 create mode 100644 packages/frontend/src/scripts/safe-uri-decode.ts
 create mode 100644 packages/frontend/src/scripts/scroll.ts
 create mode 100644 packages/frontend/src/scripts/search.ts
 create mode 100644 packages/frontend/src/scripts/select-file.ts
 create mode 100644 packages/frontend/src/scripts/show-suspended-dialog.ts
 create mode 100644 packages/frontend/src/scripts/shuffle.ts
 create mode 100644 packages/frontend/src/scripts/sound.ts
 create mode 100644 packages/frontend/src/scripts/sticky-sidebar.ts
 create mode 100644 packages/frontend/src/scripts/theme-editor.ts
 create mode 100644 packages/frontend/src/scripts/theme.ts
 create mode 100644 packages/frontend/src/scripts/time.ts
 create mode 100644 packages/frontend/src/scripts/timezones.ts
 create mode 100644 packages/frontend/src/scripts/touch.ts
 create mode 100644 packages/frontend/src/scripts/unison-reload.ts
 create mode 100644 packages/frontend/src/scripts/upload.ts
 create mode 100644 packages/frontend/src/scripts/upload/compress-config.ts
 create mode 100644 packages/frontend/src/scripts/url.ts
 create mode 100644 packages/frontend/src/scripts/use-chart-tooltip.ts
 create mode 100644 packages/frontend/src/scripts/use-interval.ts
 create mode 100644 packages/frontend/src/scripts/use-leave-guard.ts
 create mode 100644 packages/frontend/src/scripts/use-note-capture.ts
 create mode 100644 packages/frontend/src/scripts/use-tooltip.ts
 create mode 100644 packages/frontend/src/store.ts
 create mode 100644 packages/frontend/src/stream.ts
 create mode 100644 packages/frontend/src/style.scss
 create mode 100644 packages/frontend/src/theme-store.ts
 create mode 100644 packages/frontend/src/themes/_dark.json5
 create mode 100644 packages/frontend/src/themes/_light.json5
 create mode 100644 packages/frontend/src/themes/d-astro.json5
 create mode 100644 packages/frontend/src/themes/d-botanical.json5
 create mode 100644 packages/frontend/src/themes/d-cherry.json5
 create mode 100644 packages/frontend/src/themes/d-dark.json5
 create mode 100644 packages/frontend/src/themes/d-future.json5
 create mode 100644 packages/frontend/src/themes/d-green-lime.json5
 create mode 100644 packages/frontend/src/themes/d-green-orange.json5
 create mode 100644 packages/frontend/src/themes/d-ice.json5
 create mode 100644 packages/frontend/src/themes/d-persimmon.json5
 create mode 100644 packages/frontend/src/themes/d-u0.json5
 create mode 100644 packages/frontend/src/themes/l-apricot.json5
 create mode 100644 packages/frontend/src/themes/l-cherry.json5
 create mode 100644 packages/frontend/src/themes/l-coffee.json5
 create mode 100644 packages/frontend/src/themes/l-light.json5
 create mode 100644 packages/frontend/src/themes/l-rainy.json5
 create mode 100644 packages/frontend/src/themes/l-sushi.json5
 create mode 100644 packages/frontend/src/themes/l-u0.json5
 create mode 100644 packages/frontend/src/themes/l-vivid.json5
 create mode 100644 packages/frontend/src/types/menu.ts
 create mode 100644 packages/frontend/src/ui/_common_/common.vue
 create mode 100644 packages/frontend/src/ui/_common_/navbar-for-mobile.vue
 create mode 100644 packages/frontend/src/ui/_common_/navbar.vue
 create mode 100644 packages/frontend/src/ui/_common_/statusbar-federation.vue
 create mode 100644 packages/frontend/src/ui/_common_/statusbar-rss.vue
 create mode 100644 packages/frontend/src/ui/_common_/statusbar-user-list.vue
 create mode 100644 packages/frontend/src/ui/_common_/statusbars.vue
 create mode 100644 packages/frontend/src/ui/_common_/stream-indicator.vue
 create mode 100644 packages/frontend/src/ui/_common_/sw-inject.ts
 create mode 100644 packages/frontend/src/ui/_common_/upload.vue
 create mode 100644 packages/frontend/src/ui/classic.header.vue
 create mode 100644 packages/frontend/src/ui/classic.sidebar.vue
 create mode 100644 packages/frontend/src/ui/classic.vue
 create mode 100644 packages/frontend/src/ui/classic.widgets.vue
 create mode 100644 packages/frontend/src/ui/deck.vue
 create mode 100644 packages/frontend/src/ui/deck/antenna-column.vue
 create mode 100644 packages/frontend/src/ui/deck/column-core.vue
 create mode 100644 packages/frontend/src/ui/deck/column.vue
 create mode 100644 packages/frontend/src/ui/deck/deck-store.ts
 create mode 100644 packages/frontend/src/ui/deck/direct-column.vue
 create mode 100644 packages/frontend/src/ui/deck/list-column.vue
 create mode 100644 packages/frontend/src/ui/deck/main-column.vue
 create mode 100644 packages/frontend/src/ui/deck/mentions-column.vue
 create mode 100644 packages/frontend/src/ui/deck/notifications-column.vue
 create mode 100644 packages/frontend/src/ui/deck/tl-column.vue
 create mode 100644 packages/frontend/src/ui/deck/widgets-column.vue
 create mode 100644 packages/frontend/src/ui/universal.vue
 create mode 100644 packages/frontend/src/ui/universal.widgets.vue
 create mode 100644 packages/frontend/src/ui/visitor.vue
 create mode 100644 packages/frontend/src/ui/visitor/a.vue
 create mode 100644 packages/frontend/src/ui/visitor/b.vue
 create mode 100644 packages/frontend/src/ui/visitor/header.vue
 create mode 100644 packages/frontend/src/ui/visitor/kanban.vue
 create mode 100644 packages/frontend/src/ui/zen.vue
 create mode 100644 packages/frontend/src/widgets/activity.calendar.vue
 create mode 100644 packages/frontend/src/widgets/activity.chart.vue
 create mode 100644 packages/frontend/src/widgets/activity.vue
 create mode 100644 packages/frontend/src/widgets/aichan.vue
 create mode 100644 packages/frontend/src/widgets/aiscript.vue
 create mode 100644 packages/frontend/src/widgets/button.vue
 create mode 100644 packages/frontend/src/widgets/calendar.vue
 create mode 100644 packages/frontend/src/widgets/clock.vue
 create mode 100644 packages/frontend/src/widgets/digital-clock.vue
 create mode 100644 packages/frontend/src/widgets/federation.vue
 create mode 100644 packages/frontend/src/widgets/index.ts
 create mode 100644 packages/frontend/src/widgets/instance-cloud.vue
 create mode 100644 packages/frontend/src/widgets/job-queue.vue
 create mode 100644 packages/frontend/src/widgets/memo.vue
 create mode 100644 packages/frontend/src/widgets/notifications.vue
 create mode 100644 packages/frontend/src/widgets/online-users.vue
 create mode 100644 packages/frontend/src/widgets/photos.vue
 create mode 100644 packages/frontend/src/widgets/post-form.vue
 create mode 100644 packages/frontend/src/widgets/rss-ticker.vue
 create mode 100644 packages/frontend/src/widgets/rss.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/cpu-mem.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/cpu.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/disk.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/index.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/mem.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/net.vue
 create mode 100644 packages/frontend/src/widgets/server-metric/pie.vue
 create mode 100644 packages/frontend/src/widgets/slideshow.vue
 create mode 100644 packages/frontend/src/widgets/timeline.vue
 create mode 100644 packages/frontend/src/widgets/trends.vue
 create mode 100644 packages/frontend/src/widgets/unix-clock.vue
 create mode 100644 packages/frontend/src/widgets/user-list.vue
 create mode 100644 packages/frontend/src/widgets/widget.ts
 create mode 100644 packages/frontend/tsconfig.json
 create mode 100644 packages/frontend/vite.config.ts
 create mode 100644 packages/frontend/vite.json5.ts

(limited to 'packages/client/src/components/MkUserSelectDialog.vue')

diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2625cf75d3..0d287327a7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -16,7 +16,7 @@ updates:
     interval: daily
   open-pull-requests-limit: 0
 - package-ecosystem: npm
-  directory: "/packages/client"
+  directory: "/packages/frontend"
   schedule:
     interval: daily
   open-pull-requests-limit: 0
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 98f1d2e383..b4fd0dd5df 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -2,7 +2,7 @@
 - packages/backend/**/*
 
 '🖥️Client':
-- packages/client/**/*
+- packages/frontend/**/*
 
 '🧪Test':
 - cypress/**/*
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b5c801c642..a0e84a0985 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -30,7 +30,7 @@ jobs:
       matrix:
         workspace:
         - backend
-        - client
+        - frontend
         - sw
     steps:
     - uses: actions/checkout@v2
diff --git a/.gitignore b/.gitignore
index db3681551e..bfccdd46cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,7 +16,7 @@ report.*.json
 !.yarn/releases
 !.yarn/sdks
 !.yarn/versions
-packages/client/.yarn/cache
+packages/frontend/.yarn/cache
 packages/backend/.yarn/cache
 packages/sw/.yarn/cache
 
diff --git a/Dockerfile b/Dockerfile
index de9ec3e1c7..25cec42a9b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,7 @@ COPY [".yarnrc.yml", "package.json", "yarn.lock", "./"]
 COPY [".yarn", "./.yarn"]
 COPY ["scripts", "./scripts"]
 COPY ["packages/backend/package.json", "./packages/backend/"]
-COPY ["packages/client/package.json", "./packages/client/"]
+COPY ["packages/frontend/package.json", "./packages/frontend/"]
 COPY ["packages/sw/package.json", "./packages/sw/"]
 
 RUN yarn install --immutable
@@ -37,7 +37,7 @@ COPY --from=builder /misskey/node_modules ./node_modules
 COPY --from=builder /misskey/built ./built
 COPY --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules
 COPY --from=builder /misskey/packages/backend/built ./packages/backend/built
-COPY --from=builder /misskey/packages/client/node_modules ./packages/client/node_modules
+COPY --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules
 COPY . ./
 
 ENV NODE_ENV=production
diff --git a/gulpfile.js b/gulpfile.js
index 07a8a99d69..d567e8bf6c 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -15,21 +15,21 @@ gulp.task('copy:backend:views', () =>
 	gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views'))
 );
 
-gulp.task('copy:client:fonts', () =>
-	gulp.src('./packages/client/node_modules/three/examples/fonts/**/*').pipe(gulp.dest('./built/_client_dist_/fonts/'))
+gulp.task('copy:frontend:fonts', () =>
+	gulp.src('./packages/frontend/node_modules/three/examples/fonts/**/*').pipe(gulp.dest('./built/_frontend_dist_/fonts/'))
 );
 
-gulp.task('copy:client:tabler-icons', () =>
-	gulp.src('./packages/client/node_modules/@tabler/icons/iconfont/**/*').pipe(gulp.dest('./built/_client_dist_/tabler-icons/'))
+gulp.task('copy:frontend:tabler-icons', () =>
+	gulp.src('./packages/frontend/node_modules/@tabler/icons/iconfont/**/*').pipe(gulp.dest('./built/_frontend_dist_/tabler-icons/'))
 );
 
-gulp.task('copy:client:locales', cb => {
-	fs.mkdirSync('./built/_client_dist_/locales', { recursive: true });
+gulp.task('copy:frontend:locales', cb => {
+	fs.mkdirSync('./built/_frontend_dist_/locales', { recursive: true });
 
 	const v = { '_version_': meta.version };
 
 	for (const [lang, locale] of Object.entries(locales)) {
-		fs.writeFileSync(`./built/_client_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
+		fs.writeFileSync(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
 	}
 
 	cb();
@@ -53,7 +53,7 @@ gulp.task('build:backend:style', () => {
 });
 
 gulp.task('build', gulp.parallel(
-	'copy:client:locales', 'copy:backend:views', 'build:backend:script', 'build:backend:style', 'copy:client:fonts', 'copy:client:tabler-icons'
+	'copy:frontend:locales', 'copy:backend:views', 'build:backend:script', 'build:backend:style', 'copy:frontend:fonts', 'copy:frontend:tabler-icons'
 ));
 
 gulp.task('default', gulp.task('build'));
diff --git a/package.json b/package.json
index 8013f6cb1f..25e0d15297 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
 	},
 	"packageManager": "yarn@3.3.0",
 	"workspaces": [
-		"packages/client",
+		"packages/frontend",
 		"packages/backend",
 		"packages/sw"
 	],
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 97acfcb919..727bbc9d7b 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -38,8 +38,8 @@ const _filename = fileURLToPath(import.meta.url);
 const _dirname = dirname(_filename);
 
 const staticAssets = `${_dirname}/../../../assets/`;
-const clientAssets = `${_dirname}/../../../../client/assets/`;
-const assets = `${_dirname}/../../../../../built/_client_dist_/`;
+const clientAssets = `${_dirname}/../../../../frontend/assets/`;
+const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
 const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
 const viteOut = `${_dirname}/../../../../../built/_vite_/`;
 
diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js
deleted file mode 100644
index 6c3bfb5a6e..0000000000
--- a/packages/client/.eslintrc.js
+++ /dev/null
@@ -1,89 +0,0 @@
-module.exports = {
-	root: true,
-	env: {
-		'node': false,
-	},
-	parser: 'vue-eslint-parser',
-	parserOptions: {
-		'parser': '@typescript-eslint/parser',
-		tsconfigRootDir: __dirname,
-		project: ['./tsconfig.json'],
-		extraFileExtensions: ['.vue'],
-	},
-	extends: [
-		'../shared/.eslintrc.js',
-		'plugin:vue/vue3-recommended',
-	],
-	rules: {
-		'@typescript-eslint/no-empty-interface': [
-			'error',
-			{
-				'allowSingleExtends': true,
-			},
-		],
-		'@typescript-eslint/prefer-nullish-coalescing': [
-			'error',
-		],
-		// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
-		// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
-		'id-denylist': ['error', 'window', 'e'],
-		'no-shadow': ['warn'],
-		'vue/attributes-order': ['error', {
-			'alphabetical': false,
-		}],
-		'vue/no-use-v-if-with-v-for': ['error', {
-			'allowUsingIterationVar': false,
-		}],
-		'vue/no-ref-as-operand': 'error',
-		'vue/no-multi-spaces': ['error', {
-			'ignoreProperties': false,
-		}],
-		'vue/no-v-html': 'warn',
-		'vue/order-in-components': 'error',
-		'vue/html-indent': ['warn', 'tab', {
-			'attribute': 1,
-			'baseIndent': 0,
-			'closeBracket': 0,
-			'alignAttributesVertically': true,
-			'ignores': [],
-		}],
-		'vue/html-closing-bracket-spacing': ['warn', {
-			'startTag': 'never',
-			'endTag': 'never',
-			'selfClosingTag': 'never',
-		}],
-		'vue/multi-word-component-names': 'warn',
-		'vue/require-v-for-key': 'warn',
-		'vue/no-unused-components': 'warn',
-		'vue/valid-v-for': 'warn',
-		'vue/return-in-computed-property': 'warn',
-		'vue/no-setup-props-destructure': 'warn',
-		'vue/max-attributes-per-line': 'off',
-		'vue/html-self-closing': 'off',
-		'vue/singleline-html-element-content-newline': 'off',
-		// (vue/vue3-recommended disabled the autofix for Vue 2 compatibility)
-		'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }],
-	},
-	globals: {
-		// Node.js
-		'module': false,
-		'require': false,
-		'__dirname': false,
-
-		// Vue
-		'$$': false,
-		'$ref': false,
-		'$shallowRef': false,
-		'$computed': false,
-
-		// Misskey
-		'_DEV_': false,
-		'_LANGS_': false,
-		'_VERSION_': false,
-		'_ENV_': false,
-		'_PERF_PREFIX_': false,
-		'_DATA_TRANSFER_DRIVE_FILE_': false,
-		'_DATA_TRANSFER_DRIVE_FOLDER_': false,
-		'_DATA_TRANSFER_DECK_COLUMN_': false,
-	},
-};
diff --git a/packages/client/.vscode/settings.json b/packages/client/.vscode/settings.json
deleted file mode 100644
index 4b0903b763..0000000000
--- a/packages/client/.vscode/settings.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-	"typescript.tsdk": "node_modules\\typescript\\lib",
-	"path-intellisense.mappings": {
-		"@": "${workspaceRoot}/packages/client/src/"
-	},
-	"eslint.validate": [
-		"javascript",
-		"javascriptreact",
-		"vue"
-	]
-}
diff --git a/packages/client/@types/global.d.ts b/packages/client/@types/global.d.ts
deleted file mode 100644
index c757482900..0000000000
--- a/packages/client/@types/global.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-type FIXME = any;
-
-declare const _LANGS_: string[][];
-declare const _VERSION_: string;
-declare const _ENV_: string;
-declare const _DEV_: boolean;
-declare const _PERF_PREFIX_: string;
-declare const _DATA_TRANSFER_DRIVE_FILE_: string;
-declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
-declare const _DATA_TRANSFER_DECK_COLUMN_: string;
diff --git a/packages/client/@types/theme.d.ts b/packages/client/@types/theme.d.ts
deleted file mode 100644
index 67f724a9aa..0000000000
--- a/packages/client/@types/theme.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-declare module '@/themes/*.json5' {
-	import { Theme } from "@/scripts/theme";
-
-	const theme: Theme;
-
-	export default theme;
-}
diff --git a/packages/client/@types/vue.d.ts b/packages/client/@types/vue.d.ts
deleted file mode 100644
index 9c9c34ccc5..0000000000
--- a/packages/client/@types/vue.d.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/// <reference types="vue/macros-global" />
-
-import type { $i } from '@/account';
-import type { defaultStore } from '@/store';
-import type { instance } from '@/instance';
-import type { i18n } from '@/i18n';
-
-declare module 'vue' {
-	interface ComponentCustomProperties {
-		$i: typeof $i;
-		$store: typeof defaultStore;
-		$instance: typeof instance;
-		$t: typeof i18n['t'];
-		$ts: typeof i18n['ts'];
-	}
-}
diff --git a/packages/client/assets/about-icon.png b/packages/client/assets/about-icon.png
deleted file mode 100644
index afc1f0c728..0000000000
Binary files a/packages/client/assets/about-icon.png and /dev/null differ
diff --git a/packages/client/assets/dummy.png b/packages/client/assets/dummy.png
deleted file mode 100644
index 39332b0c1b..0000000000
Binary files a/packages/client/assets/dummy.png and /dev/null differ
diff --git a/packages/client/assets/fedi.jpg b/packages/client/assets/fedi.jpg
deleted file mode 100644
index cbf3748eb8..0000000000
Binary files a/packages/client/assets/fedi.jpg and /dev/null differ
diff --git a/packages/client/assets/label-red.svg b/packages/client/assets/label-red.svg
deleted file mode 100644
index 45996aa9ce..0000000000
--- a/packages/client/assets/label-red.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
-	 y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
-<polygon fill="#ea2412" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
-</svg>
diff --git a/packages/client/assets/label.svg b/packages/client/assets/label.svg
deleted file mode 100644
index b1f85f3c07..0000000000
--- a/packages/client/assets/label.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
-	 y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
-<polygon fill="#0B8AEA" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
-</svg>
diff --git a/packages/client/assets/misskey.svg b/packages/client/assets/misskey.svg
deleted file mode 100644
index 3fcb2d3ecb..0000000000
--- a/packages/client/assets/misskey.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="515px" height="136px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
-    <g id="path2" transform="matrix(0.264585,0,0,0.264585,-50.0005,-50.0001)">
-        <path d="M256.418,188.976C248.558,188.944 240.758,190.308 233.379,193.013C220.308,197.613 209.533,205.888 201.091,217.802C193.02,229.329 188.977,242.195 188.977,256.409L188.977,508.89C188.977,527.332 195.52,543.29 208.576,556.732C222.032,569.803 237.99,576.331 256.418,576.331C275.259,576.331 291.204,569.803 304.274,556.747C317.73,543.291 324.441,527.332 324.441,508.89L324.441,462.983C324.584,453.04 334.824,455.655 340.01,462.983C349.695,479.767 370.251,494.19 394.193,494.105L394.193,494.119C418.119,494.019 438.005,482.196 448.375,462.983C452.304,458.354 463.377,450.455 464.52,462.983L464.52,508.89C464.52,527.332 471.047,543.29 484.104,556.732C497.574,569.803 513.511,576.331 531.953,576.331C550.78,576.331 566.739,569.803 579.809,556.747C593.265,543.291 599.977,527.332 599.977,508.89L599.977,256.409C599.977,242.195 595.752,229.329 587.309,217.802C579.224,205.874 568.653,197.613 555.597,193.013C547.912,190.314 540.228,188.976 532.543,188.976C511.788,188.976 494.301,197.046 480.073,213.188L411.636,293.281C410.107,294.438 405.006,303.247 394.178,303.247C383.379,303.247 378.868,294.439 377.325,293.296L308.297,213.188C294.47,197.046 277.173,188.976 256.418,188.976ZM682.904,188.983C666.763,188.983 652.926,194.748 641.404,206.271C630.261,217.413 624.691,231.054 624.691,247.196C624.691,263.338 630.261,277.174 641.404,288.697C652.926,299.839 666.763,305.41 682.904,305.41C699.046,305.41 712.88,299.839 724.412,288.697C735.935,277.174 741.693,263.338 741.693,247.196C741.693,231.054 735.935,217.413 724.412,206.271C712.88,194.748 699.046,188.983 682.904,188.983ZM1327.58,193.013C1311.45,193.013 1297.62,198.779 1286.1,210.302C1274.96,221.444 1269.38,235.08 1269.38,251.212L1269.38,519.72C1269.38,535.861 1274.96,549.697 1286.1,561.22C1297.62,572.362 1311.45,577.933 1327.58,577.933C1343.73,577.933 1357.55,572.362 1369.08,561.22C1380.6,549.697 1386.37,535.867 1386.37,519.734C1386.37,508.211 1387.9,502.453 1390.96,502.453C1392.51,502.453 1394.05,503.023 1395.58,504.166L1453.2,560.061C1464.72,571.203 1478.36,576.774 1494.11,576.774C1510.62,576.774 1524.45,571.009 1535.6,559.486C1546.74,547.572 1552.31,533.936 1552.31,518.575C1552.31,502.053 1546.36,488.029 1534.45,476.506C1508.32,450.765 1494.69,437.517 1493.54,436.755C1488.54,431.755 1488.73,427.53 1494.11,424.073L1495.27,423.497L1495.27,422.929L1531.57,399.875C1548.85,388.352 1557.5,372.026 1557.5,350.904C1557.5,339.381 1554.42,328.622 1548.28,318.623C1536.76,301.339 1520.43,292.691 1499.3,292.691C1487.78,292.691 1477.02,295.768 1467.04,301.911C1422.48,331.499 1399.42,346.678 1397.88,347.449C1395.2,349.363 1392.7,349.738 1390.4,348.586C1387.7,347.434 1386.35,344.939 1386.35,341.101L1386.35,251.212C1386.35,235.08 1380.59,221.444 1369.07,210.302C1357.55,198.779 1343.72,193.013 1327.58,193.013ZM1716.37,291.738C1676.42,291.738 1642.24,305.949 1613.81,334.376C1585.76,362.422 1571.74,396.227 1571.74,435.795C1571.74,475.745 1585.76,509.932 1613.81,538.359C1642.24,566.404 1676.42,580.428 1716.37,580.428C1755.94,580.428 1789.94,566.404 1818.37,538.359C1827.2,529.521 1831.62,518.773 1831.62,506.107C1831.62,493.423 1827.2,482.664 1818.37,473.827C1809.53,464.999 1798.77,460.584 1786.11,460.584C1773.42,460.584 1762.66,464.999 1753.83,473.827C1743.46,484.588 1730.97,489.963 1716.37,489.963C1701.4,489.963 1688.53,484.78 1677.77,474.41C1667.39,463.649 1662.2,450.775 1662.2,435.795C1662.2,421.206 1667.59,408.72 1678.35,398.34C1683.73,392.578 1690.26,388.74 1697.93,386.817C1699.87,386.436 1701.4,386.623 1702.55,387.385C1703.32,388.547 1702.93,389.702 1701.39,390.854L1689.87,402.953C1681.03,411.791 1676.61,422.359 1676.61,434.644C1676.61,447.319 1680.45,457.497 1688.13,465.182C1695.81,472.868 1706.57,476.705 1720.41,476.705C1730.01,476.705 1739.61,471.91 1749.21,462.311L1816.06,396.044C1824.9,387.197 1829.32,376.436 1829.32,363.77C1829.32,351.086 1824.9,340.332 1816.06,331.504C1789.17,304.992 1755.94,291.738 1716.37,291.738ZM877.977,292.668C841.947,292.194 813.839,301.679 793.662,321.133C775.996,338.036 767.168,359.358 767.168,385.089C767.549,417.363 780.035,441.565 804.624,457.697C811.918,462.687 820.941,466.72 831.693,469.796C837.083,471.72 846.111,474.02 858.777,476.705C869.919,479.391 882.023,481.886 895.088,484.191C897.774,484.962 898.924,486.312 898.543,488.236C898.543,490.541 897.58,491.691 895.657,491.691C890.667,492.072 886.059,492.266 881.831,492.266C850.328,488.81 829.001,485.927 817.859,483.622C814.793,482.851 811.535,482.463 808.078,482.463C796.165,482.463 785.787,486.884 776.949,495.721C768.502,504.178 764.282,514.551 764.282,526.836C764.282,536.825 767.352,545.854 773.494,553.92C780.027,561.986 788.486,567.169 798.866,569.473C831.13,576.778 860.317,580.428 886.429,580.428C922.16,580.428 950.013,570.825 969.992,551.617C987.277,535.094 995.925,513.775 995.925,487.653C995.925,455.388 983.626,431.187 959.037,415.045C945.972,406.598 927.915,400.45 904.869,396.612C891.042,393.927 879.518,391.427 870.3,389.112L870.3,389.127C867.605,388.356 866.067,386.818 865.686,384.513C865.686,382.59 867.224,381.44 870.3,381.059C873.757,380.678 877.415,380.678 881.262,381.059C913.146,384.135 934.652,386.823 945.794,389.127C948.861,389.889 951.931,390.271 955.007,390.271C967.301,390.271 977.674,386.051 986.121,377.604C994.959,368.767 999.379,358.393 999.379,346.49C999.379,336.109 996.109,326.894 989.576,318.837C983.043,310.761 974.79,305.566 964.81,303.261C938.298,297.5 911.788,294.042 885.285,292.89C882.813,292.77 880.379,292.7 877.977,292.668ZM1128.73,292.668C1092.7,292.194 1064.59,301.679 1044.42,321.133C1026.75,338.036 1017.92,359.358 1017.92,385.089C1018.3,417.363 1030.79,441.565 1055.38,457.697C1062.67,462.687 1071.7,466.72 1082.46,469.796C1087.84,471.72 1096.86,474.02 1109.54,476.705C1120.68,479.391 1132.79,481.886 1145.84,484.191C1148.53,484.962 1149.68,486.312 1149.3,488.236C1149.3,490.541 1148.34,491.691 1146.41,491.691C1141.42,492.072 1136.81,492.266 1132.59,492.266C1101.09,488.81 1079.77,485.927 1068.63,483.622C1065.55,482.851 1062.29,482.463 1058.83,482.463C1046.92,482.463 1036.55,486.884 1027.72,495.721C1019.26,504.178 1015.04,514.551 1015.04,526.836C1015.04,536.825 1018.11,545.854 1024.26,553.92C1030.79,561.986 1039.24,567.169 1049.62,569.473C1081.88,576.778 1111.08,580.428 1137.2,580.428C1172.92,580.428 1200.77,570.825 1220.75,551.617C1238.03,535.094 1246.68,513.775 1246.68,487.653C1246.68,455.388 1234.39,431.187 1209.81,415.045C1196.74,406.598 1178.68,400.45 1155.64,396.612C1141.81,393.927 1130.29,391.427 1121.07,389.112L1121.05,389.127C1118.37,388.356 1116.84,386.818 1116.45,384.513C1116.45,382.59 1117.99,381.44 1121.05,381.059C1124.52,380.678 1128.17,380.678 1132.01,381.059C1163.89,384.135 1185.41,386.823 1196.55,389.127C1199.62,389.889 1202.69,390.271 1205.76,390.271C1218.06,390.271 1228.43,386.051 1236.89,377.604C1245.72,368.767 1250.13,358.393 1250.13,346.49C1250.13,336.109 1246.87,326.894 1240.35,318.837C1233.81,310.761 1225.55,305.566 1215.56,303.261C1189.06,297.5 1162.55,294.042 1136.04,292.89C1133.57,292.77 1131.13,292.7 1128.73,292.668ZM1910.17,296.736C1894.04,296.736 1880.21,302.501 1868.69,314.024C1857.55,325.157 1851.98,338.793 1851.98,354.934L1851.98,435.028C1851.98,473.825 1865.8,507.05 1893.45,534.705C1921.12,562.36 1954.36,576.191 1993.15,576.191C2000.84,576.191 2007.95,575.614 2014.48,574.471C2018.32,573.699 2021,574.469 2022.53,576.774C2023.69,578.307 2023.3,580.42 2021.39,583.115C2016.39,590.029 2005.82,593.486 1989.69,593.486C1983.55,593.486 1975.68,591.949 1966.07,588.873C1956.47,585.797 1948.98,584.259 1943.6,584.259C1920.93,584.259 1904.99,594.638 1895.77,615.388C1892.32,622.302 1890.58,629.598 1890.58,637.283C1890.58,659.948 1900.77,675.892 1921.13,685.11C1941.49,694.709 1964.34,699.505 1989.69,699.505C2033.49,699.505 2068.25,686.639 2093.98,660.898C2120.11,635.166 2133.18,600.784 2133.18,557.758L2133.18,452.308C2133.94,446.157 2134.32,440.399 2134.32,435.028L2134.32,354.934C2134.32,338.802 2128.57,325.166 2117.04,314.024C2105.9,302.501 2092.27,296.736 2076.13,296.736C2059.99,296.736 2046.16,302.501 2034.63,314.024C2023.11,325.157 2017.35,338.793 2017.35,354.934L2017.35,435.028C2017.35,441.551 2015.04,447.309 2010.43,452.308C2005.83,456.918 2000.07,459.225 1993.15,459.225C1986.62,459.225 1980.86,456.918 1975.87,452.308C1971.26,447.309 1968.95,441.551 1968.95,435.028L1968.95,354.934C1968.95,338.802 1963.19,325.166 1951.67,314.024C1940.14,302.501 1926.3,296.736 1910.17,296.736ZM683.473,316.947C667.331,316.947 653.495,322.713 641.972,334.236C630.449,345.768 624.691,359.602 624.691,375.744L624.691,518.118C624.691,534.259 630.449,548.095 641.972,559.618C653.504,570.761 667.341,576.331 683.473,576.331C699.624,576.331 713.27,570.761 724.412,559.618C735.935,548.095 741.693,534.259 741.693,518.118L741.693,375.744C741.693,359.593 735.935,345.759 724.412,334.236C713.261,322.713 699.614,316.947 683.473,316.947Z" style="fill:white;fill-rule:nonzero;"/>
-    </g>
-</svg>
diff --git a/packages/client/assets/remove.png b/packages/client/assets/remove.png
deleted file mode 100644
index c2e222a0fc..0000000000
Binary files a/packages/client/assets/remove.png and /dev/null differ
diff --git a/packages/client/assets/sounds/aisha/1.mp3 b/packages/client/assets/sounds/aisha/1.mp3
deleted file mode 100644
index d8e9a2f265..0000000000
Binary files a/packages/client/assets/sounds/aisha/1.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/aisha/2.mp3 b/packages/client/assets/sounds/aisha/2.mp3
deleted file mode 100644
index 477c2eba43..0000000000
Binary files a/packages/client/assets/sounds/aisha/2.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/aisha/3.mp3 b/packages/client/assets/sounds/aisha/3.mp3
deleted file mode 100644
index fe0d8063df..0000000000
Binary files a/packages/client/assets/sounds/aisha/3.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba1.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba1.mp3
deleted file mode 100644
index 616b506c4f..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba1.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba2.mp3
deleted file mode 100644
index 33c2837620..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba2.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba3.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba3.mp3
deleted file mode 100644
index 1791f26573..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba3.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba4.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba4.mp3
deleted file mode 100644
index 5f8bf468e5..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba4.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba5.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba5.mp3
deleted file mode 100644
index dabe754b5b..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba5.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba6.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba6.mp3
deleted file mode 100644
index 57ecb01bda..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba6.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/noizenecio/kick_gaba7.mp3 b/packages/client/assets/sounds/noizenecio/kick_gaba7.mp3
deleted file mode 100644
index 6ba317deb1..0000000000
Binary files a/packages/client/assets/sounds/noizenecio/kick_gaba7.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/down.mp3 b/packages/client/assets/sounds/syuilo/down.mp3
deleted file mode 100644
index 4cd421139d..0000000000
Binary files a/packages/client/assets/sounds/syuilo/down.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/kick.mp3 b/packages/client/assets/sounds/syuilo/kick.mp3
deleted file mode 100644
index 4e0e72091c..0000000000
Binary files a/packages/client/assets/sounds/syuilo/kick.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/client/assets/sounds/syuilo/pirori-square-wet.mp3
deleted file mode 100644
index babf1fce60..0000000000
Binary files a/packages/client/assets/sounds/syuilo/pirori-square-wet.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/pirori-wet.mp3 b/packages/client/assets/sounds/syuilo/pirori-wet.mp3
deleted file mode 100644
index 25e2c46a64..0000000000
Binary files a/packages/client/assets/sounds/syuilo/pirori-wet.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/pirori.mp3 b/packages/client/assets/sounds/syuilo/pirori.mp3
deleted file mode 100644
index a745415ac0..0000000000
Binary files a/packages/client/assets/sounds/syuilo/pirori.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/poi1.mp3 b/packages/client/assets/sounds/syuilo/poi1.mp3
deleted file mode 100644
index 59dae90965..0000000000
Binary files a/packages/client/assets/sounds/syuilo/poi1.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/poi2.mp3 b/packages/client/assets/sounds/syuilo/poi2.mp3
deleted file mode 100644
index a65c653891..0000000000
Binary files a/packages/client/assets/sounds/syuilo/poi2.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/pope1.mp3 b/packages/client/assets/sounds/syuilo/pope1.mp3
deleted file mode 100644
index d6f53cfacc..0000000000
Binary files a/packages/client/assets/sounds/syuilo/pope1.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/pope2.mp3 b/packages/client/assets/sounds/syuilo/pope2.mp3
deleted file mode 100644
index fe5d95e292..0000000000
Binary files a/packages/client/assets/sounds/syuilo/pope2.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/popo.mp3 b/packages/client/assets/sounds/syuilo/popo.mp3
deleted file mode 100644
index a2a1605bbb..0000000000
Binary files a/packages/client/assets/sounds/syuilo/popo.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/queue-jammed.mp3 b/packages/client/assets/sounds/syuilo/queue-jammed.mp3
deleted file mode 100644
index 99e0c437fe..0000000000
Binary files a/packages/client/assets/sounds/syuilo/queue-jammed.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/reverved.mp3 b/packages/client/assets/sounds/syuilo/reverved.mp3
deleted file mode 100644
index 47588ef270..0000000000
Binary files a/packages/client/assets/sounds/syuilo/reverved.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/ryukyu.mp3 b/packages/client/assets/sounds/syuilo/ryukyu.mp3
deleted file mode 100644
index 9e935e3f37..0000000000
Binary files a/packages/client/assets/sounds/syuilo/ryukyu.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/snare.mp3 b/packages/client/assets/sounds/syuilo/snare.mp3
deleted file mode 100644
index 9244189c2d..0000000000
Binary files a/packages/client/assets/sounds/syuilo/snare.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/square-pico.mp3 b/packages/client/assets/sounds/syuilo/square-pico.mp3
deleted file mode 100644
index c4d8305ae7..0000000000
Binary files a/packages/client/assets/sounds/syuilo/square-pico.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/triple.mp3 b/packages/client/assets/sounds/syuilo/triple.mp3
deleted file mode 100644
index 54ab974d46..0000000000
Binary files a/packages/client/assets/sounds/syuilo/triple.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/up.mp3 b/packages/client/assets/sounds/syuilo/up.mp3
deleted file mode 100644
index 3f30867764..0000000000
Binary files a/packages/client/assets/sounds/syuilo/up.mp3 and /dev/null differ
diff --git a/packages/client/assets/sounds/syuilo/waon.mp3 b/packages/client/assets/sounds/syuilo/waon.mp3
deleted file mode 100644
index a4af473861..0000000000
Binary files a/packages/client/assets/sounds/syuilo/waon.mp3 and /dev/null differ
diff --git a/packages/client/assets/tagcanvas.min.js b/packages/client/assets/tagcanvas.min.js
deleted file mode 100644
index bcee46e682..0000000000
--- a/packages/client/assets/tagcanvas.min.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Copyright (C) 2010-2021 Graham Breach
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Lesser General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Lesser General Public License for more details.
- * 
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-/**
- * TagCanvas 2.11
- * For more information, please contact <graham@goat1000.com>
- */
- (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a<e;++a)b.push(c[a]);return b.sort(d),b}function ai(a){for(var b=a.length-1,d,c;b;)c=~~(Math.random()*b),d=a[b],a[b]=a[c],a[c]=d,--b}function ag(){var a=window.AudioContext||window.webkitAudioContext;if(e=new a,!e){e='off';return}return e}function $(u,a,t,s,r,q,p){var j=s,h=r,i=t*.01,n=80*i,m=100*i,o=40*i,l=30*i,c=l/2,b=j+n,f=b-o,k=h+m,d=k-l,g=h+l,e=h+m/2;if(a.setTransform(1,0,0,1,0,0),a.setLineDash([]),a.globalAlpha=1,a.strokeStyle=p,a.lineWidth=q,a.lineJoin='round',a.beginPath(),a.moveTo(f,g),a.lineTo(f,d),a.moveTo(b,k),a.lineTo(f,d),a.lineTo(j,d),a.lineTo(j,g),a.lineTo(f,g),a.lineTo(b,h),u){a.lineTo(b,g),a.moveTo(b,d),a.lineTo(b,k),a.moveTo(b-c,e-c),a.lineTo(b+c,e+c),a.moveTo(b+c,e-c),a.lineTo(b-c,e+c),a.stroke();return}a.closePath(),a.stroke()}function s(a,b,c){this.x=a,this.y=b,this.z=c}z=s.prototype,z.length=function(){return E(this.x*this.x+this.y*this.y+this.z*this.z)},z.dot=function(a){return this.x*a.x+this.y*a.y+this.z*a.z},z.cross=function(a){var b=this.y*a.z-this.z*a.y,c=this.z*a.x-this.x*a.z,d=this.x*a.y-this.y*a.x;return new s(b,c,d)},z.angle=function(b){var c=this.dot(b),a;return c==0?Math.PI/2:(a=c/(this.length()*b.length()),a>=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a<g;++a)b=a*h-1+h/2,c=E(1-b*b),d=a*i,e.push([l(d)*c*j,b*k,o(d)*c*m]);return e}function U(n,p,m,k,h,g){var b,f=[],i=2/n,j,a,d,c,e;j=Math.PI*(3-E(5)+(parseFloat(g)?parseFloat(g):0));for(a=0;a<n;++a)d=a*i-1+i/2,b=a*j,c=l(b),e=o(b),f.push(p?[d*m,c*k,e*h]:[c*m,d*k,e*h]);return f}function aa(k,e,f,h,i,j){var b,g=[],m=Math.PI*2/e,a,c,d;for(a=0;a<e;++a)b=a*m,c=l(b),d=o(b),g.push(k?[j*f,c*h,d*i]:[c*f,j*h,d*i]);return g}function ax(a,b,c,d,e){return U(a,0,b,c,d,e)}function aH(a,b,c,d,e){return U(a,1,b,c,d,e)}function aG(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(0,b,c,d,e,a)}function aF(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(1,b,c,d,e,a)}function av(b){var a=new Image;a.onload=function(){var c=a.width/2,d=a.height/2;b.centreFunc=function(b,g,h,e,f){b.setTransform(1,0,0,1,0,0),b.globalAlpha=1,b.drawImage(a,e-c,f-d)}},a.src=b.centreImage}function aE(a,c){var b=a,d,e,f=(c*1).toPrecision(3)+')';return a[0]==='#'?(I[a]||(a.length===4?I[a]='rgba('+R[a[1]]+R[a[2]]+R[a[3]]:I[a]='rgba('+D[a.substr(1,2)]+D[a.substr(3,2)]+D[a.substr(5,2)]),b=I[a]+f):a.substr(0,4)==='rgb('||a.substr(0,4)==='hsl('?b=a.replace('(','a(').replace(')',','+f):(a.substr(0,5)==='rgba('||a.substr(0,5)==='hsla(')&&(d=a.lastIndexOf(',')+1,e=a.indexOf(')'),c*=parseFloat(a.substring(d,e)),b=a.substr(0,d)+c.toPrecision(3)+')'),b}function k(b,d){if(window.G_vmlCanvasManager)return null;var a=c.createElement('canvas');return a.width=b,a.height=d,a}function aD(){var b=k(3,3),a,c;return!!b&&(a=b.getContext('2d'),a.strokeStyle='#000',a.shadowColor='#fff',a.shadowBlur=3,a.globalAlpha=0,a.strokeRect(2,2,2,2),a.globalAlpha=1,c=a.getImageData(2,2,1,1),b=null,c.data[0]>0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b<d;++b)aC(f,l,b,e[b])}return j=g(h(j||0,d-1),0),c=f.getImageData(~~((l-1)*m),j,1,1).data,'rgba('+c[0]+','+c[1]+','+c[2]+','+c[3]/255+')'}function Y(b,i,q,k,o,n,h,d,a,g,f,l){var m=o+(d||0)+(a.length&&a[0]<0?p(a[0]):0),j=n+(d||0)+(a.length&&a[1]<0?p(a[1]):0),c,e;b.font=i,b.textBaseline='top',b.fillStyle=q,h&&(b.shadowColor=h),d&&(b.shadowBlur=d),a.length&&(b.shadowOffsetX=a[0],b.shadowOffsetY=a[1]);for(c=0;c<k.length;++c)e=0,f&&('right'==l?e=g-f[c]:'centre'==l&&(e=(g-f[c])/2)),b.fillText(k[c],m+e,j),j+=parseInt(i)}function y(d,a,b,f,e,c,g){c?(d.beginPath(),d.moveTo(a,b+e-c),d.arcTo(a,b,a+c,b,c),d.arcTo(a+f,b,a+f,b+c,c),d.arcTo(a+f,b+e,a+f-c,b+e,c),d.arcTo(a,b+e,a,b+e-c,c),d.closePath(),d[g?'stroke':'fill']()):d[g?'strokeRect':'fillRect'](a,b,f,e)}function O(a,b,c,d,e,f,g,h,i){this.strings=a,this.font=b,this.width=c,this.height=d,this.maxWidth=e,this.stringWidths=f,this.align=g,this.valign=h,this.scale=i}M=O.prototype,M.SetImage=function(a,b,c,d,e,f,g,h){this.image=a,this.iwidth=b*this.scale,this.iheight=c*this.scale,this.ipos=d,this.ipad=e*this.scale,this.iscale=h,this.ialign=f,this.ivalign=g},M.Align=function(c,d,a){var b=0;return a=='right'||a=='bottom'?b=d-c:a!='left'&&a!='top'&&(b=(d-c)/2),b},M.Create=function(G,D,F,b,A,m,q,j,E){var o,e,f,a,l,s,i,u,v,r,w,n,c,d,x,B=p(q[0]),C=p(q[1]),t,z;return j=g(j,B+m,C+m),l=2*(j+b),i=2*(j+b),e=this.width+l,f=this.height+i,v=r=j+b,this.image&&(w=n=j+b,c=this.iwidth,d=this.iheight,this.ipos=='top'||this.ipos=='bottom'?(c<this.width?w+=this.Align(c,this.width,this.ialign):v+=this.Align(this.width,c,this.align),this.ipos=='top'?r+=d+this.ipad:n+=this.height+this.ipad,e=g(e,c+l),f+=d+this.ipad):(d<this.height?n+=this.Align(d,this.height,this.ivalign):r+=this.Align(this.height,d,this.valign),this.ipos=='right'?w+=this.width+this.ipad:v+=c+this.ipad,e+=c+this.ipad,f=g(f,d+i))),o=k(e,f),!o?null:(l=i=b/2,s=e-b,u=f-b,x=h(E,s/2,u/2),a=o.getContext('2d'),D&&(a.fillStyle=D,y(a,l,i,s,u,x)),b&&(a.strokeStyle=F,a.lineWidth=b,y(a,l,i,s,u,x,!0)),(m||B||C)&&(t=k(e,f),t&&(z=a,a=t.getContext('2d'))),Y(a,this.font,G,this.strings,v,r,0,0,[],this.maxWidth,this.stringWidths,this.align),this.image&&a.drawImage(this.image,w,n,c,d),z&&(a=z,A&&(a.shadowColor=A),m&&(a.shadowBlur=m),a.shadowOffsetX=q[0],a.shadowOffsetY=q[1],a.drawImage(t,0,0)),o)};function H(a,c,d){var b=k(c,d),e;return b?(e=b.getContext('2d'),e.drawImage(a,(c-a.width)/2,(d-a.height)/2),b):null}function S(e,b,c){var a=k(b,c),d;return a?(d=a.getContext('2d'),d.drawImage(e,0,0,b,c),a):null}function W(n,u,t,e,s,c,v,d,r,w){var g=u+(2*d+c)*e,f=t+(2*d+c)*e,l=k(g,f),b,i,q,m,j,o,a,p;return l?(c*=e,r*=e,i=q=c/2,m=g-c,j=f-c,d=d*e+i,b=l.getContext('2d'),p=h(r,m/2,j/2),s&&(b.fillStyle=s,y(b,i,q,m,j,p)),c&&(b.strokeStyle=v,b.lineWidth=c,y(b,i,q,m,j,p,!0)),w?(o=k(g,f),a=o.getContext('2d'),a.drawImage(n,d,d,u,t),a.globalCompositeOperation='source-in',a.fillStyle=v,a.fillRect(0,0,g,f),a.globalCompositeOperation='destination-over',a.drawImage(l,0,0),a.globalCompositeOperation='source-over',b.drawImage(o,0,0)):b.drawImage(n,d,d,n.width,n.height),{image:l,width:g/e,height:f/e}):null}function at(l,f,c,d,j){var e,a,b=parseFloat(f),i=g(c,d);return e=k(c,d),!e?null:(f.indexOf('%')>0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d<f;++d)for(b=0;b<e;++b)n=(d*e+b)*4,i.data[n+1]>0&&(b<a.min.x&&(a.min.x=b),b>a.max.x&&(a.max.x=b),d<a.min.y&&(a.min.y=d),d>a.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f<e.naturalWidth||d<e.naturalHeight?(c=S(g,f,d),c&&(a.fimage=c)):(f=a.iw,d=a.ih,h=1),parseFloat(b.imageRadius)&&(a.image=a.fimage=g=at(a.image,b.imageRadius,f,d,h)),a.HasText()||(b.shadow&&(c=ao(a.image,f,d,h,b.shadow,b.shadowBlur,b.shadowOffset),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),(b.bgColour||b.bgOutlineThickness)&&(k=b.bgColour=='tag'?i(a.a,'background-color'):b.bgColour,m=b.bgOutline=='tag'?i(a.a,'color'):b.bgOutline||b.textColour,f=a.fimage.width,d=a.fimage.height,b.outlineMethod=='colour'&&(c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,a.outline.colour,b.padding,b.bgRadius,1),c&&(a.oimage=c.image)),c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,m,b.padding,b.bgRadius),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),b.outlineMethod=='size'&&(b.outlineIncrease>0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;a<this.text.length;++a)if(this.text[a].length)return!1;return!0},F.Lines=function(c){var e=c?1:0,b,d,a;c=c||this.e,b=c.childNodes,d=b.length;for(a=0;a<d;++a)b[a].nodeName=='BR'?(this.text.push(this.line.join(' ')),this.br=1):b[a].nodeType==3?this.br?(this.line=[b[a].nodeValue],this.br=0):this.line.push(b[a].nodeValue):this.Lines(b[a]);return e||this.br||this.text.push(this.line.join(' ')),this.text},F.SplitWidth=function(h,e,f,g){var c,b,a,d=[];e.font=g+'px '+f;for(c=0;c<this.text.length;++c){a=this.text[c].split(/\s+/),this.line=[a[0]];for(b=1;b<a.length;++b)e.measureText(this.line.join(' ')+' '+a[b]).width>h?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a<e;++a)this.line_widths[a]=c=d.measureText(this.text[a]).width,b=g(b,c);return b},d.Measure=function(e,a){var f=ae(this.text,this.textFont,this.textHeight),b,k,h,i,g,l,j,c,d;j=f?f.max.y+f.min.y:this.textHeight,e.font=this.font=this.textHeight+'px '+this.textFont,l=this.MeasureText(e),a.txtOpt&&(b=a.txtScale,k=b*this.textHeight,h=k+'px '+this.textFont,i=[b*a.shadowOffset[0],b*a.shadowOffset[1]],e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*j+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw,this.ih,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),c=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),a.outlineMethod=='colour'?this.oimage=d.Create(this.outline.colour,this.bgColour,this.outline.colour,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius):a.outlineMethod=='size'&&(f=ae(this.text,this.textFont,this.textHeight+a.outlineIncrease),k=f.max.y+f.min.y,h=b*(this.textHeight+a.outlineIncrease)+'px '+this.textFont,e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*k+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw+a.outlineIncrease,this.ih+a.outlineIncrease,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),this.oimage=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),this.oscale=this.oimage.width/c.width,a.outlineIncrease>0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a<f;++a)d=e[a]||'size','both'==d?(this.Weight(c[a],b.ctxt,b,'size',b.min_weight[a],b.max_weight[a],a),this.Weight(c[a],b.ctxt,b,'colour',b.min_weight[a],b.max_weight[a],a)):this.Weight(c[a],b.ctxt,b,d,b.min_weight[a],b.max_weight[a],a);this.Measure(b.ctxt,b)},d.Weight=function(b,i,a,d,f,h,e){b=isNaN(b)?1:b;var c=(b-f)/(h-f);'colour'==d?this.colour=L(a,c,e):'bgcolour'==d?this.bgColour=L(a,c,e):'bgoutline'==d?this.bgOutline=L(a,c,e):'outline'==d?this.outline.colour=L(a,c,e):'size'==d&&(a.weightSizeMin>0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b<this.text.length;++b)d=g,'right'==e.textAlign?d+=this.w/2-this.line_widths[b]:'centre'==e.textAlign?d-=this.line_widths[b]/2:d-=this.w/2,a.setTransform(c,0,0,c,c*d,c*f),a.fillText(this.text[b],0,0),f+=this.textHeight},d.DrawImage=function(b,i,k,l){var e=this.x,f=this.y,a=this.sc,j=l||this.fimage,c=this.w,d=this.h,g=this.alpha,h=this.shadow;b.globalAlpha=g,h&&this.SetShadowColour(b,h,g),e+=i/a-c/2,f+=k/a-d/2,b.setTransform(a,0,0,a,a*e,a*f),b.drawImage(j,0,0,c,d)},d.DrawImageIE=function(b,d,e){var c=this.fimage,a=this.sc,f=c.width=this.w*a,g=c.height=this.h*a,h=this.x*a+d-f/2,i=this.y*a+e-g/2;b.setTransform(1,0,0,1,0,0),b.globalAlpha=this.alpha,b.drawImage(c,h,i)},d.Calc=function(g,e){var a,b=this.tc,d=b.minBrightness,f=b.maxBrightness,c=b.max_radius;return a=g.xform(this.position),this.xformed=a,a=aI(b,a,b.stretchX,b.stretchY),this.x=a.x,this.y=a.y,this.z=a.z,this.sc=a.w,this.alpha=e*G(d+(f-d)*(c-this.z)/(2*c),0,1),this.xformed},d.UpdateActive=function(h,e,f){var a=this.outline,b=this.w,c=this.h,d=this.x-b/2,g=this.y-c/2;return a.Update(d,g,b,c,this.sc,this.z,e,f),a},d.CheckActive=function(a,d,e){var b=this.tc,c=this.UpdateActive(a,d,e);return c.Active(a,b.mx,b.my)?c:null},d.Clicked=function(f){var b=this.a,a=b.target,d=b.href,e;if(a!=''&&a!='_self'){if(self.frames[a])self.frames[a].document.location=d;else{try{if(top.frames[a]){top.frames[a].document.location=d;return}}catch(a){}window.open(d,a)}return}if(c.createEvent){if(e=c.createEvent('MouseEvents'),e.initMouseEvent('click',1,1,window,0,0,0,0,0,0,0,0,0,0,null),!b.dispatchEvent(e))return}else if(b.fireEvent)if(!b.fireEvent('onclick'))return;c.location=d},d.StopAudio=function(){this.audio&&this.playing&&this.audio.pause(),this.stopped=1,this.playing=0},d.PlayAudio=function(){if(e==='off'||this.tc.audioOff)return;if(!e&&!ag())return;var a=this.tc.audio,c=this.tc.gain,d='suspended',b;if(this.audio)if(this.track||(this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination)),a=this.audio,c=this.gain,!a.paused)return 1;if(a){if(e.state==d&&e.resume(),e.state==d)return;return c.gain.value=h(2,g(0,this.tc.audioVolume*1)),a.currentTime=0,this.stopped=0,b=a.play(),b!==void 0&&b.then(a=>{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d<l.length;++d)i[l[d]]=b[l[d]];throw b.parentNode.insertBefore(i,b),b.parentNode.removeChild(b),0}for(d in a.options)this[d]=k&&n(k[d])?k[d]:n(a[d])?a[d]:a.options[d];if(this.canvas=b,this.ctxt=b.getContext('2d'),this.z1=250/g(this.depth,.001),this.z2=this.z1/this.zoom,this.radius=h(b.height,b.width)*.0075,this.max_radius=100,this.max_weight=[],this.min_weight=[],this.textFont=this.textFont&&Q(this.textFont),this.textHeight*=1,this.imageRadius=this.imageRadius.toString(),this.pulsateTo=G(this.pulsateTo,0,1),this.minBrightness=G(this.minBrightness,0,1),this.maxBrightness=G(this.maxBrightness,this.minBrightness,1),this.ctxt.textBaseline='top',this.lx=(this.lock+'').indexOf('x')+1,this.ly=(this.lock+'').indexOf('y')+1,this.frozen=this.dx=this.dy=this.fixedAnim=this.touchState=0,this.fixedAlpha=1,this.source=o||f,this.repeatTags=h(64,~~this.repeatTags),this.minTags=h(200,~~this.minTags),~~this.scrollPause>0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;d<j[f].length;++d)i=j[f][d],t(i[0],i[1],i[2]?i[2]:b)}a.started||(a.NextFrame=window.requestAnimationFrame?az:aA,a.interval=this.interval,a.NextFrame(this.interval),a.started=1)}b=a.prototype,b.SourceElements=function(){return c.querySelectorAll?c.querySelectorAll('#'+this.source):[c.getElementById(this.source)]},b.HideTags=function(){var b=this.SourceElements(),a;for(a=0;a<b.length;++a)b[a].style.display='none'},b.GetTags=function(){var e=this.SourceElements(),c,f=[],a,b,d;for(d=0;d<=this.repeatTags;++d)for(a=0;a<e.length;++a){c=e[a].getElementsByTagName('a');for(b=0;b<c.length;++b)f.push(c[b])}return f},b.Message=function(j){var g=[],a,f,b=j.split(''),d,e,h,i;for(a=0;a<b.length;++a)b[a]!=' '&&(f=a-b.length/2,d=c.createElement('A'),d.href='#',d.innerText=b[a],h=100*o(f/9),i=-100*l(f/9),e=new J(this,b[a],d,[h,0,i],2,18,'#000','#fff',0,0,0,'monospace',2,b[a]),e.Init(),g.push(e));return g},b.AddAudio=function(b,c){if(e==='off')return;var a=b.getElementsByTagName('audio');a.length&&(c.SetAudio(a[0]),this.hasAudio=1)},b.CreateTag=function(b){var e,c,a,f,d,g,h,j,k=[0,0,0],l;if('text'!=this.imageMode)if(e=b.getElementsByTagName('img'),e.length)if(c=new Image,c.src=e[0].src,!this.imageMode)return a=new J(this,"",b,k,0,0),a.SetImage(c),A(c,e[0],e[1],a,this),this.AddAudio(b,a),a;if('image'!=this.imageMode&&(d=new V(b),f=d.Lines(),d.Empty()?d=null:(g=this.textFont||Q(i(b,'font-family')),this.splitWidth&&(f=d.SplitWidth(this.splitWidth,this.ctxt,g,this.textHeight)),h=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,j=this.bgOutline=='tag'?i(b,'color'):this.bgOutline)),d||c)return a=new J(this,f,b,k,2,this.textHeight+2,this.textColour||i(b,'color'),h,this.bgRadius,j,this.bgOutlineThickness,g,this.padding,d&&d.original),c?(a.SetImage(c),A(c,e[0],e[1],a,this)):a.Init(),this.AddAudio(b,a),a},b.UpdateTag=function(a,b){var c=this.textColour||i(b,'color'),d=this.textFont||Q(i(b,'font-family')),e=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,f=this.bgOutline=='tag'?i(b,'color'):this.bgOutline;a.a=b,a.title=b.title,(a.colour!=c||a.textFont!=d||a.bgColour!=e||a.bgOutline!=f)&&a.SetFont(d,c,e,f)},b.Weight=function(d){var f=d.length,c,b,a,e=[],g,h=this.weightFrom?this.weightFrom.split(/[, ]/):[null],i=h.length;for(b=0;b<f;++b){e[b]=[];for(a=0;a<i;++a)c=aj(d[b].a,h[a],this.textHeight),(!this.max_weight[a]||c>this.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||c<this.min_weight[a])&&(this.min_weight[a]=c),e[b][a]=c}for(a=0;a<i;++a)this.max_weight[a]>this.min_weight[a]&&(g=1);if(g)for(b=0;b<f;++b)d[b].SetWeight(e[b])},b.Load=function(){var c=this.GetTags(),b=[],d,k,l,h,i,j,f,a,e=[],m={sphere:aB,vcylinder:ax,hcylinder:aH,vring:aG,hring:aF};if(c.length){e.length=c.length;for(a=0;a<c.length;++a)e[a]=a;this.shuffleTags&&ai(e),h=100*this.radiusX,i=100*this.radiusY,j=100*this.radiusZ,this.max_radius=g(h,g(i,j));for(a=0;a<c.length;++a)k=this.CreateTag(c[e[a]]),k&&b.push(k);this.weight&&this.Weight(b,!0),this.shapeArgs?this.shapeArgs[0]=b.length:(l=this.shape.toString().split(/[(),]/),d=l.shift(),typeof window[d]=='function'?this.shape=window[d]:this.shape=m[d]||m.sphere,this.shapeArgs=[b.length,h,i,j].concat(l)),f=this.shape.apply(this,this.shapeArgs),this.listLength=b.length;for(a=0;a<b.length;++a)b[a].position=new s(f[a][0],f[a][1],f[a][2])}this.noTagsMessage&&!b.length&&(a=this.imageMode&&this.imageMode!='both'?this.imageMode+' ':'',b=this.Message('No '+a+'tags')),this.taglist=b},b.Update=function(){var e=this.GetTags(),d=[],j=this.taglist,k,f=[],c=[],h,i,g,a,b;if(!this.shapeArgs)return this.Load();if(e.length){g=this.listLength=e.length,i=j.length;for(a=0;a<i;++a)d.push(j[a]),c.push(a);for(a=0;a<g;++a){for(b=0,k=0;b<i;++b)j[b].EqualTo(e[a])&&(this.UpdateTag(d[b],e[a]),k=c[b]=-1);k||f.push(a)}for(a=0,b=0;a<i;++a)c[b]==-1?c.splice(b,1):++b;if(c.length){for(ai(c);c.length&&f.length;)a=c.shift(),b=f.shift(),d[a]=this.CreateTag(e[b]);for(c.sort(function(a,b){return a-b});c.length;)d.splice(c.pop(),1)}for(b=d.length/(f.length+1),a=0;f.length;)d.splice(af(++a*b),0,this.CreateTag(e[f.shift()]));this.shapeArgs[0]=g=d.length,h=this.shape.apply(this,this.shapeArgs);for(a=0;a<g;++a)d[a].position=new s(h[a][0],h[a][1],h[a][2]);this.weight&&this.Weight(d)}this.taglist=d},b.SetShadow=function(a){a.shadowBlur=this.shadowBlur,a.shadowOffsetX=this.shadowOffset[0],a.shadowOffsetY=this.shadowOffset[1]},b.LoadAudio=function(){if(!e&&!ag())return;this.audio=c.createElement('audio'),this.audio.src=this.activeAudio,this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination),this.hasAudio=1,P=function(a){e.resume(),c.removeEventListener('click',P)},c.addEventListener('click',P)},b.ShowAudioIcon=function(){var a=this.audioIconSize,c=this.canvas,d=this.ctxt,k=c.width-a-3,f=c.height-a-3,g=this.audioIconThickness,h='#000',i='#fff',j=this.audioIconDark,b=this.audioOff,l='suspended';if(!e)return;b||(b=e.state===l),this.audioIcon&&this.hasAudio&&($(b,d,a,k,f,g+1,j?i:h),$(b,d,a,k,f,g,j?h:i))},b.CheckAudioIcon=function(){var a=this.audioIconSize,b=this.canvas,c=this.audioIconThickness/2,d=b.width-a-3-c,e=b.height-a-3-c;if(this.audioIcon&&this.mx>=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c<k;++c)e[c].Calc(this.transform,this.fixedAlpha);if(e=ak(e,function(a,b){return b.z-a.z}),n&&this.fixedAnim.active)b=this.fixedAnim.tag.UpdateActive(d,h,g);else if(this.active=null,this.CheckAudioIcon())m='pointer';else{for(c=0;c<k;++c)f=this.mx>=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c<k;++c){if(!r&&e[c].z<=0){try{this.centreFunc(d,i,j,h,g)}catch(a){alert(a),this.centreFunc=x}r=!0}b&&b.tag==e[c]&&b.PreDraw(d,e[c],h,g)||e[c].Draw(d,h,g),b&&b.tag==e[c]&&b.PostDraw(d)}this.freezeActive&&b?this.Freeze():(this.UnFreeze(),this.drawn=k==this.listLength),this.fixedCallback&&(this.fixedCallback(this,this.fixedCallbackTag),this.fixedCallback=null),n||this.Animate(i,j,p),b&&(b.LastDraw(d),b.tag!=t&&(this.currentAudio&&this.currentAudio!=b.tag&&this.currentAudio.StopAudio(),b.tag.PlayAudio()&&(this.currentAudio=b.tag)),m=this.activeCursor),l.style.cursor=m,this.Tooltip(b,this.taglist[o]),this.audioIcon&&this.ShowAudioIcon()},b.TooltipNone=function(){},b.TooltipNative=function(b,a){b?this.canvas.title=a&&a.title?a.title:'':this.canvas.title=this.ctitle},b.SetTTDiv=function(c,d){var a=this,b=a.ttdiv.style;c!=a.ttdiv.innerHTML&&(b.display='none'),a.ttdiv.innerHTML=c,d&&(d.title=a.ttdiv.innerHTML),b.display=='none'&&!a.tttimer&&(a.tttimer=setTimeout(function(){var c=X(a.canvas.id);b.display='block',b.left=c.x+a.mx+'px',b.top=c.y+a.my+24+'px',a.tttimer=null},a.tooltipDelay))},b.TooltipDiv=function(b,a){b&&a&&a.title?this.SetTTDiv(a.title,a):!b&&this.mx!=-1&&this.my!=-1&&this.ctitle.length?this.SetTTDiv(this.ctitle):this.ttdiv.style.display='none'},b.Transform=function(c,a,b){if(a||b){var d=o(a),e=l(a),f=o(b),g=l(b),h=new m([g,0,f,0,1,0,-f,0,g]),i=new m([1,0,0,0,e,-d,0,d,e]);c.transform=c.transform.mul(h.mul(i))}},b.AnimateFixed=function(){var a,b,c,d,e;return!!(this.fadeIn&&(b=q()-this.startTime,b>=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&d<g&&e<h?(b=a.maxSpeed,c=a.reverse?-1:1,a.lx||(a.yaw=(d*2*b/g-b)*c*f),a.ly||(a.pitch=(e*2*b/h-b)*-c*f),a.initial=null):a.initial||(a.frozen&&!a.freezeDecel?a.yaw=a.pitch=0:a.Decel(a)),this.Transform(a,a.pitch,a.yaw)},b.AnimateDrag=function(d,e,c){var a=this,b=100*c*a.maxSpeed/a.max_radius/a.zoom;a.dx||a.dy?(a.lx||(a.yaw=a.dx*b/a.stretchX),a.ly||(a.pitch=a.dy*-b/a.stretchY),a.dx=a.dy=0,a.initial=null):a.initial||a.Decel(a),this.Transform(a,a.pitch,a.yaw)},b.Freeze=function(){this.frozen||(this.preFreeze=[this.yaw,this.pitch],this.frozen=1,this.drawn=0)},b.UnFreeze=function(){this.frozen&&(this.yaw=this.preFreeze[0],this.pitch=this.preFreeze[1],this.frozen=0)},b.Decel=function(a){var b=a.minSpeed,c=p(a.yaw),d=p(a.pitch);!a.lx&&c>b&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b<this.taglist.length;++b)if(this.taglist[b].a[c]==d)return this.taglist[b]},b.RotateTag=function(a,h,i,j,f,g){var b=a.Calc(this.transform,1),c=new s(b.x,b.y,b.z),d=ay(i,h),e=c.angle(d),k=c.cross(d).unit();e==0?(this.fixedCallbackTag=a,this.fixedCallback=f):this.fixedAnim={angle:-e,axis:k,t:j,t0:q(),cb:f,tag:a,active:g}},b.TagToFront=function(a,b,c,d){this.RotateTag(a,0,0,b,c,d)},b.Volume=function(a){this.audioVolume=a*1},a.Start=function(b,c,d){a.Delete(b),a.tc[b]=new a(b,c,d)};function N(c,b){a.tc[b]&&a.tc[b][c]()}a.Linear=function(a,b){return b/a},a.Smooth=function(a,b){return.5-l(b*Math.PI/a)/2},a.Pause=function(a){N('Pause',a)},a.Resume=function(a){N('Resume',a)},a.Reload=function(a){N('Load',a)},a.Update=function(a){N('Update',a)},a.SetSpeed=function(c,b){return!!(B(b)&&a.tc[c]&&!isNaN(b[0])&&!isNaN(b[1]))&&(a.tc[c].SetSpeed(b),!0)},a.TagToFront=function(c,b){return!!B(b)&&(b.lat=b.lng=0,a.RotateTag(c,b))},a.RotateTag=function(c,b){if(B(b)&&a.tc[c]){isNaN(b.time)&&(b.time=500);var d=a.tc[c].FindTag(b);if(d)return a.tc[c].RotateTag(d,b.lat,b.lng,b.time,b.callback,b.active),!0}return!1},a.Delete=function(b){var d,e;if(j[b])if(e=c.getElementById(b),e)for(d=0;d<j[b].length;++d)am(j[b][d][0],j[b][d][1],e);delete j[b],delete a.tc[b]},a.tc={},a.options={z1:2e4,z2:2e4,z0:2e-4,freezeActive:!1,freezeDecel:!1,activeCursor:'pointer',pulsateTo:1,pulsateTime:3,reverse:!1,depth:.5,maxSpeed:.05,minSpeed:0,decel:.95,interval:20,minBrightness:.1,maxBrightness:1,outlineColour:'#ffff99',outlineThickness:2,outlineOffset:5,outlineMethod:'outline',outlineRadius:0,textColour:'#ff99ff',textHeight:15,textFont:'Helvetica, Arial, sans-serif',shadow:'#000',shadowBlur:0,shadowOffset:[0,0],initial:null,hideTags:!0,zoom:1,weight:!1,weightMode:'size',weightFrom:null,weightSize:1,weightSizeMin:null,weightSizeMax:null,weightGradient:{0:'#f00',0.33:'#ff0',0.66:'#0f0',1:'#00f'},txtOpt:!0,txtScale:2,frontSelect:!1,wheelZoom:!0,zoomMin:.3,zoomMax:3,zoomStep:.05,shape:'sphere',lock:null,tooltip:null,tooltipDelay:300,tooltipClass:'tctooltip',radiusX:1,radiusY:1,radiusZ:1,stretchX:1,stretchY:1,offsetX:0,offsetY:0,shuffleTags:!1,noSelect:!1,noMouse:!1,imageScale:1,paused:!1,dragControl:!1,dragThreshold:4,centreFunc:x,splitWidth:0,animTiming:'Smooth',clickToFront:!1,fadeIn:0,padding:0,bgColour:null,bgRadius:0,bgOutline:null,bgOutlineThickness:0,outlineIncrease:4,textAlign:'centre',textVAlign:'middle',imageMode:null,imagePosition:null,imagePadding:2,imageAlign:'centre',imageVAlign:'middle',noTagsMessage:!0,centreImage:null,pinchZoom:!1,repeatTags:0,minTags:0,imageRadius:0,scrollPause:!1,outlineDash:0,outlineDashSpace:0,outlineDashSpeed:1,activeAudio:'',audioVolume:1,audioIcon:1,audioIconSize:20,audioIconThickness:2,audioIconDark:0,altImage:0};for(r in a.options)a[r]=a.options[r];window.TagCanvas=a,t('load',function(){a.loaded=1},window)})()
diff --git a/packages/client/assets/unread.svg b/packages/client/assets/unread.svg
deleted file mode 100644
index 8c3cc9f475..0000000000
--- a/packages/client/assets/unread.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
-	 y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
-<circle fill="#3AA2DC" cx="16.5" cy="16.5" r="6"/>
-</svg>
diff --git a/packages/client/package.json b/packages/client/package.json
deleted file mode 100644
index 0af8ffac0b..0000000000
--- a/packages/client/package.json
+++ /dev/null
@@ -1,94 +0,0 @@
-{
-	"name": "client",
-	"private": true,
-	"scripts": {
-		"watch": "vite",
-		"build": "vite build",
-		"lint": "vue-tsc --noEmit && eslint --quiet \"src/**/*.{ts,vue}\""
-	},
-	"dependencies": {
-		"@discordapp/twemoji": "14.0.2",
-		"@rollup/plugin-alias": "4.0.2",
-		"@rollup/plugin-json": "6.0.0",
-		"@rollup/pluginutils": "5.0.2",
-		"@syuilo/aiscript": "0.11.1",
-		"@tabler/icons": "^1.118.0",
-		"@vitejs/plugin-vue": "4.0.0",
-		"@vue/compiler-sfc": "3.2.45",
-		"autobind-decorator": "2.4.0",
-		"autosize": "5.0.2",
-		"blurhash": "2.0.4",
-		"broadcast-channel": "4.18.1",
-		"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
-		"chart.js": "4.1.1",
-		"chartjs-adapter-date-fns": "3.0.0",
-		"chartjs-chart-matrix": "^1.3.0",
-		"chartjs-plugin-gradient": "0.6.1",
-		"chartjs-plugin-zoom": "2.0.0",
-		"compare-versions": "5.0.1",
-		"cropperjs": "2.0.0-beta",
-		"date-fns": "2.29.3",
-		"escape-regexp": "0.0.1",
-		"eventemitter3": "5.0.0",
-		"idb-keyval": "6.2.0",
-		"insert-text-at-cursor": "0.3.0",
-		"is-file-animated": "1.0.2",
-		"json5": "2.2.2",
-		"katex": "0.15.6",
-		"matter-js": "0.18.0",
-		"mfm-js": "0.23.0",
-		"misskey-js": "0.0.14",
-		"photoswipe": "5.3.4",
-		"prismjs": "1.29.0",
-		"punycode": "2.1.1",
-		"querystring": "0.2.1",
-		"rndstr": "1.0.0",
-		"rollup": "3.8.0",
-		"s-age": "1.1.2",
-		"sass": "1.57.1",
-		"seedrandom": "3.0.5",
-		"strict-event-emitter-types": "2.0.0",
-		"stringz": "2.1.0",
-		"syuilo-password-strength": "0.0.1",
-		"textarea-caret": "3.1.0",
-		"three": "0.148.0",
-		"throttle-debounce": "5.0.0",
-		"tinycolor2": "1.4.2",
-		"tsc-alias": "1.8.2",
-		"tsconfig-paths": "4.1.1",
-		"twemoji-parser": "14.0.0",
-		"typescript": "4.9.4",
-		"uuid": "9.0.0",
-		"vanilla-tilt": "1.8.0",
-		"vite": "4.0.3",
-		"vue": "3.2.45",
-		"vue-prism-editor": "2.0.0-alpha.2",
-		"vuedraggable": "next"
-	},
-	"devDependencies": {
-		"@types/escape-regexp": "0.0.1",
-		"@types/glob": "8.0.0",
-		"@types/gulp": "4.0.10",
-		"@types/gulp-rename": "2.0.1",
-		"@types/katex": "0.14.0",
-		"@types/matter-js": "0.18.2",
-		"@types/punycode": "2.1.0",
-		"@types/seedrandom": "3.0.3",
-		"@types/throttle-debounce": "5.0.0",
-		"@types/tinycolor2": "1.4.3",
-		"@types/uuid": "9.0.0",
-		"@types/websocket": "1.0.5",
-		"@types/ws": "8.5.3",
-		"@typescript-eslint/eslint-plugin": "5.47.0",
-		"@typescript-eslint/parser": "5.47.0",
-		"@vue/runtime-core": "3.2.45",
-		"cross-env": "7.0.3",
-		"cypress": "12.2.0",
-		"eslint": "8.30.0",
-		"eslint-plugin-import": "2.26.0",
-		"eslint-plugin-vue": "9.8.0",
-		"start-server-and-test": "1.15.2",
-		"vue-eslint-parser": "^9.1.0",
-		"vue-tsc": "^1.0.16"
-	}
-}
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts
deleted file mode 100644
index 0e991cdfb5..0000000000
--- a/packages/client/src/account.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import { defineAsyncComponent, reactive } from 'vue';
-import * as misskey from 'misskey-js';
-import { showSuspendedDialog } from './scripts/show-suspended-dialog';
-import { i18n } from './i18n';
-import { del, get, set } from '@/scripts/idb-proxy';
-import { apiUrl } from '@/config';
-import { waiting, api, popup, popupMenu, success, alert } from '@/os';
-import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-
-// TODO: 他のタブと永続化されたstateを同期
-
-type Account = misskey.entities.MeDetailed;
-
-const accountData = localStorage.getItem('account');
-
-// TODO: 外部からはreadonlyに
-export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
-
-export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
-export const iAmAdmin = $i != null && $i.isAdmin;
-
-export async function signout() {
-	waiting();
-	localStorage.removeItem('account');
-
-	await removeAccount($i.id);
-
-	const accounts = await getAccounts();
-
-	//#region Remove service worker registration
-	try {
-		if (navigator.serviceWorker.controller) {
-			const registration = await navigator.serviceWorker.ready;
-			const push = await registration.pushManager.getSubscription();
-			if (push) {
-				await window.fetch(`${apiUrl}/sw/unregister`, {
-					method: 'POST',
-					body: JSON.stringify({
-						i: $i.token,
-						endpoint: push.endpoint,
-					}),
-					headers: {
-						'Content-Type': 'application/json',
-					},
-				});
-			}
-		}
-
-		if (accounts.length === 0) {
-			await navigator.serviceWorker.getRegistrations()
-				.then(registrations => {
-					return Promise.all(registrations.map(registration => registration.unregister()));
-				});
-		}
-	} catch (err) {}
-	//#endregion
-
-	document.cookie = 'igi=; path=/';
-
-	if (accounts.length > 0) login(accounts[0].token);
-	else unisonReload('/');
-}
-
-export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> {
-	return (await get('accounts')) || [];
-}
-
-export async function addAccount(id: Account['id'], token: Account['token']) {
-	const accounts = await getAccounts();
-	if (!accounts.some(x => x.id === id)) {
-		await set('accounts', accounts.concat([{ id, token }]));
-	}
-}
-
-export async function removeAccount(id: Account['id']) {
-	const accounts = await getAccounts();
-	accounts.splice(accounts.findIndex(x => x.id === id), 1);
-
-	if (accounts.length > 0) await set('accounts', accounts);
-	else await del('accounts');
-}
-
-function fetchAccount(token: string): Promise<Account> {
-	return new Promise((done, fail) => {
-		// Fetch user
-		window.fetch(`${apiUrl}/i`, {
-			method: 'POST',
-			body: JSON.stringify({
-				i: token,
-			}),
-			headers: {
-				'Content-Type': 'application/json',
-			},
-		})
-			.then(res => res.json())
-			.then(res => {
-				if (res.error) {
-					if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
-						showSuspendedDialog().then(() => {
-							signout();
-						});
-					} else {
-						alert({
-							type: 'error',
-							title: i18n.ts.failedToFetchAccountInformation,
-							text: JSON.stringify(res.error),
-						});
-					}
-				} else {
-					res.token = token;
-					done(res);
-				}
-			})
-			.catch(fail);
-	});
-}
-
-export function updateAccount(accountData) {
-	for (const [key, value] of Object.entries(accountData)) {
-		$i[key] = value;
-	}
-	localStorage.setItem('account', JSON.stringify($i));
-}
-
-export function refreshAccount() {
-	return fetchAccount($i.token).then(updateAccount);
-}
-
-export async function login(token: Account['token'], redirect?: string) {
-	waiting();
-	if (_DEV_) console.log('logging as token ', token);
-	const me = await fetchAccount(token);
-	localStorage.setItem('account', JSON.stringify(me));
-	document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
-	await addAccount(me.id, token);
-
-	if (redirect) {
-		// 他のタブは再読み込みするだけ
-		reloadChannel.postMessage(null);
-		// このページはredirectで指定された先に移動
-		location.href = redirect;
-		return;
-	}
-
-	unisonReload();
-}
-
-export async function openAccountMenu(opts: {
-	includeCurrentAccount?: boolean;
-	withExtraOperation: boolean;
-	active?: misskey.entities.UserDetailed['id'];
-	onChoose?: (account: misskey.entities.UserDetailed) => void;
-}, ev: MouseEvent) {
-	function showSigninDialog() {
-		popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
-			done: res => {
-				addAccount(res.id, res.i);
-				success();
-			},
-		}, 'closed');
-	}
-
-	function createAccount() {
-		popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
-			done: res => {
-				addAccount(res.id, res.i);
-				switchAccountWithToken(res.i);
-			},
-		}, 'closed');
-	}
-
-	async function switchAccount(account: misskey.entities.UserDetailed) {
-		const storedAccounts = await getAccounts();
-		const token = storedAccounts.find(x => x.id === account.id).token;
-		switchAccountWithToken(token);
-	}
-
-	function switchAccountWithToken(token: string) {
-		login(token);
-	}
-
-	const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
-	const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) });
-
-	function createItem(account: misskey.entities.UserDetailed) {
-		return {
-			type: 'user',
-			user: account,
-			active: opts.active != null ? opts.active === account.id : false,
-			action: () => {
-				if (opts.onChoose) {
-					opts.onChoose(account);
-				} else {
-					switchAccount(account);
-				}
-			},
-		};
-	}
-
-	const accountItemPromises = storedAccounts.map(a => new Promise(res => {
-		accountsPromise.then(accounts => {
-			const account = accounts.find(x => x.id === a.id);
-			if (account == null) return res(null);
-			res(createItem(account));
-		});
-	}));
-
-	if (opts.withExtraOperation) {
-		popupMenu([...[{
-			type: 'link',
-			text: i18n.ts.profile,
-			to: `/@${ $i.username }`,
-			avatar: $i,
-		}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
-			type: 'parent',
-			icon: 'ti ti-plus',
-			text: i18n.ts.addAccount,
-			children: [{
-				text: i18n.ts.existingAccount,
-				action: () => { showSigninDialog(); },
-			}, {
-				text: i18n.ts.createAccount,
-				action: () => { createAccount(); },
-			}],
-		}, {
-			type: 'link',
-			icon: 'ti ti-users',
-			text: i18n.ts.manageAccounts,
-			to: '/settings/accounts',
-		}]], ev.currentTarget ?? ev.target, {
-			align: 'left',
-		});
-	} else {
-		popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
-			align: 'left',
-		});
-	}
-}
diff --git a/packages/client/src/components/MkAbuseReport.vue b/packages/client/src/components/MkAbuseReport.vue
deleted file mode 100644
index 9a3464b640..0000000000
--- a/packages/client/src/components/MkAbuseReport.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<template>
-<div class="bcekxzvu _gap _panel">
-	<div class="target">
-		<MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`">
-			<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/>
-			<div class="names">
-				<MkUserName class="name" :user="report.targetUser"/>
-				<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
-			</div>
-		</MkA>
-		<MkKeyValue class="_formBlock">
-			<template #key>{{ i18n.ts.registeredDate }}</template>
-			<template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
-		</MkKeyValue>
-	</div>
-	<div class="detail">
-		<div>
-			<Mfm :text="report.comment"/>
-		</div>
-		<hr/>
-		<div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div>
-		<div v-if="report.assignee">
-			{{ i18n.ts.moderator }}:
-			<MkAcct :user="report.assignee"/>
-		</div>
-		<div><MkTime :time="report.createdAt"/></div>
-		<div class="action">
-			<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
-				{{ i18n.ts.forwardReport }}
-				<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
-			</MkSwitch>
-			<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import MkButton from '@/components/MkButton.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import { acct, userPage } from '@/filters/user';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	report: any;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'resolved', reportId: string): void;
-}>();
-
-let forward = $ref(props.report.forwarded);
-
-function resolve() {
-	os.apiWithDialog('admin/resolve-abuse-user-report', {
-		forward: forward,
-		reportId: props.report.id,
-	}).then(() => {
-		emit('resolved', props.report.id);
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.bcekxzvu {
-	display: flex;
-
-	> .target {
-		width: 35%;
-		box-sizing: border-box;
-		text-align: left;
-		padding: 24px;
-		border-right: solid 1px var(--divider);
-
-		> .info {
-			display: flex;
-			box-sizing: border-box;
-			align-items: center;
-			padding: 14px;
-			border-radius: 8px;
-			--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;
-
-			> .avatar {
-				width: 42px;
-				height: 42px;
-			}
-
-			> .names {
-				margin-left: 0.3em;
-				padding: 0 8px;
-				flex: 1;
-
-				> .name {
-					font-weight: bold;
-				}
-			}
-		}
-	}
-
-	> .detail {
-		flex: 1;
-		padding: 24px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkAbuseReportWindow.vue b/packages/client/src/components/MkAbuseReportWindow.vue
deleted file mode 100644
index 039f77c859..0000000000
--- a/packages/client/src/components/MkAbuseReportWindow.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<template>
-<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
-	<template #header>
-		<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
-		<I18n :src="i18n.ts.reportAbuseOf" tag="span">
-			<template #name>
-				<b><MkAcct :user="user"/></b>
-			</template>
-		</I18n>
-	</template>
-	<div class="dpvffvvy _monolithic_">
-		<div class="_section">
-			<MkTextarea v-model="comment">
-				<template #label>{{ i18n.ts.details }}</template>
-				<template #caption>{{ i18n.ts.fillAbuseReportDescription }}</template>
-			</MkTextarea>
-		</div>
-		<div class="_section">
-			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
-		</div>
-	</div>
-</XWindow>
-</template>
-
-<script setup lang="ts">
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import XWindow from '@/components/MkWindow.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	user: Misskey.entities.User;
-	initialComment?: string;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const uiWindow = ref<InstanceType<typeof XWindow>>();
-const comment = ref(props.initialComment || '');
-
-function send() {
-	os.apiWithDialog('users/report-abuse', {
-		userId: props.user.id,
-		comment: comment.value,
-	}, undefined).then(res => {
-		os.alert({
-			type: 'success',
-			text: i18n.ts.abuseReported,
-		});
-		uiWindow.value?.close();
-		emit('closed');
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.dpvffvvy {
-	--root-margin: 16px;
-}
-</style>
diff --git a/packages/client/src/components/MkActiveUsersHeatmap.vue b/packages/client/src/components/MkActiveUsersHeatmap.vue
deleted file mode 100644
index 02b2eeeb36..0000000000
--- a/packages/client/src/components/MkActiveUsersHeatmap.vue
+++ /dev/null
@@ -1,236 +0,0 @@
-<template>
-<div ref="rootEl">
-	<MkLoading v-if="fetching"/>
-	<div v-else>
-		<canvas ref="chartEl"></canvas>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import { enUS } from 'date-fns/locale';
-import tinycolor from 'tinycolor2';
-import * as os from '@/os';
-import 'chartjs-adapter-date-fns';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
-import { chartVLine } from '@/scripts/chart-vline';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	MatrixController, MatrixElement,
-);
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const rootEl = $ref<HTMLDivElement>(null);
-const chartEl = $ref<HTMLCanvasElement>(null);
-const now = new Date();
-let chartInstance: Chart = null;
-let fetching = $ref(true);
-
-const { handler: externalTooltipHandler } = useChartTooltip({
-	position: 'middle',
-});
-
-async function renderChart() {
-	if (chartInstance) {
-		chartInstance.destroy();
-	}
-
-	const wide = rootEl.offsetWidth > 700;
-	const narrow = rootEl.offsetWidth < 400;
-
-	const weeks = wide ? 50 : narrow ? 10 : 25;
-	const chartLimit = 7 * weeks;
-
-	const getDate = (ago: number) => {
-		const y = now.getFullYear();
-		const m = now.getMonth();
-		const d = now.getDate();
-
-		return new Date(y, m, d - ago);
-	};
-
-	const format = (arr) => {
-		return arr.map((v, i) => {
-			const dt = getDate(i);
-			const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
-			return {
-				x: iso,
-				y: dt.getDay(),
-				d: iso,
-				v,
-			};
-		});
-	};
-
-	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
-
-	fetching = false;
-
-	await nextTick();
-
-	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
-	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-	const color = '#3498db';
-
-	const max = Math.max(...raw.readWrite);
-
-	const marginEachCell = 4;
-
-	chartInstance = new Chart(chartEl, {
-		type: 'matrix',
-		data: {
-			datasets: [{
-				label: 'Read & Write',
-				data: format(raw.readWrite),
-				pointRadius: 0,
-				borderWidth: 0,
-				borderJoinStyle: 'round',
-				borderRadius: 3,
-				backgroundColor(c) {
-					const value = c.dataset.data[c.dataIndex].v;
-					const a = value / max;
-					return alpha(color, a);
-				},
-				fill: true,
-				width(c) {
-					const a = c.chart.chartArea ?? {};
-					// 20週間
-					return (a.right - a.left) / weeks - marginEachCell;
-				},
-				height(c) {
-					const a = c.chart.chartArea ?? {};
-					// 7日
-					return (a.bottom - a.top) / 7 - marginEachCell;
-				},
-			}],
-		},
-		options: {
-			aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
-			layout: {
-				padding: {
-					left: 8,
-					right: 0,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					type: 'time',
-					offset: true,
-					position: 'bottom',
-					time: {
-						unit: 'week',
-						round: 'week',
-						isoWeekday: 0,
-						displayFormats: {
-							week: 'MMM dd',
-						},
-					},
-					grid: {
-						display: false,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: true,
-						maxRotation: 0,
-						autoSkipPadding: 8,
-					},
-				},
-				y: {
-					offset: true,
-					reverse: true,
-					position: 'right',
-					grid: {
-						display: false,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						maxRotation: 0,
-						autoSkip: true,
-						padding: 1,
-						font: {
-							size: 9,
-						},
-						callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value],
-					},
-				},
-			},
-			animation: false,
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					callbacks: {
-						title(context) {
-							const v = context[0].dataset.data[context[0].dataIndex];
-							return v.d;
-						},
-						label(context) {
-							const v = context.dataset.data[context.dataIndex];
-							return ['Active: ' + v.v];
-						},
-					},
-					//mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-			},
-		},
-	});
-}
-
-onMounted(async () => {
-	renderChart();
-});
-</script>
diff --git a/packages/client/src/components/MkAnalogClock.vue b/packages/client/src/components/MkAnalogClock.vue
deleted file mode 100644
index 40ef626aed..0000000000
--- a/packages/client/src/components/MkAnalogClock.vue
+++ /dev/null
@@ -1,225 +0,0 @@
-<template>
-<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
-	<template v-if="props.graduations === 'dots'">
-		<circle
-			v-for="(angle, i) in graduationsMajor"
-			:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
-			:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
-			:r="0.125"
-			:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : majorGraduationColor"
-			:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
-		/>
-	</template>
-	<template v-else-if="props.graduations === 'numbers'">
-		<text
-			v-for="(angle, i) in texts"
-			:x="5 + (Math.sin(angle) * (5 - textsPadding))"
-			:y="5 - (Math.cos(angle) * (5 - textsPadding))"
-			text-anchor="middle"
-			dominant-baseline="middle"
-			:font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7"
-			:font-weight="(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'"
-			:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : 'currentColor'"
-			:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
-		>
-			{{ i === 0 ? (props.twentyfour ? '24' : '12') : i }}
-		</text>
-	</template>
-
-	<!--
-	<line
-		:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
-		:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
-		:x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
-		:y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
-		:stroke="sHandColor"
-		:stroke-width="thickness / 2"
-		stroke-linecap="round"
-	/>
-	-->
-
-	<line
-		class="s"
-		:class="{ animate: !disableSAnimate && sAnimation !== 'none', elastic: sAnimation === 'elastic', easeOut: sAnimation === 'easeOut' }"
-		:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
-		:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
-		:x2="5 + (0 * ((sHandLengthRatio * 5) - handsPadding))"
-		:y2="5 - (1 * ((sHandLengthRatio * 5) - handsPadding))"
-		:stroke="sHandColor"
-		:stroke-width="thickness / 2"
-		:style="`transform: rotateZ(${sAngle}rad)`"
-		stroke-linecap="round"
-	/>
-
-	<line
-		:x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
-		:y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
-		:x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
-		:y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
-		:stroke="mHandColor"
-		:stroke-width="thickness"
-		stroke-linecap="round"
-	/>
-
-	<line
-		:x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
-		:y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
-		:x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
-		:y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
-		:stroke="hHandColor"
-		:stroke-width="thickness"
-		stroke-linecap="round"
-	/>
-</svg>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed, onMounted, onBeforeUnmount, shallowRef, nextTick } from 'vue';
-import tinycolor from 'tinycolor2';
-import { globalEvents } from '@/events.js';
-
-// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
-const angleDiff = (a: number, b: number) => {
-	const x = Math.abs(a - b);
-	return Math.abs((x + Math.PI) % (Math.PI * 2) - Math.PI);
-};
-
-const graduationsPadding = 0.5;
-const textsPadding = 0.6;
-const handsPadding = 1;
-const handsTailLength = 0.7;
-const hHandLengthRatio = 0.75;
-const mHandLengthRatio = 1;
-const sHandLengthRatio = 1;
-const numbersOpacityFactor = 0.35;
-
-const props = withDefaults(defineProps<{
-	thickness?: number;
-	offset?: number;
-	twentyfour?: boolean;
-	graduations?: 'none' | 'dots' | 'numbers';
-	fadeGraduations?: boolean;
-	sAnimation?: 'none' | 'elastic' | 'easeOut';
-}>(), {
-	numbers: false,
-	thickness: 0.1,
-	offset: 0 - new Date().getTimezoneOffset(),
-	twentyfour: false,
-	graduations: 'dots',
-	fadeGraduations: true,
-	sAnimation: 'elastic',
-});
-
-const graduationsMajor = computed(() => {
-	const angles: number[] = [];
-	const times = props.twentyfour ? 24 : 12;
-	for (let i = 0; i < times; i++) {
-		const angle = Math.PI * i / (times / 2);
-		angles.push(angle);
-	}
-	return angles;
-});
-const texts = computed(() => {
-	const angles: number[] = [];
-	const times = props.twentyfour ? 24 : 12;
-	for (let i = 0; i < times; i++) {
-		const angle = Math.PI * i / (times / 2);
-		angles.push(angle);
-	}
-	return angles;
-});
-
-let enabled = true;
-let majorGraduationColor = $ref<string>();
-//let minorGraduationColor = $ref<string>();
-let sHandColor = $ref<string>();
-let mHandColor = $ref<string>();
-let hHandColor = $ref<string>();
-let nowColor = $ref<string>();
-let h = $ref<number>(0);
-let m = $ref<number>(0);
-let s = $ref<number>(0);
-let hAngle = $ref<number>(0);
-let mAngle = $ref<number>(0);
-let sAngle = $ref<number>(0);
-let disableSAnimate = $ref(false);
-let sOneRound = false;
-
-function tick() {
-	const now = new Date();
-	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
-	s = now.getSeconds();
-	m = now.getMinutes();
-	h = now.getHours();
-	hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
-	mAngle = Math.PI * (m + s / 60) / 30;
-	if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
-		sAngle = Math.PI * 60 / 30;
-		window.setTimeout(() => {
-			disableSAnimate = true;
-			window.setTimeout(() => {
-				sAngle = 0;
-				window.setTimeout(() => {
-					disableSAnimate = false;
-				}, 100);
-			}, 100);
-		}, 700);
-	} else {
-		sAngle = Math.PI * s / 30;
-	}
-	sOneRound = s === 59;
-}
-
-tick();
-
-function calcColors() {
-	const computedStyle = getComputedStyle(document.documentElement);
-	const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
-	const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
-	majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
-	//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-	sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
-	mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
-	hHandColor = accent;
-	nowColor = accent;
-}
-
-calcColors();
-
-onMounted(() => {
-	const update = () => {
-		if (enabled) {
-			tick();
-			window.setTimeout(update, 1000);
-		}
-	};
-	update();
-
-	globalEvents.on('themeChanged', calcColors);
-});
-
-onBeforeUnmount(() => {
-	enabled = false;
-
-	globalEvents.off('themeChanged', calcColors);
-});
-</script>
-
-<style lang="scss" scoped>
-.mbcofsoe {
-	display: block;
-
-	> .s {
-		will-change: transform;
-		transform-origin: 50% 50%;
-
-		&.animate.elastic {
-			transition: transform .2s cubic-bezier(.4,2.08,.55,.44);
-		}
-
-		&.animate.easeOut {
-			transition: transform .7s cubic-bezier(0,.7,.3,1);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkAutocomplete.vue b/packages/client/src/components/MkAutocomplete.vue
deleted file mode 100644
index 72783921d5..0000000000
--- a/packages/client/src/components/MkAutocomplete.vue
+++ /dev/null
@@ -1,476 +0,0 @@
-<template>
-<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
-	<ol v-if="type === 'user'" ref="suggests" class="users">
-		<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
-			<img class="avatar" :src="user.avatarUrl"/>
-			<span class="name">
-				<MkUserName :key="user.id" :user="user"/>
-			</span>
-			<span class="username">@{{ acct(user) }}</span>
-		</li>
-		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
-	</ol>
-	<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
-		<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
-			<span class="name">{{ hashtag }}</span>
-		</li>
-	</ol>
-	<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
-		<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
-			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
-			<span v-else-if="defaultStore.state.emojiStyle != 'native'" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
-			<span v-else class="emoji">{{ emoji.emoji }}</span>
-			<!-- eslint-disable-next-line vue/no-v-html -->
-			<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
-			<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
-		</li>
-	</ol>
-	<ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags">
-		<li v-for="tag in mfmTags" tabindex="-1" @click="complete(type, tag)" @keydown="onKeydown">
-			<span class="tag">{{ tag }}</span>
-		</li>
-	</ol>
-</div>
-</template>
-
-<script lang="ts">
-import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
-import contains from '@/scripts/contains';
-import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import { acct } from '@/filters/user';
-import * as os from '@/os';
-import { MFM_TAGS } from '@/scripts/mfm-tags';
-import { defaultStore } from '@/store';
-import { emojilist } from '@/scripts/emojilist';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-type EmojiDef = {
-	emoji: string;
-	name: string;
-	aliasOf?: string;
-	url?: string;
-	isCustomEmoji?: boolean;
-};
-
-const lib = emojilist.filter(x => x.category !== 'flags');
-
-const emjdb: EmojiDef[] = lib.map(x => ({
-	emoji: x.char,
-	name: x.name,
-	url: char2path(x.char),
-}));
-
-const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
-
-for (const x of lib) {
-	if (x.keywords) {
-		for (const k of x.keywords) {
-			emjdb.push({
-				emoji: x.char,
-				name: k,
-				aliasOf: x.name,
-				url: char2path(x.char),
-			});
-		}
-	}
-}
-
-emjdb.sort((a, b) => a.name.length - b.name.length);
-
-//#region Construct Emoji DB
-const customEmojis = instance.emojis;
-const emojiDefinitions: EmojiDef[] = [];
-
-for (const x of customEmojis) {
-	emojiDefinitions.push({
-		name: x.name,
-		emoji: `:${x.name}:`,
-		url: x.url,
-		isCustomEmoji: true,
-	});
-
-	if (x.aliases) {
-		for (const alias of x.aliases) {
-			emojiDefinitions.push({
-				name: alias,
-				aliasOf: x.name,
-				emoji: `:${x.name}:`,
-				url: x.url,
-				isCustomEmoji: true,
-			});
-		}
-	}
-}
-
-emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
-
-const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
-//#endregion
-
-export default {
-	emojiDb,
-	emojiDefinitions,
-	emojilist,
-	customEmojis,
-};
-</script>
-
-<script lang="ts" setup>
-const props = defineProps<{
-	type: string;
-	q: string | null;
-	textarea: HTMLTextAreaElement;
-	close: () => void;
-	x: number;
-	y: number;
-}>();
-
-const emit = defineEmits<{
-	(event: 'done', value: { type: string; value: any }): void;
-	(event: 'closed'): void;
-}>();
-
-const suggests = ref<Element>();
-const rootEl = ref<HTMLDivElement>();
-
-const fetching = ref(true);
-const users = ref<any[]>([]);
-const hashtags = ref<any[]>([]);
-const emojis = ref<(EmojiDef)[]>([]);
-const items = ref<Element[] | HTMLCollection>([]);
-const mfmTags = ref<string[]>([]);
-const select = ref(-1);
-const zIndex = os.claimZIndex('high');
-
-function complete(type: string, value: any) {
-	emit('done', { type, value });
-	emit('closed');
-	if (type === 'emoji') {
-		let recents = defaultStore.state.recentlyUsedEmojis;
-		recents = recents.filter((emoji: any) => emoji !== value);
-		recents.unshift(value);
-		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
-	}
-}
-
-function setPosition() {
-	if (!rootEl.value) return;
-	if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
-		rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
-	} else {
-		rootEl.value.style.left = `${props.x}px`;
-	}
-	if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
-		rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
-		rootEl.value.style.marginTop = '0';
-	} else {
-		rootEl.value.style.top = props.y + 'px';
-		rootEl.value.style.marginTop = 'calc(1em + 8px)';
-	}
-}
-
-function exec() {
-	select.value = -1;
-	if (suggests.value) {
-		for (const el of Array.from(items.value)) {
-			el.removeAttribute('data-selected');
-		}
-	}
-	if (props.type === 'user') {
-		if (!props.q) {
-			users.value = [];
-			fetching.value = false;
-			return;
-		}
-
-		const cacheKey = `autocomplete:user:${props.q}`;
-		const cache = sessionStorage.getItem(cacheKey);
-
-		if (cache) {
-			users.value = JSON.parse(cache);
-			fetching.value = false;
-		} else {
-			os.api('users/search-by-username-and-host', {
-				username: props.q,
-				limit: 10,
-				detail: false,
-			}).then(searchedUsers => {
-				users.value = searchedUsers as any[];
-				fetching.value = false;
-				// キャッシュ
-				sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
-			});
-		}
-	} else if (props.type === 'hashtag') {
-		if (!props.q || props.q === '') {
-			hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
-			fetching.value = false;
-		} else {
-			const cacheKey = `autocomplete:hashtag:${props.q}`;
-			const cache = sessionStorage.getItem(cacheKey);
-			if (cache) {
-				const hashtags = JSON.parse(cache);
-				hashtags.value = hashtags;
-				fetching.value = false;
-			} else {
-				os.api('hashtags/search', {
-					query: props.q,
-					limit: 30,
-				}).then(searchedHashtags => {
-					hashtags.value = searchedHashtags as any[];
-					fetching.value = false;
-					// キャッシュ
-					sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
-				});
-			}
-		}
-	} else if (props.type === 'emoji') {
-		if (!props.q || props.q === '') {
-			// 最近使った絵文字をサジェスト
-			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
-			return;
-		}
-
-		const matched: EmojiDef[] = [];
-		const max = 30;
-
-		emojiDb.some(x => {
-			if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
-			return matched.length === max;
-		});
-
-		if (matched.length < max) {
-			emojiDb.some(x => {
-				if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
-				return matched.length === max;
-			});
-		}
-
-		if (matched.length < max) {
-			emojiDb.some(x => {
-				if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
-				return matched.length === max;
-			});
-		}
-
-		emojis.value = matched;
-	} else if (props.type === 'mfmTag') {
-		if (!props.q || props.q === '') {
-			mfmTags.value = MFM_TAGS;
-			return;
-		}
-
-		mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
-	}
-}
-
-function onMousedown(event: Event) {
-	if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
-}
-
-function onKeydown(event: KeyboardEvent) {
-	const cancel = () => {
-		event.preventDefault();
-		event.stopPropagation();
-	};
-
-	switch (event.key) {
-		case 'Enter':
-			if (select.value !== -1) {
-				cancel();
-				(items.value[select.value] as any).click();
-			} else {
-				props.close();
-			}
-			break;
-
-		case 'Escape':
-			cancel();
-			props.close();
-			break;
-
-		case 'ArrowUp':
-			if (select.value !== -1) {
-				cancel();
-				selectPrev();
-			} else {
-				props.close();
-			}
-			break;
-
-		case 'Tab':
-		case 'ArrowDown':
-			cancel();
-			selectNext();
-			break;
-
-		default:
-			event.stopPropagation();
-			props.textarea.focus();
-	}
-}
-
-function selectNext() {
-	if (++select.value >= items.value.length) select.value = 0;
-	if (items.value.length === 0) select.value = -1;
-	applySelect();
-}
-
-function selectPrev() {
-	if (--select.value < 0) select.value = items.value.length - 1;
-	applySelect();
-}
-
-function applySelect() {
-	for (const el of Array.from(items.value)) {
-		el.removeAttribute('data-selected');
-	}
-
-	if (select.value !== -1) {
-		items.value[select.value].setAttribute('data-selected', 'true');
-		(items.value[select.value] as any).focus();
-	}
-}
-
-function chooseUser() {
-	props.close();
-	os.selectUser().then(user => {
-		complete('user', user);
-		props.textarea.focus();
-	});
-}
-
-onUpdated(() => {
-	setPosition();
-	items.value = suggests.value?.children ?? [];
-});
-
-onMounted(() => {
-	setPosition();
-
-	props.textarea.addEventListener('keydown', onKeydown);
-
-	for (const el of Array.from(document.querySelectorAll('body *'))) {
-		el.addEventListener('mousedown', onMousedown);
-	}
-
-	nextTick(() => {
-		exec();
-
-		watch(() => props.q, () => {
-			nextTick(() => {
-				exec();
-			});
-		});
-	});
-});
-
-onBeforeUnmount(() => {
-	props.textarea.removeEventListener('keydown', onKeydown);
-
-	for (const el of Array.from(document.querySelectorAll('body *'))) {
-		el.removeEventListener('mousedown', onMousedown);
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.swhvrteh {
-	position: fixed;
-	max-width: 100%;
-	margin-top: calc(1em + 8px);
-	overflow: hidden;
-	transition: top 0.1s ease, left 0.1s ease;
-
-	> ol {
-		display: block;
-		margin: 0;
-		padding: 4px 0;
-		max-height: 190px;
-		max-width: 500px;
-		overflow: auto;
-		list-style: none;
-
-		> li {
-			display: flex;
-			align-items: center;
-			padding: 4px 12px;
-			white-space: nowrap;
-			overflow: hidden;
-			font-size: 0.9em;
-			cursor: default;
-
-			&, * {
-				user-select: none;
-			}
-
-			* {
-				overflow: hidden;
-				text-overflow: ellipsis;
-			}
-
-			&:hover {
-				background: var(--X3);
-			}
-
-			&[data-selected='true'] {
-				background: var(--accent);
-
-				&, * {
-					color: #fff !important;
-				}
-			}
-
-			&:active {
-				background: var(--accentDarken);
-
-				&, * {
-					color: #fff !important;
-				}
-			}
-		}
-	}
-
-	> .users > li {
-
-		.avatar {
-			min-width: 28px;
-			min-height: 28px;
-			max-width: 28px;
-			max-height: 28px;
-			margin: 0 8px 0 0;
-			border-radius: 100%;
-		}
-
-		.name {
-			margin: 0 8px 0 0;
-		}
-	}
-
-	> .emojis > li {
-
-		.emoji {
-			display: inline-block;
-			margin: 0 4px 0 0;
-			width: 24px;
-
-			> img {
-				width: 24px;
-				vertical-align: bottom;
-			}
-		}
-
-		.alias {
-			margin: 0 0 0 8px;
-		}
-	}
-
-	> .mfmTags > li {
-
-		.name {
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkAvatars.vue b/packages/client/src/components/MkAvatars.vue
deleted file mode 100644
index 162338b639..0000000000
--- a/packages/client/src/components/MkAvatars.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<template>
-<div>
-	<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
-		<MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
-import * as os from '@/os';
-
-const props = defineProps<{
-	userIds: string[];
-}>();
-
-const users = ref([]);
-
-onMounted(async () => {
-	users.value = await os.api('users/show', {
-		userIds: props.userIds,
-	});
-});
-</script>
diff --git a/packages/client/src/components/MkButton.vue b/packages/client/src/components/MkButton.vue
deleted file mode 100644
index 891645bb2a..0000000000
--- a/packages/client/src/components/MkButton.vue
+++ /dev/null
@@ -1,227 +0,0 @@
-<template>
-<button
-	v-if="!link"
-	ref="el" class="bghgjjyj _button"
-	:class="{ inline, primary, gradate, danger, rounded, full }"
-	:type="type"
-	@click="emit('click', $event)"
-	@mousedown="onMousedown"
->
-	<div ref="ripples" class="ripples"></div>
-	<div class="content">
-		<slot></slot>
-	</div>
-</button>
-<MkA
-	v-else class="bghgjjyj _button"
-	:class="{ inline, primary, gradate, danger, rounded, full }"
-	:to="to"
-	@mousedown="onMousedown"
->
-	<div ref="ripples" class="ripples"></div>
-	<div class="content">
-		<slot></slot>
-	</div>
-</MkA>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted } from 'vue';
-
-const props = defineProps<{
-	type?: 'button' | 'submit' | 'reset';
-	primary?: boolean;
-	gradate?: boolean;
-	rounded?: boolean;
-	inline?: boolean;
-	link?: boolean;
-	to?: string;
-	autofocus?: boolean;
-	wait?: boolean;
-	danger?: boolean;
-	full?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'click', payload: MouseEvent): void;
-}>();
-
-let el = $ref<HTMLElement | null>(null);
-let ripples = $ref<HTMLElement | null>(null);
-
-onMounted(() => {
-	if (props.autofocus) {
-		nextTick(() => {
-			el!.focus();
-		});
-	}
-});
-
-function distance(p, q): number {
-	return Math.hypot(p.x - q.x, p.y - q.y);
-}
-
-function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
-	const origin = { x: circleCenterX, y: circleCenterY };
-	const dist1 = distance({ x: 0, y: 0 }, origin);
-	const dist2 = distance({ x: boxW, y: 0 }, origin);
-	const dist3 = distance({ x: 0, y: boxH }, origin);
-	const dist4 = distance({ x: boxW, y: boxH }, origin);
-	return Math.max(dist1, dist2, dist3, dist4) * 2;
-}
-
-function onMousedown(evt: MouseEvent): void {
-	const target = evt.target! as HTMLElement;
-	const rect = target.getBoundingClientRect();
-
-	const ripple = document.createElement('div');
-	ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
-	ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
-
-	ripples!.appendChild(ripple);
-
-	const circleCenterX = evt.clientX - rect.left;
-	const circleCenterY = evt.clientY - rect.top;
-
-	const scale = calcCircleScale(target.clientWidth, target.clientHeight, circleCenterX, circleCenterY);
-
-	window.setTimeout(() => {
-		ripple.style.transform = 'scale(' + (scale / 2) + ')';
-	}, 1);
-	window.setTimeout(() => {
-		ripple.style.transition = 'all 1s ease';
-		ripple.style.opacity = '0';
-	}, 1000);
-	window.setTimeout(() => {
-		if (ripples) ripples.removeChild(ripple);
-	}, 2000);
-}
-</script>
-
-<style lang="scss" scoped>
-.bghgjjyj {
-	position: relative;
-	z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
-	display: block;
-	min-width: 100px;
-	width: max-content;
-	padding: 7px 14px;
-	text-align: center;
-	font-weight: normal;
-	font-size: 95%;
-	box-shadow: none;
-	text-decoration: none;
-	background: var(--buttonBg);
-	border-radius: 5px;
-	overflow: clip;
-	box-sizing: border-box;
-	transition: background 0.1s ease;
-
-	&:not(:disabled):hover {
-		background: var(--buttonHoverBg);
-	}
-
-	&:not(:disabled):active {
-		background: var(--buttonHoverBg);
-	}
-
-	&.full {
-		width: 100%;
-	}
-
-	&.rounded {
-		border-radius: 999px;
-	}
-
-	&.primary {
-		font-weight: bold;
-		color: var(--fgOnAccent) !important;
-		background: var(--accent);
-
-		&:not(:disabled):hover {
-			background: var(--X8);
-		}
-
-		&:not(:disabled):active {
-			background: var(--X8);
-		}
-	}
-
-	&.gradate {
-		font-weight: bold;
-		color: var(--fgOnAccent) !important;
-		background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
-
-		&:not(:disabled):hover {
-			background: linear-gradient(90deg, var(--X8), var(--X8));
-		}
-
-		&:not(:disabled):active {
-			background: linear-gradient(90deg, var(--X8), var(--X8));
-		}
-	}
-
-	&.danger {
-		color: #ff2a2a;
-
-		&.primary {
-			color: #fff;
-			background: #ff2a2a;
-
-			&:not(:disabled):hover {
-				background: #ff4242;
-			}
-
-			&:not(:disabled):active {
-				background: #d42e2e;
-			}
-		}
-	}
-
-	&:disabled {
-		opacity: 0.7;
-	}
-
-	&:focus-visible {
-		outline: solid 2px var(--focus);
-		outline-offset: 2px;
-	}
-
-	&.inline {
-		display: inline-block;
-		width: auto;
-		min-width: 100px;
-	}
-
-	> .ripples {
-		position: absolute;
-		z-index: 0;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		border-radius: 6px;
-		overflow: hidden;
-
-		::v-deep(div) {
-			position: absolute;
-			width: 2px;
-			height: 2px;
-			border-radius: 100%;
-			background: rgba(0, 0, 0, 0.1);
-			opacity: 1;
-			transform: scale(1);
-			transition: all 0.5s cubic-bezier(0,.5,0,1);
-		}
-	}
-
-	&.primary > .ripples ::v-deep(div) {
-		background: rgba(0, 0, 0, 0.15);
-	}
-
-	> .content {
-		position: relative;
-		z-index: 1;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkCaptcha.vue b/packages/client/src/components/MkCaptcha.vue
deleted file mode 100644
index 6d218389fc..0000000000
--- a/packages/client/src/components/MkCaptcha.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<template>
-<div>
-	<span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span>
-	<div ref="captchaEl"></div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-
-type Captcha = {
-	render(container: string | Node, options: {
-		readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
-	}): string;
-	remove(id: string): void;
-	execute(id: string): void;
-	reset(id?: string): void;
-	getResponse(id: string): string;
-};
-
-type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
-
-type CaptchaContainer = {
-	readonly [_ in CaptchaProvider]?: Captcha;
-};
-
-declare global {
-	interface Window extends CaptchaContainer { }
-}
-
-const props = defineProps<{
-	provider: CaptchaProvider;
-	sitekey: string;
-	modelValue?: string | null;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', v: string | null): void;
-}>();
-
-const available = ref(false);
-
-const captchaEl = ref<HTMLDivElement | undefined>();
-
-const variable = computed(() => {
-	switch (props.provider) {
-		case 'hcaptcha': return 'hcaptcha';
-		case 'recaptcha': return 'grecaptcha';
-		case 'turnstile': return 'turnstile';
-	}
-});
-
-const loaded = !!window[variable.value];
-
-const src = computed(() => {
-	switch (props.provider) {
-		case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
-		case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
-		case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
-	}
-});
-
-const scriptId = computed(() => `script-${props.provider}`)
-
-const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
-
-if (loaded) {
-	available.value = true;
-} else {
-	(document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), {
-		async: true,
-		id: scriptId.value,
-		src: src.value,
-	})))
-		.addEventListener('load', () => available.value = true);
-}
-
-function reset() {
-	if (captcha.value.reset) captcha.value.reset();
-}
-
-function requestRender() {
-	if (captcha.value.render && captchaEl.value instanceof Element) {
-		captcha.value.render(captchaEl.value, {
-			sitekey: props.sitekey,
-			theme: defaultStore.state.darkMode ? 'dark' : 'light',
-			callback: callback,
-			'expired-callback': callback,
-			'error-callback': callback,
-		});
-	} else {
-		window.setTimeout(requestRender, 1);
-	}
-}
-
-function callback(response?: string) {
-	emit('update:modelValue', typeof response === 'string' ? response : null);
-}
-
-onMounted(() => {
-	if (available.value) {
-		requestRender();
-	} else {
-		watch(available, requestRender);
-	}
-});
-
-onBeforeUnmount(() => {
-	reset();
-});
-
-defineExpose({
-	reset,
-});
-
-</script>
diff --git a/packages/client/src/components/MkChannelFollowButton.vue b/packages/client/src/components/MkChannelFollowButton.vue
deleted file mode 100644
index 9e275d6172..0000000000
--- a/packages/client/src/components/MkChannelFollowButton.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<template>
-<button
-	class="hdcaacmi _button"
-	:class="{ wait, active: isFollowing, full }"
-	:disabled="wait"
-	@click="onClick"
->
-	<template v-if="!wait">
-		<template v-if="isFollowing">
-			<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
-		</template>
-		<template v-else>
-			<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
-		</template>
-	</template>
-	<template v-else>
-		<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/>
-	</template>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	channel: Record<string, any>;
-	full?: boolean;
-}>(), {
-	full: false,
-});
-
-const isFollowing = ref<boolean>(props.channel.isFollowing);
-const wait = ref(false);
-
-async function onClick() {
-	wait.value = true;
-
-	try {
-		if (isFollowing.value) {
-			await os.api('channels/unfollow', {
-				channelId: props.channel.id,
-			});
-			isFollowing.value = false;
-		} else {
-			await os.api('channels/follow', {
-				channelId: props.channel.id,
-			});
-			isFollowing.value = true;
-		}
-	} catch (err) {
-		console.error(err);
-	} finally {
-		wait.value = false;
-	}
-}
-</script>
-
-<style lang="scss" scoped>
-.hdcaacmi {
-	position: relative;
-	display: inline-block;
-	font-weight: bold;
-	color: var(--accent);
-	background: transparent;
-	border: solid 1px var(--accent);
-	padding: 0;
-	height: 31px;
-	font-size: 16px;
-	border-radius: 32px;
-	background: #fff;
-
-	&.full {
-		padding: 0 8px 0 12px;
-		font-size: 14px;
-	}
-
-	&:not(.full) {
-		width: 31px;
-	}
-
-	&:focus-visible {
-		&:after {
-			content: "";
-			pointer-events: none;
-			position: absolute;
-			top: -5px;
-			right: -5px;
-			bottom: -5px;
-			left: -5px;
-			border: 2px solid var(--focus);
-			border-radius: 32px;
-		}
-	}
-
-	&:hover {
-		//background: mix($primary, #fff, 20);
-	}
-
-	&:active {
-		//background: mix($primary, #fff, 40);
-	}
-
-	&.active {
-		color: #fff;
-		background: var(--accent);
-
-		&:hover {
-			background: var(--accentLighten);
-			border-color: var(--accentLighten);
-		}
-
-		&:active {
-			background: var(--accentDarken);
-			border-color: var(--accentDarken);
-		}
-	}
-
-	&.wait {
-		cursor: wait !important;
-		opacity: 0.7;
-	}
-
-	> span {
-		margin-right: 6px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkChannelPreview.vue b/packages/client/src/components/MkChannelPreview.vue
deleted file mode 100644
index 6ef50bddcf..0000000000
--- a/packages/client/src/components/MkChannelPreview.vue
+++ /dev/null
@@ -1,154 +0,0 @@
-<template>
-<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
-	<div class="banner" :style="bannerStyle">
-		<div class="fade"></div>
-		<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
-		<div class="status">
-			<div>
-				<i class="ti ti-users ti-fw"></i>
-				<I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;">
-					<template #n>
-						<b>{{ channel.usersCount }}</b>
-					</template>
-				</I18n>
-			</div>
-			<div>
-				<i class="ti ti-pencil ti-fw"></i>
-				<I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;">
-					<template #n>
-						<b>{{ channel.notesCount }}</b>
-					</template>
-				</I18n>
-			</div>
-		</div>
-	</div>
-	<article v-if="channel.description">
-		<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
-	</article>
-	<footer>
-		<span v-if="channel.lastNotedAt">
-			{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
-		</span>
-	</footer>
-</MkA>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	channel: Record<string, any>;
-}>();
-
-const bannerStyle = computed(() => {
-	if (props.channel.bannerUrl) {
-		return { backgroundImage: `url(${props.channel.bannerUrl})` };
-	} else {
-		return { backgroundColor: '#4c5e6d' };
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.eftoefju {
-	display: block;
-	overflow: hidden;
-	width: 100%;
-
-	&:hover {
-		text-decoration: none;
-	}
-
-	> .banner {
-		position: relative;
-		width: 100%;
-		height: 200px;
-		background-position: center;
-		background-size: cover;
-
-		> .fade {
-			position: absolute;
-			bottom: 0;
-			left: 0;
-			width: 100%;
-			height: 64px;
-			background: linear-gradient(0deg, var(--panel), var(--X15));
-		}
-
-		> .name {
-			position: absolute;
-			top: 16px;
-			left: 16px;
-			padding: 12px 16px;
-			background: rgba(0, 0, 0, 0.7);
-			color: #fff;
-			font-size: 1.2em;
-		}
-
-		> .status {
-			position: absolute;
-			z-index: 1;
-			bottom: 16px;
-			right: 16px;
-			padding: 8px 12px;
-			font-size: 80%;
-			background: rgba(0, 0, 0, 0.7);
-			border-radius: 6px;
-			color: #fff;
-		}
-	}
-
-	> article {
-		padding: 16px;
-
-		> p {
-			margin: 0;
-			font-size: 1em;
-		}
-	}
-
-	> footer {
-		padding: 12px 16px;
-		border-top: solid 0.5px var(--divider);
-
-		> span {
-			opacity: 0.7;
-			font-size: 0.9em;
-		}
-	}
-
-	@media (max-width: 550px) {
-		font-size: 0.9em;
-
-		> .banner {
-			height: 80px;
-
-			> .status {
-				display: none;
-			}
-		}
-
-		> article {
-			padding: 12px;
-		}
-
-		> footer {
-			display: none;
-		}
-	}
-
-	@media (max-width: 500px) {
-		font-size: 0.8em;
-
-		> .banner {
-			height: 70px;
-		}
-
-		> article {
-			padding: 8px;
-		}
-	}
-}
-
-</style>
diff --git a/packages/client/src/components/MkChart.vue b/packages/client/src/components/MkChart.vue
deleted file mode 100644
index fbbc231b88..0000000000
--- a/packages/client/src/components/MkChart.vue
+++ /dev/null
@@ -1,859 +0,0 @@
-<template>
-<div class="cbbedffa">
-	<canvas ref="chartEl"></canvas>
-	<div v-if="fetching" class="fetching">
-		<MkLoading/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-/* eslint-disable id-denylist --
-  Chart.js has a `data` attribute in most chart definitions, which triggers the
-  id-denylist violation when setting it. This is causing about 60+ lint issues.
-  As this is part of Chart.js's API it makes sense to disable the check here.
-*/
-import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import 'chartjs-adapter-date-fns';
-import { enUS } from 'date-fns/locale';
-import zoomPlugin from 'chartjs-plugin-zoom';
-import gradient from 'chartjs-plugin-gradient';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import { chartVLine } from '@/scripts/chart-vline';
-
-const props = defineProps({
-	src: {
-		type: String,
-		required: true,
-	},
-	args: {
-		type: Object,
-		required: false,
-	},
-	limit: {
-		type: Number,
-		required: false,
-		default: 90,
-	},
-	span: {
-		type: String as PropType<'hour' | 'day'>,
-		required: true,
-	},
-	detailed: {
-		type: Boolean,
-		required: false,
-		default: false,
-	},
-	stacked: {
-		type: Boolean,
-		required: false,
-		default: false,
-	},
-	bar: {
-		type: Boolean,
-		required: false,
-		default: false,
-	},
-	aspectRatio: {
-		type: Number,
-		required: false,
-		default: null,
-	},
-});
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	zoomPlugin,
-	gradient,
-);
-
-const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
-const negate = arr => arr.map(x => -x);
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const colors = {
-	blue: '#008FFB',
-	green: '#00E396',
-	yellow: '#FEB019',
-	red: '#FF4560',
-	purple: '#e300db',
-	orange: '#fe6919',
-	lime: '#bde800',
-	cyan: '#00e0e0',
-};
-const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
-const getColor = (i) => {
-	return colorSets[i % colorSets.length];
-};
-
-const now = new Date();
-let chartInstance: Chart = null;
-let chartData: {
-	series: {
-		name: string;
-		type: 'line' | 'area';
-		color?: string;
-		dashed?: boolean;
-		hidden?: boolean;
-		data: {
-			x: number;
-			y: number;
-		}[];
-	}[];
-} = null;
-
-const chartEl = ref<HTMLCanvasElement>(null);
-const fetching = ref(true);
-
-const getDate = (ago: number) => {
-	const y = now.getFullYear();
-	const m = now.getMonth();
-	const d = now.getDate();
-	const h = now.getHours();
-
-	return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
-};
-
-const format = (arr) => {
-	return arr.map((v, i) => ({
-		x: getDate(i).getTime(),
-		y: v,
-	}));
-};
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-
-const render = () => {
-	if (chartInstance) {
-		chartInstance.destroy();
-	}
-
-	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-
-	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-	const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
-
-	chartInstance = new Chart(chartEl.value, {
-		type: props.bar ? 'bar' : 'line',
-		data: {
-			labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
-			datasets: chartData.series.map((x, i) => ({
-				parsing: false,
-				label: x.name,
-				data: x.data.slice().reverse(),
-				tension: 0.3,
-				pointRadius: 0,
-				borderWidth: props.bar ? 0 : 2,
-				borderColor: x.color ? x.color : getColor(i),
-				borderDash: x.dashed ? [5, 5] : [],
-				borderJoinStyle: 'round',
-				borderRadius: props.bar ? 3 : undefined,
-				backgroundColor: props.bar ? (x.color ? x.color : getColor(i)) : alpha(x.color ? x.color : getColor(i), 0.1),
-				gradient: props.bar ? undefined : {
-					backgroundColor: {
-						axis: 'y',
-						colors: {
-							0: alpha(x.color ? x.color : getColor(i), 0),
-							[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.35),
-						},
-					},
-				},
-				barPercentage: 0.9,
-				categoryPercentage: 0.9,
-				fill: x.type === 'area',
-				clip: 8,
-				hidden: !!x.hidden,
-			})),
-		},
-		options: {
-			aspectRatio: props.aspectRatio || 2.5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 8,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					type: 'time',
-					stacked: props.stacked,
-					offset: false,
-					time: {
-						stepSize: 1,
-						unit: props.span === 'day' ? 'month' : 'day',
-					},
-					grid: {
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: props.detailed,
-						maxRotation: 0,
-						autoSkipPadding: 16,
-					},
-					adapters: {
-						date: {
-							locale: enUS,
-						},
-					},
-					min: getDate(props.limit).getTime(),
-				},
-				y: {
-					position: 'left',
-					stacked: props.stacked,
-					suggestedMax: 50,
-					grid: {
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: props.detailed,
-						//mirror: true,
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-				mode: 'index',
-			},
-			elements: {
-				point: {
-					hoverRadius: 5,
-					hoverBorderWidth: 2,
-				},
-			},
-			animation: false,
-			plugins: {
-				legend: {
-					display: props.detailed,
-					position: 'bottom',
-					labels: {
-						boxWidth: 16,
-					},
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-				zoom: props.detailed ? {
-					pan: {
-						enabled: true,
-					},
-					zoom: {
-						wheel: {
-							enabled: true,
-						},
-						pinch: {
-							enabled: true,
-						},
-						drag: {
-							enabled: false,
-						},
-						mode: 'x',
-					},
-					limits: {
-						x: {
-							min: 'original',
-							max: 'original',
-						},
-						y: {
-							min: 'original',
-							max: 'original',
-						},
-					},
-				} : undefined,
-				gradient,
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-};
-
-const exportData = () => {
-	// TODO
-};
-
-const fetchFederationChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Received',
-			type: 'area',
-			data: format(raw.inboxInstances),
-			color: colors.blue,
-		}, {
-			name: 'Delivered',
-			type: 'area',
-			data: format(raw.deliveredInstances),
-			color: colors.green,
-		}, {
-			name: 'Stalled',
-			type: 'area',
-			data: format(raw.stalled),
-			color: colors.red,
-		}, {
-			name: 'Pub Active',
-			type: 'line',
-			data: format(raw.pubActive),
-			color: colors.purple,
-		}, {
-			name: 'Sub Active',
-			type: 'line',
-			data: format(raw.subActive),
-			color: colors.orange,
-		}, {
-			name: 'Pub & Sub',
-			type: 'line',
-			data: format(raw.pubsub),
-			dashed: true,
-			color: colors.cyan,
-		}, {
-			name: 'Pub',
-			type: 'line',
-			data: format(raw.pub),
-			dashed: true,
-			color: colors.purple,
-		}, {
-			name: 'Sub',
-			type: 'line',
-			data: format(raw.sub),
-			dashed: true,
-			color: colors.orange,
-		}],
-	};
-};
-
-const fetchApRequestChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'In',
-			type: 'area',
-			color: '#008FFB',
-			data: format(raw.inboxReceived),
-		}, {
-			name: 'Out (succ)',
-			type: 'area',
-			color: '#00E396',
-			data: format(raw.deliverSucceeded),
-		}, {
-			name: 'Out (fail)',
-			type: 'area',
-			color: '#FEB019',
-			data: format(raw.deliverFailed),
-		}],
-	};
-};
-
-const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'All',
-			type: 'line',
-			data: format(type === 'combined'
-				? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
-				: sum(raw[type].inc, negate(raw[type].dec)),
-			),
-			color: '#888888',
-		}, {
-			name: 'Renotes',
-			type: 'area',
-			data: format(type === 'combined'
-				? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
-				: raw[type].diffs.renote,
-			),
-			color: colors.green,
-		}, {
-			name: 'Replies',
-			type: 'area',
-			data: format(type === 'combined'
-				? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
-				: raw[type].diffs.reply,
-			),
-			color: colors.yellow,
-		}, {
-			name: 'Normal',
-			type: 'area',
-			data: format(type === 'combined'
-				? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
-				: raw[type].diffs.normal,
-			),
-			color: colors.blue,
-		}, {
-			name: 'With file',
-			type: 'area',
-			data: format(type === 'combined'
-				? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
-				: raw[type].diffs.withFile,
-			),
-			color: colors.purple,
-		}],
-	};
-};
-
-const fetchNotesTotalChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Combined',
-			type: 'line',
-			data: format(sum(raw.local.total, raw.remote.total)),
-		}, {
-			name: 'Local',
-			type: 'area',
-			data: format(raw.local.total),
-		}, {
-			name: 'Remote',
-			type: 'area',
-			data: format(raw.remote.total),
-		}],
-	};
-};
-
-const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Combined',
-			type: 'line',
-			data: format(total
-				? sum(raw.local.total, raw.remote.total)
-				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
-			),
-		}, {
-			name: 'Local',
-			type: 'area',
-			data: format(total
-				? raw.local.total
-				: sum(raw.local.inc, negate(raw.local.dec)),
-			),
-		}, {
-			name: 'Remote',
-			type: 'area',
-			data: format(total
-				? raw.remote.total
-				: sum(raw.remote.inc, negate(raw.remote.dec)),
-			),
-		}],
-	};
-};
-
-const fetchActiveUsersChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Read & Write',
-			type: 'area',
-			data: format(raw.readWrite),
-			color: colors.orange,
-		}, {
-			name: 'Write',
-			type: 'area',
-			data: format(raw.write),
-			color: colors.lime,
-		}, {
-			name: 'Read',
-			type: 'area',
-			data: format(raw.read),
-			color: colors.blue,
-		}, {
-			name: '< Week',
-			type: 'area',
-			data: format(raw.registeredWithinWeek),
-			color: colors.green,
-		}, {
-			name: '< Month',
-			type: 'area',
-			data: format(raw.registeredWithinMonth),
-			color: colors.yellow,
-		}, {
-			name: '< Year',
-			type: 'area',
-			data: format(raw.registeredWithinYear),
-			color: colors.red,
-		}, {
-			name: '> Week',
-			type: 'area',
-			data: format(raw.registeredOutsideWeek),
-			color: colors.yellow,
-		}, {
-			name: '> Month',
-			type: 'area',
-			data: format(raw.registeredOutsideMonth),
-			color: colors.red,
-		}, {
-			name: '> Year',
-			type: 'area',
-			data: format(raw.registeredOutsideYear),
-			color: colors.purple,
-		}],
-	};
-};
-
-const fetchDriveChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
-	return {
-		bytes: true,
-		series: [{
-			name: 'All',
-			type: 'line',
-			dashed: true,
-			data: format(
-				sum(
-					raw.local.incSize,
-					negate(raw.local.decSize),
-					raw.remote.incSize,
-					negate(raw.remote.decSize),
-				),
-			),
-		}, {
-			name: 'Local +',
-			type: 'area',
-			data: format(raw.local.incSize),
-		}, {
-			name: 'Local -',
-			type: 'area',
-			data: format(negate(raw.local.decSize)),
-		}, {
-			name: 'Remote +',
-			type: 'area',
-			data: format(raw.remote.incSize),
-		}, {
-			name: 'Remote -',
-			type: 'area',
-			data: format(negate(raw.remote.decSize)),
-		}],
-	};
-};
-
-const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'All',
-			type: 'line',
-			dashed: true,
-			data: format(
-				sum(
-					raw.local.incCount,
-					negate(raw.local.decCount),
-					raw.remote.incCount,
-					negate(raw.remote.decCount),
-				),
-			),
-		}, {
-			name: 'Local +',
-			type: 'area',
-			data: format(raw.local.incCount),
-		}, {
-			name: 'Local -',
-			type: 'area',
-			data: format(negate(raw.local.decCount)),
-		}, {
-			name: 'Remote +',
-			type: 'area',
-			data: format(raw.remote.incCount),
-		}, {
-			name: 'Remote -',
-			type: 'area',
-			data: format(negate(raw.remote.decCount)),
-		}],
-	};
-};
-
-const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'In',
-			type: 'area',
-			color: '#008FFB',
-			data: format(raw.requests.received),
-		}, {
-			name: 'Out (succ)',
-			type: 'area',
-			color: '#00E396',
-			data: format(raw.requests.succeeded),
-		}, {
-			name: 'Out (fail)',
-			type: 'area',
-			color: '#FEB019',
-			data: format(raw.requests.failed),
-		}],
-	};
-};
-
-const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Users',
-			type: 'area',
-			color: '#008FFB',
-			data: format(total
-				? raw.users.total
-				: sum(raw.users.inc, negate(raw.users.dec)),
-			),
-		}],
-	};
-};
-
-const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Notes',
-			type: 'area',
-			color: '#008FFB',
-			data: format(total
-				? raw.notes.total
-				: sum(raw.notes.inc, negate(raw.notes.dec)),
-			),
-		}],
-	};
-};
-
-const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Following',
-			type: 'area',
-			color: '#008FFB',
-			data: format(total
-				? raw.following.total
-				: sum(raw.following.inc, negate(raw.following.dec)),
-			),
-		}, {
-			name: 'Followers',
-			type: 'area',
-			color: '#00E396',
-			data: format(total
-				? raw.followers.total
-				: sum(raw.followers.inc, negate(raw.followers.dec)),
-			),
-		}],
-	};
-};
-
-const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		bytes: true,
-		series: [{
-			name: 'Drive usage',
-			type: 'area',
-			color: '#008FFB',
-			data: format(total
-				? raw.drive.totalUsage
-				: sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
-			),
-		}],
-	};
-};
-
-const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Drive files',
-			type: 'area',
-			color: '#008FFB',
-			data: format(total
-				? raw.drive.totalFiles
-				: sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
-			),
-		}],
-	};
-};
-
-const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
-	return {
-		series: [...(props.args.withoutAll ? [] : [{
-			name: 'All',
-			type: 'line',
-			data: format(sum(raw.inc, negate(raw.dec))),
-			color: '#888888',
-		}]), {
-			name: 'With file',
-			type: 'area',
-			data: format(raw.diffs.withFile),
-			color: colors.purple,
-		}, {
-			name: 'Renotes',
-			type: 'area',
-			data: format(raw.diffs.renote),
-			color: colors.green,
-		}, {
-			name: 'Replies',
-			type: 'area',
-			data: format(raw.diffs.reply),
-			color: colors.yellow,
-		}, {
-			name: 'Normal',
-			type: 'area',
-			data: format(raw.diffs.normal),
-			color: colors.blue,
-		}],
-	};
-};
-
-const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Local',
-			type: 'area',
-			data: format(raw.local.followings.total),
-		}, {
-			name: 'Remote',
-			type: 'area',
-			data: format(raw.remote.followings.total),
-		}],
-	};
-};
-
-const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Local',
-			type: 'area',
-			data: format(raw.local.followers.total),
-		}, {
-			name: 'Remote',
-			type: 'area',
-			data: format(raw.remote.followers.total),
-		}],
-	};
-};
-
-const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
-	const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
-	return {
-		series: [{
-			name: 'Inc',
-			type: 'area',
-			data: format(raw.incSize),
-		}, {
-			name: 'Dec',
-			type: 'area',
-			data: format(raw.decSize),
-		}],
-	};
-};
-
-const fetchAndRender = async () => {
-	const fetchData = () => {
-		switch (props.src) {
-			case 'federation': return fetchFederationChart();
-			case 'ap-request': return fetchApRequestChart();
-			case 'users': return fetchUsersChart(false);
-			case 'users-total': return fetchUsersChart(true);
-			case 'active-users': return fetchActiveUsersChart();
-			case 'notes': return fetchNotesChart('combined');
-			case 'local-notes': return fetchNotesChart('local');
-			case 'remote-notes': return fetchNotesChart('remote');
-			case 'notes-total': return fetchNotesTotalChart();
-			case 'drive': return fetchDriveChart();
-			case 'drive-files': return fetchDriveFilesChart();
-			case 'instance-requests': return fetchInstanceRequestsChart();
-			case 'instance-users': return fetchInstanceUsersChart(false);
-			case 'instance-users-total': return fetchInstanceUsersChart(true);
-			case 'instance-notes': return fetchInstanceNotesChart(false);
-			case 'instance-notes-total': return fetchInstanceNotesChart(true);
-			case 'instance-ff': return fetchInstanceFfChart(false);
-			case 'instance-ff-total': return fetchInstanceFfChart(true);
-			case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false);
-			case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true);
-			case 'instance-drive-files': return fetchInstanceDriveFilesChart(false);
-			case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
-
-			case 'per-user-notes': return fetchPerUserNotesChart();
-			case 'per-user-following': return fetchPerUserFollowingChart();
-			case 'per-user-followers': return fetchPerUserFollowersChart();
-			case 'per-user-drive': return fetchPerUserDriveChart();
-		}
-	};
-	fetching.value = true;
-	chartData = await fetchData();
-	fetching.value = false;
-	render();
-};
-
-watch(() => [props.src, props.span], fetchAndRender);
-
-onMounted(() => {
-	fetchAndRender();
-});
-/* eslint-enable id-denylist */
-</script>
-
-<style lang="scss" scoped>
-.cbbedffa {
-	position: relative;
-
-	> .fetching {
-		position: absolute;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		-webkit-backdrop-filter: var(--blur, blur(12px));
-		backdrop-filter: var(--blur, blur(12px));
-		display: flex;
-		justify-content: center;
-		align-items: center;
-		cursor: wait;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkChartTooltip.vue b/packages/client/src/components/MkChartTooltip.vue
deleted file mode 100644
index d36f45463c..0000000000
--- a/packages/client/src/components/MkChartTooltip.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<template>
-<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
-	<div v-if="title || series" class="qpcyisrl">
-		<div v-if="title" class="title">{{ title }}</div>
-		<template v-if="series">
-			<div v-for="x in series" class="series">
-				<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span>
-				<span>{{ x.text }}</span>
-			</div>
-		</template>
-	</div>
-</MkTooltip>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkTooltip from './MkTooltip.vue';
-
-defineProps<{
-	showing: boolean;
-	x: number;
-	y: number;
-	title?: string;
-	series?: {
-		backgroundColor: string;
-		borderColor: string;
-		text: string;
-	}[];
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.qpcyisrl {
-	> .title {
-		margin-bottom: 4px;
-	}
-
-	> .series {
-		> .color {
-			display: inline-block;
-			width: 8px;
-			height: 8px;
-			border-width: 1px;
-			border-style: solid;
-			margin-right: 8px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkCode.core.vue b/packages/client/src/components/MkCode.core.vue
deleted file mode 100644
index b074028821..0000000000
--- a/packages/client/src/components/MkCode.core.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-<!-- eslint-disable vue/no-v-html -->
-<template>
-<code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code>
-<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import Prism from 'prismjs';
-import 'prismjs/themes/prism-okaidia.css';
-
-const props = defineProps<{
-	code: string;
-	lang?: string;
-	inline?: boolean;
-}>();
-
-const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
-const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
-</script>
diff --git a/packages/client/src/components/MkCode.vue b/packages/client/src/components/MkCode.vue
deleted file mode 100644
index 1640258d5b..0000000000
--- a/packages/client/src/components/MkCode.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<template>
-<XCode :code="code" :lang="lang" :inline="inline"/>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-
-defineProps<{
-	code: string;
-	lang?: string;
-	inline?: boolean;
-}>();
-
-const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
-</script>
diff --git a/packages/client/src/components/MkContainer.vue b/packages/client/src/components/MkContainer.vue
deleted file mode 100644
index 6d4d5be2bc..0000000000
--- a/packages/client/src/components/MkContainer.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-<template>
-<div v-size="{ max: [380] }" class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }">
-	<header v-if="showHeader" ref="header">
-		<div class="title"><slot name="header"></slot></div>
-		<div class="sub">
-			<slot name="func"></slot>
-			<button v-if="foldable" class="_button" @click="() => showBody = !showBody">
-				<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
-				<template v-else><i class="ti ti-chevron-down"></i></template>
-			</button>
-		</div>
-	</header>
-	<transition
-		:name="$store.state.animation ? 'container-toggle' : ''"
-		@enter="enter"
-		@after-enter="afterEnter"
-		@leave="leave"
-		@after-leave="afterLeave"
-	>
-		<div v-show="showBody" ref="content" class="content" :class="{ omitted }">
-			<slot></slot>
-			<button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
-				<span>{{ $ts.showMore }}</span>
-			</button>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-
-export default defineComponent({
-	props: {
-		showHeader: {
-			type: Boolean,
-			required: false,
-			default: true,
-		},
-		thin: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		naked: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		foldable: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		expanded: {
-			type: Boolean,
-			required: false,
-			default: true,
-		},
-		scrollable: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		maxHeight: {
-			type: Number,
-			required: false,
-			default: null,
-		},
-	},
-	data() {
-		return {
-			showBody: this.expanded,
-			omitted: null,
-			ignoreOmit: false,
-		};
-	},
-	mounted() {
-		this.$watch('showBody', showBody => {
-			const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
-			this.$el.style.minHeight = `${headerHeight}px`;
-			if (showBody) {
-				this.$el.style.flexBasis = 'auto';
-			} else {
-				this.$el.style.flexBasis = `${headerHeight}px`;
-			}
-		}, {
-			immediate: true,
-		});
-
-		this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
-
-		const calcOmit = () => {
-			if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
-			const height = this.$refs.content.offsetHeight;
-			this.omitted = height > this.maxHeight;
-		};
-
-		calcOmit();
-		new ResizeObserver((entries, observer) => {
-			calcOmit();
-		}).observe(this.$refs.content);
-	},
-	methods: {
-		toggleContent(show: boolean) {
-			if (!this.foldable) return;
-			this.showBody = show;
-		},
-
-		enter(el) {
-			const elementHeight = el.getBoundingClientRect().height;
-			el.style.height = 0;
-			el.offsetHeight; // reflow
-			el.style.height = elementHeight + 'px';
-		},
-		afterEnter(el) {
-			el.style.height = null;
-		},
-		leave(el) {
-			const elementHeight = el.getBoundingClientRect().height;
-			el.style.height = elementHeight + 'px';
-			el.offsetHeight; // reflow
-			el.style.height = 0;
-		},
-		afterLeave(el) {
-			el.style.height = null;
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.container-toggle-enter-active, .container-toggle-leave-active {
-	overflow-y: hidden;
-	transition: opacity 0.5s, height 0.5s !important;
-}
-.container-toggle-enter-from {
-	opacity: 0;
-}
-.container-toggle-leave-to {
-	opacity: 0;
-}
-
-.ukygtjoj {
-	position: relative;
-	overflow: clip;
-	contain: content;
-
-	&.naked {
-		background: transparent !important;
-		box-shadow: none !important;
-	}
-
-	&.scrollable {
-		display: flex;
-		flex-direction: column;
-
-		> .content {
-			overflow: auto;
-		}
-	}
-
-	> header {
-		position: sticky;
-		top: var(--stickyTop, 0px);
-		left: 0;
-		color: var(--panelHeaderFg);
-		background: var(--panelHeaderBg);
-		border-bottom: solid 0.5px var(--panelHeaderDivider);
-		z-index: 2;
-		line-height: 1.4em;
-
-		> .title {
-			margin: 0;
-			padding: 12px 16px;
-
-			> ::v-deep(i) {
-				margin-right: 6px;
-			}
-
-			&:empty {
-				display: none;
-			}
-		}
-
-		> .sub {
-			position: absolute;
-			z-index: 2;
-			top: 0;
-			right: 0;
-			height: 100%;
-
-			> ::v-deep(button) {
-				width: 42px;
-				height: 100%;
-			}
-		}
-	}
-
-	> .content {
-		--stickyTop: 0px;
-
-		&.omitted {
-			position: relative;
-			max-height: var(--maxHeight);
-			overflow: hidden;
-
-			> .fade {
-				display: block;
-				position: absolute;
-				z-index: 10;
-				bottom: 0;
-				left: 0;
-				width: 100%;
-				height: 64px;
-				background: linear-gradient(0deg, var(--panel), var(--X15));
-
-				> span {
-					display: inline-block;
-					background: var(--panel);
-					padding: 6px 10px;
-					font-size: 0.8em;
-					border-radius: 999px;
-					box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
-				}
-
-				&:hover {
-					> span {
-						background: var(--panelHighlight);
-					}
-				}
-			}
-		}
-	}
-
-	&.max-width_380px, &.thin {
-		> header {
-			> .title {
-				padding: 8px 10px;
-				font-size: 0.9em;
-			}
-		}
-
-		> .content {
-		}
-	}
-}
-
-@container (max-width: 380px) {
-	.ukygtjoj {
-		> header {
-			> .title {
-				padding: 8px 10px;
-				font-size: 0.9em;
-			}
-		}
-	}
-}
-
-._forceContainerFull_ .ukygtjoj {
-	> header {
-		> .title {
-			padding: 12px 16px !important;
-		}
-	}
-}
-
-._forceContainerFull_.ukygtjoj {
-	> header {
-		> .title {
-			padding: 12px 16px !important;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkContextMenu.vue b/packages/client/src/components/MkContextMenu.vue
deleted file mode 100644
index cfc9502b41..0000000000
--- a/packages/client/src/components/MkContextMenu.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'fade' : ''" appear>
-	<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
-		<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onBeforeUnmount } from 'vue';
-import MkMenu from './MkMenu.vue';
-import { MenuItem } from './types/menu.vue';
-import contains from '@/scripts/contains';
-import * as os from '@/os';
-
-const props = defineProps<{
-	items: MenuItem[];
-	ev: MouseEvent;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-let rootEl = $ref<HTMLDivElement>();
-
-let zIndex = $ref<number>(os.claimZIndex('high'));
-
-onMounted(() => {
-	let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
-	let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
-
-	const width = rootEl.offsetWidth;
-	const height = rootEl.offsetHeight;
-
-	if (left + width - window.pageXOffset > window.innerWidth) {
-		left = window.innerWidth - width + window.pageXOffset;
-	}
-
-	if (top + height - window.pageYOffset > window.innerHeight) {
-		top = window.innerHeight - height + window.pageYOffset;
-	}
-
-	if (top < 0) {
-		top = 0;
-	}
-
-	if (left < 0) {
-		left = 0;
-	}
-
-	rootEl.style.top = `${top}px`;
-	rootEl.style.left = `${left}px`;
-
-	for (const el of Array.from(document.querySelectorAll('body *'))) {
-		el.addEventListener('mousedown', onMousedown);
-	}
-});
-
-onBeforeUnmount(() => {
-	for (const el of Array.from(document.querySelectorAll('body *'))) {
-		el.removeEventListener('mousedown', onMousedown);
-	}
-});
-
-function onMousedown(evt: Event) {
-	if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed');
-}
-</script>
-
-<style lang="scss" scoped>
-.nvlagfpb {
-	position: absolute;
-}
-
-.fade-enter-active, .fade-leave-active {
-	transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
-	transform-origin: left top;
-}
-
-.fade-enter-from, .fade-leave-to {
-	opacity: 0;
-	transform: scale(0.9);
-}
-</style>
diff --git a/packages/client/src/components/MkCropperDialog.vue b/packages/client/src/components/MkCropperDialog.vue
deleted file mode 100644
index ae18160dea..0000000000
--- a/packages/client/src/components/MkCropperDialog.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialogEl"
-	:width="800"
-	:height="500"
-	:scroll="false"
-	:with-ok-button="true"
-	@close="cancel()"
-	@ok="ok()"
-	@closed="$emit('closed')"
->
-	<template #header>{{ i18n.ts.cropImage }}</template>
-	<template #default="{ width, height }">
-		<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
-			<Transition name="fade">
-				<div v-if="loading" class="loading">
-					<MkLoading/>
-				</div>
-			</Transition>
-			<div class="container">
-				<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
-			</div>
-		</div>
-	</template>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted } from 'vue';
-import * as misskey from 'misskey-js';
-import Cropper from 'cropperjs';
-import tinycolor from 'tinycolor2';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { defaultStore } from '@/store';
-import { apiUrl } from '@/config';
-import { i18n } from '@/i18n';
-import { getProxiedImageUrl } from '@/scripts/media-proxy';
-
-const emit = defineEmits<{
-	(ev: 'ok', cropped: misskey.entities.DriveFile): void;
-	(ev: 'cancel'): void;
-	(ev: 'closed'): void;
-}>();
-
-const props = defineProps<{
-	file: misskey.entities.DriveFile;
-	aspectRatio: number;
-}>();
-
-const imgUrl = getProxiedImageUrl(props.file.url);
-let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
-let imgEl = $ref<HTMLImageElement>();
-let cropper: Cropper | null = null;
-let loading = $ref(true);
-
-const ok = async () => {
-	const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
-		const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
-		croppedCanvas.toBlob(blob => {
-			const formData = new FormData();
-			formData.append('file', blob);
-			formData.append('i', $i.token);
-			if (defaultStore.state.uploadFolder) {
-				formData.append('folderId', defaultStore.state.uploadFolder);
-			}
-
-			window.fetch(apiUrl + '/drive/files/create', {
-				method: 'POST',
-				body: formData,
-			})
-				.then(response => response.json())
-				.then(f => {
-					res(f);
-				});
-		});
-	});
-
-	os.promiseDialog(promise);
-
-	const f = await promise;
-
-	emit('ok', f);
-	dialogEl.close();
-};
-
-const cancel = () => {
-	emit('cancel');
-	dialogEl.close();
-};
-
-const onImageLoad = () => {
-	loading = false;
-
-	if (cropper) {
-		cropper.getCropperImage()!.$center('contain');
-		cropper.getCropperSelection()!.$center();
-	}
-};
-
-onMounted(() => {
-	cropper = new Cropper(imgEl, {
-	});
-
-	const computedStyle = getComputedStyle(document.documentElement);
-
-	const selection = cropper.getCropperSelection()!;
-	selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
-	selection.aspectRatio = props.aspectRatio;
-	selection.initialAspectRatio = props.aspectRatio;
-	selection.outlined = true;
-
-	window.setTimeout(() => {
-		cropper.getCropperImage()!.$center('contain');
-		selection.$center();
-	}, 100);
-
-	// モーダルオープンアニメーションが終わったあとで再度調整
-	window.setTimeout(() => {
-		cropper.getCropperImage()!.$center('contain');
-		selection.$center();
-	}, 500);
-});
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.5s ease 0.5s;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.mk-cropper-dialog {
-	display: flex;
-	flex-direction: column;
-	width: var(--vw);
-	height: var(--vh);
-	position: relative;
-
-	> .loading {
-		position: absolute;
-		z-index: 10;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		-webkit-backdrop-filter: var(--blur, blur(10px));
-		backdrop-filter: var(--blur, blur(10px));
-		background: rgba(0, 0, 0, 0.5);
-	}
-
-	> .container {
-		flex: 1;
-		width: 100%;
-		height: 100%;
-
-		> ::v-deep(cropper-canvas) {
-			width: 100%;
-			height: 100%;
-
-			> cropper-selection > cropper-handle[action="move"] {
-				background: transparent;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkCwButton.vue b/packages/client/src/components/MkCwButton.vue
deleted file mode 100644
index ee611921ef..0000000000
--- a/packages/client/src/components/MkCwButton.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<template>
-<button class="nrvgflfu _button" @click="toggle">
-	<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
-	<span v-if="!modelValue">{{ label }}</span>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import { length } from 'stringz';
-import * as misskey from 'misskey-js';
-import { concat } from '@/scripts/array';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: boolean;
-	note: misskey.entities.Note;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', v: boolean): void;
-}>();
-
-const label = computed(() => {
-	return concat([
-		props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
-		props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
-		props.note.poll != null ? [i18n.ts.poll] : [],
-	] as string[][]).join(' / ');
-});
-
-const toggle = () => {
-	emit('update:modelValue', !props.modelValue);
-};
-</script>
-
-<style lang="scss" scoped>
-.nrvgflfu {
-	display: inline-block;
-	padding: 4px 8px;
-	font-size: 0.7em;
-	color: var(--cwFg);
-	background: var(--cwBg);
-	border-radius: 2px;
-
-	&:hover {
-		background: var(--cwHoverBg);
-	}
-
-	> span {
-		margin-left: 4px;
-
-		&:before {
-			content: '(';
-		}
-
-		&:after {
-			content: ')';
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDateSeparatedList.vue b/packages/client/src/components/MkDateSeparatedList.vue
deleted file mode 100644
index 1f88bdf137..0000000000
--- a/packages/client/src/components/MkDateSeparatedList.vue
+++ /dev/null
@@ -1,189 +0,0 @@
-<script lang="ts">
-import { defineComponent, h, PropType, TransitionGroup } from 'vue';
-import MkAd from '@/components/global/MkAd.vue';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-
-export default defineComponent({
-	props: {
-		items: {
-			type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
-			required: true,
-		},
-		direction: {
-			type: String,
-			required: false,
-			default: 'down',
-		},
-		reversed: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		noGap: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		ad: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-
-	setup(props, { slots, expose }) {
-		function getDateText(time: string) {
-			const date = new Date(time).getDate();
-			const month = new Date(time).getMonth() + 1;
-			return i18n.t('monthAndDay', {
-				month: month.toString(),
-				day: date.toString(),
-			});
-		}
-
-		if (props.items.length === 0) return;
-
-		const renderChildren = () => props.items.map((item, i) => {
-			if (!slots || !slots.default) return;
-
-			const el = slots.default({
-				item: item,
-			})[0];
-			if (el.key == null && item.id) el.key = item.id;
-
-			if (
-				i !== props.items.length - 1 &&
-				new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
-			) {
-				const separator = h('div', {
-					class: 'separator',
-					key: item.id + ':separator',
-				}, h('p', {
-					class: 'date',
-				}, [
-					h('span', [
-						h('i', {
-							class: 'ti ti-chevron-up icon',
-						}),
-						getDateText(item.createdAt),
-					]),
-					h('span', [
-						getDateText(props.items[i + 1].createdAt),
-						h('i', {
-							class: 'ti ti-chevron-down icon',
-						}),
-					]),
-				]));
-
-				return [el, separator];
-			} else {
-				if (props.ad && item._shouldInsertAd_) {
-					return [h(MkAd, {
-						class: 'a', // advertiseの意(ブロッカー対策)
-						key: item.id + ':ad',
-						prefer: ['horizontal', 'horizontal-big'],
-					}), el];
-				} else {
-					return el;
-				}
-			}
-		});
-
-		return () => h(
-			defaultStore.state.animation ? TransitionGroup : 'div',
-			defaultStore.state.animation ? {
-				class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
-				name: 'list',
-				tag: 'div',
-				'data-direction': props.direction,
-				'data-reversed': props.reversed ? 'true' : 'false',
-			} : {
-				class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
-			},
-			{ default: renderChildren });
-	},
-});
-</script>
-
-<style lang="scss">
-.sqadhkmv {
-	container-type: inline-size;
-
-	> *:empty {
-		display: none;
-	}
-
-	> *:not(:last-child) {
-		margin-bottom: var(--margin);
-	}
-
-	> .list-move {
-		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-	}
-
-	> .list-enter-active {
-		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-	}
-
-	&[data-direction="up"] {
-		> .list-enter-from {
-			opacity: 0;
-			transform: translateY(64px);
-		}
-	}
-
-	&[data-direction="down"] {
-		> .list-enter-from {
-			opacity: 0;
-			transform: translateY(-64px);
-		}
-	}
-
-	> .separator {
-		text-align: center;
-
-		> .date {
-			display: inline-block;
-			position: relative;
-			margin: 0;
-			padding: 0 16px;
-			line-height: 32px;
-			text-align: center;
-			font-size: 12px;
-			color: var(--dateLabelFg);
-
-			> span {
-				&:first-child {
-					margin-right: 8px;
-
-					> .icon {
-						margin-right: 8px;
-					}
-				}
-
-				&:last-child {
-					margin-left: 8px;
-
-					> .icon {
-						margin-left: 8px;
-					}
-				}
-			}
-		}
-	}
-
-	&.noGap {
-		> * {
-			margin: 0 !important;
-			border: none;
-			border-radius: 0;
-			box-shadow: none;
-
-			&:not(:last-child) {
-				border-bottom: solid 0.5px var(--divider);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDialog.vue b/packages/client/src/components/MkDialog.vue
deleted file mode 100644
index 374ecd8abf..0000000000
--- a/packages/client/src/components/MkDialog.vue
+++ /dev/null
@@ -1,208 +0,0 @@
-<template>
-<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
-	<div class="mk-dialog">
-		<div v-if="icon" class="icon">
-			<i :class="icon"></i>
-		</div>
-		<div v-else-if="!input && !select" class="icon" :class="type">
-			<i v-if="type === 'success'" class="ti ti-check"></i>
-			<i v-else-if="type === 'error'" class="ti ti-circle-x"></i>
-			<i v-else-if="type === 'warning'" class="ti ti-alert-triangle"></i>
-			<i v-else-if="type === 'info'" class="ti ti-info-circle"></i>
-			<i v-else-if="type === 'question'" class="ti ti-question-circle"></i>
-			<MkLoading v-else-if="type === 'waiting'" :em="true"/>
-		</div>
-		<header v-if="title"><Mfm :text="title"/></header>
-		<div v-if="text" class="body"><Mfm :text="text"/></div>
-		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
-			<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
-		</MkInput>
-		<MkSelect v-if="select" v-model="selectedValue" autofocus>
-			<template v-if="select.items">
-				<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
-			</template>
-			<template v-else>
-				<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
-					<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
-				</optgroup>
-			</template>
-		</MkSelect>
-		<div v-if="(showOkButton || showCancelButton) && !actions" class="buttons">
-			<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton>
-			<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
-		</div>
-		<div v-if="actions" class="buttons">
-			<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
-		</div>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { onBeforeUnmount, onMounted, ref } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import { i18n } from '@/i18n';
-
-type Input = {
-	type: HTMLInputElement['type'];
-	placeholder?: string | null;
-	default: any | null;
-};
-
-type Select = {
-	items: {
-		value: string;
-		text: string;
-	}[];
-	groupedItems: {
-		label: string;
-		items: {
-			value: string;
-			text: string;
-		}[];
-	}[];
-	default: string | null;
-};
-
-const props = withDefaults(defineProps<{
-	type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
-	title: string;
-	text?: string;
-	input?: Input;
-	select?: Select;
-	icon?: string;
-	actions?: {
-		text: string;
-		primary?: boolean,
-		callback: (...args: any[]) => void;
-	}[];
-	showOkButton?: boolean;
-	showCancelButton?: boolean;
-	cancelableByBgClick?: boolean;
-}>(), {
-	type: 'info',
-	showOkButton: true,
-	showCancelButton: false,
-	cancelableByBgClick: true,
-});
-
-const emit = defineEmits<{
-	(ev: 'done', v: { canceled: boolean; result: any }): void;
-	(ev: 'closed'): void;
-}>();
-
-const modal = ref<InstanceType<typeof MkModal>>();
-
-const inputValue = ref(props.input?.default || null);
-const selectedValue = ref(props.select?.default || null);
-
-function done(canceled: boolean, result?) {
-	emit('done', { canceled, result });
-	modal.value?.close();
-}
-
-async function ok() {
-	if (!props.showOkButton) return;
-
-	const result =
-		props.input ? inputValue.value :
-		props.select ? selectedValue.value :
-		true;
-	done(false, result);
-}
-
-function cancel() {
-	done(true);
-}
-/*
-function onBgClick() {
-	if (props.cancelableByBgClick) cancel();
-}
-*/
-function onKeydown(evt: KeyboardEvent) {
-	if (evt.key === 'Escape') cancel();
-}
-
-function onInputKeydown(evt: KeyboardEvent) {
-	if (evt.key === 'Enter') {
-		evt.preventDefault();
-		evt.stopPropagation();
-		ok();
-	}
-}
-
-onMounted(() => {
-	document.addEventListener('keydown', onKeydown);
-});
-
-onBeforeUnmount(() => {
-	document.removeEventListener('keydown', onKeydown);
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-dialog {
-	position: relative;
-	padding: 32px;
-	min-width: 320px;
-	max-width: 480px;
-	box-sizing: border-box;
-	text-align: center;
-	background: var(--panel);
-	border-radius: var(--radius);
-
-	> .icon {
-		font-size: 24px;
-
-		&.info {
-			color: #55c4dd;
-		}
-
-		&.success {
-			color: var(--success);
-		}
-
-		&.error {
-			color: var(--error);
-		}
-
-		&.warning {
-			color: var(--warn);
-		}
-
-		> * {
-			display: block;
-			margin: 0 auto;
-		}
-
-		& + header {
-			margin-top: 8px;
-		}
-	}
-
-	> header {
-		margin: 0 0 8px 0;
-		font-weight: bold;
-		font-size: 1.1em;
-
-		& + .body {
-			margin-top: 8px;
-		}
-	}
-
-	> .body {
-		margin: 16px 0 0 0;
-	}
-
-	> .buttons {
-		margin-top: 16px;
-
-		> * {
-			margin: 0 8px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDigitalClock.vue b/packages/client/src/components/MkDigitalClock.vue
deleted file mode 100644
index 9ed8d63d19..0000000000
--- a/packages/client/src/components/MkDigitalClock.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-<template>
-<span class="zjobosdg">
-	<span v-text="hh"></span>
-	<span class="colon" :class="{ showColon }">:</span>
-	<span v-text="mm"></span>
-	<span v-if="showS" class="colon" :class="{ showColon }">:</span>
-	<span v-if="showS" v-text="ss"></span>
-	<span v-if="showMs" class="colon" :class="{ showColon }">:</span>
-	<span v-if="showMs" v-text="ms"></span>
-</span>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, ref, watch } from 'vue';
-
-const props = withDefaults(defineProps<{
-	showS?: boolean;
-	showMs?: boolean;
-	offset?: number;
-}>(), {
-	showS: true,
-	showMs: false,
-	offset: 0 - new Date().getTimezoneOffset(),
-});
-
-let intervalId;
-const hh = ref('');
-const mm = ref('');
-const ss = ref('');
-const ms = ref('');
-const showColon = ref(false);
-let prevSec: number | null = null;
-
-watch(showColon, (v) => {
-	if (v) {
-		window.setTimeout(() => {
-			showColon.value = false;
-		}, 30);
-	}
-});
-
-const tick = () => {
-	const now = new Date();
-	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
-	hh.value = now.getHours().toString().padStart(2, '0');
-	mm.value = now.getMinutes().toString().padStart(2, '0');
-	ss.value = now.getSeconds().toString().padStart(2, '0');
-	ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
-	if (now.getSeconds() !== prevSec) showColon.value = true;
-	prevSec = now.getSeconds();
-};
-
-tick();
-
-watch(() => props.showMs, () => {
-	if (intervalId) window.clearInterval(intervalId);
-	intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
-}, { immediate: true });
-
-onUnmounted(() => {
-	window.clearInterval(intervalId);
-});
-</script>
-
-<style lang="scss" scoped>
-.zjobosdg {
-	> .colon {
-		opacity: 0;
-		transition: opacity 1s ease;
-
-		&.showColon {
-			opacity: 1;
-			transition: opacity 0s;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDrive.file.vue b/packages/client/src/components/MkDrive.file.vue
deleted file mode 100644
index 8c17c0530a..0000000000
--- a/packages/client/src/components/MkDrive.file.vue
+++ /dev/null
@@ -1,334 +0,0 @@
-<template>
-<div
-	class="ncvczrfv"
-	:class="{ isSelected }"
-	draggable="true"
-	:title="title"
-	@click="onClick"
-	@contextmenu.stop="onContextmenu"
-	@dragstart="onDragstart"
-	@dragend="onDragend"
->
-	<div v-if="$i?.avatarId == file.id" class="label">
-		<img src="/client-assets/label.svg"/>
-		<p>{{ i18n.ts.avatar }}</p>
-	</div>
-	<div v-if="$i?.bannerId == file.id" class="label">
-		<img src="/client-assets/label.svg"/>
-		<p>{{ i18n.ts.banner }}</p>
-	</div>
-	<div v-if="file.isSensitive" class="label red">
-		<img src="/client-assets/label-red.svg"/>
-		<p>{{ i18n.ts.nsfw }}</p>
-	</div>
-
-	<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-
-	<p class="name">
-		<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
-		<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
-	</p>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-
-const props = withDefaults(defineProps<{
-	file: Misskey.entities.DriveFile;
-	isSelected?: boolean;
-	selectMode?: boolean;
-}>(), {
-	isSelected: false,
-	selectMode: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'chosen', r: Misskey.entities.DriveFile): void;
-	(ev: 'dragstart'): void;
-	(ev: 'dragend'): void;
-}>();
-
-const isDragging = ref(false);
-
-const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
-
-function getMenu() {
-	return [{
-		text: i18n.ts.rename,
-		icon: 'ti ti-forms',
-		action: rename,
-	}, {
-		text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
-		icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
-		action: toggleSensitive,
-	}, {
-		text: i18n.ts.describeFile,
-		icon: 'ti ti-text-caption',
-		action: describe,
-	}, null, {
-		text: i18n.ts.copyUrl,
-		icon: 'ti ti-link',
-		action: copyUrl,
-	}, {
-		type: 'a',
-		href: props.file.url,
-		target: '_blank',
-		text: i18n.ts.download,
-		icon: 'ti ti-download',
-		download: props.file.name,
-	}, null, {
-		text: i18n.ts.delete,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: deleteFile,
-	}];
-}
-
-function onClick(ev: MouseEvent) {
-	if (props.selectMode) {
-		emit('chosen', props.file);
-	} else {
-		os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
-	}
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu(getMenu(), ev);
-}
-
-function onDragstart(ev: DragEvent) {
-	if (ev.dataTransfer) {
-		ev.dataTransfer.effectAllowed = 'move';
-		ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
-	}
-	isDragging.value = true;
-
-	emit('dragstart');
-}
-
-function onDragend() {
-	isDragging.value = false;
-	emit('dragend');
-}
-
-function rename() {
-	os.inputText({
-		title: i18n.ts.renameFile,
-		placeholder: i18n.ts.inputNewFileName,
-		default: props.file.name,
-	}).then(({ canceled, result: name }) => {
-		if (canceled) return;
-		os.api('drive/files/update', {
-			fileId: props.file.id,
-			name: name,
-		});
-	});
-}
-
-function describe() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
-		default: props.file.comment != null ? props.file.comment : '',
-		file: props.file,
-	}, {
-		done: caption => {
-			os.api('drive/files/update', {
-				fileId: props.file.id,
-				comment: caption.length === 0 ? null : caption,
-			});
-		},
-	}, 'closed');
-}
-
-function toggleSensitive() {
-	os.api('drive/files/update', {
-		fileId: props.file.id,
-		isSensitive: !props.file.isSensitive,
-	});
-}
-
-function copyUrl() {
-	copyToClipboard(props.file.url);
-	os.success();
-}
-/*
-function addApp() {
-	alert('not implemented yet');
-}
-*/
-async function deleteFile() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
-	});
-
-	if (canceled) return;
-	os.api('drive/files/delete', {
-		fileId: props.file.id,
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.ncvczrfv {
-	position: relative;
-	padding: 8px 0 0 0;
-	min-height: 180px;
-	border-radius: 8px;
-
-	&, * {
-		cursor: pointer;
-	}
-
-	> * {
-		pointer-events: none;
-	}
-
-	&:hover {
-		background: rgba(#000, 0.05);
-
-		> .label {
-			&:before,
-			&:after {
-				background: #0b65a5;
-			}
-
-			&.red {
-				&:before,
-				&:after {
-					background: #c12113;
-				}
-			}
-		}
-	}
-
-	&:active {
-		background: rgba(#000, 0.1);
-
-		> .label {
-			&:before,
-			&:after {
-				background: #0b588c;
-			}
-
-			&.red {
-				&:before,
-				&:after {
-					background: #ce2212;
-				}
-			}
-		}
-	}
-
-	&.isSelected {
-		background: var(--accent);
-
-		&:hover {
-			background: var(--accentLighten);
-		}
-
-		&:active {
-			background: var(--accentDarken);
-		}
-
-		> .label {
-			&:before,
-			&:after {
-				display: none;
-			}
-		}
-
-		> .name {
-			color: #fff;
-		}
-
-		> .thumbnail {
-			color: #fff;
-		}
-	}
-
-	> .label {
-		position: absolute;
-		top: 0;
-		left: 0;
-		pointer-events: none;
-
-		&:before,
-		&:after {
-			content: "";
-			display: block;
-			position: absolute;
-			z-index: 1;
-			background: #0c7ac9;
-		}
-
-		&:before {
-			top: 0;
-			left: 57px;
-			width: 28px;
-			height: 8px;
-		}
-
-		&:after {
-			top: 57px;
-			left: 0;
-			width: 8px;
-			height: 28px;
-		}
-
-		&.red {
-			&:before,
-			&:after {
-				background: #c12113;
-			}
-		}
-
-		> img {
-			position: absolute;
-			z-index: 2;
-			top: 0;
-			left: 0;
-		}
-
-		> p {
-			position: absolute;
-			z-index: 3;
-			top: 19px;
-			left: -28px;
-			width: 120px;
-			margin: 0;
-			text-align: center;
-			line-height: 28px;
-			color: #fff;
-			transform: rotate(-45deg);
-		}
-	}
-
-	> .thumbnail {
-		width: 110px;
-		height: 110px;
-		margin: auto;
-	}
-
-	> .name {
-		display: block;
-		margin: 4px 0 0 0;
-		font-size: 0.8em;
-		text-align: center;
-		word-break: break-all;
-		color: var(--fg);
-		overflow: hidden;
-
-		> .ext {
-			opacity: 0.5;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDrive.folder.vue b/packages/client/src/components/MkDrive.folder.vue
deleted file mode 100644
index 82653ca0b4..0000000000
--- a/packages/client/src/components/MkDrive.folder.vue
+++ /dev/null
@@ -1,330 +0,0 @@
-<template>
-<div
-	class="rghtznwe"
-	:class="{ draghover }"
-	draggable="true"
-	:title="title"
-	@click="onClick"
-	@contextmenu.stop="onContextmenu"
-	@mouseover="onMouseover"
-	@mouseout="onMouseout"
-	@dragover.prevent.stop="onDragover"
-	@dragenter.prevent="onDragenter"
-	@dragleave="onDragleave"
-	@drop.prevent.stop="onDrop"
-	@dragstart="onDragstart"
-	@dragend="onDragend"
->
-	<p class="name">
-		<template v-if="hover"><i class="ti ti-folder ti-fw"></i></template>
-		<template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template>
-		{{ folder.name }}
-	</p>
-	<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
-		{{ i18n.ts.uploadFolder }}
-	</p>
-	<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-
-const props = withDefaults(defineProps<{
-	folder: Misskey.entities.DriveFolder;
-	isSelected?: boolean;
-	selectMode?: boolean;
-}>(), {
-	isSelected: false,
-	selectMode: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
-	(ev: 'move', v: Misskey.entities.DriveFolder): void;
-	(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
-	(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
-	(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
-	(ev: 'dragstart'): void;
-	(ev: 'dragend'): void;
-}>();
-
-const hover = ref(false);
-const draghover = ref(false);
-const isDragging = ref(false);
-
-const title = computed(() => props.folder.name);
-
-function checkboxClicked() {
-	emit('chosen', props.folder);
-}
-
-function onClick() {
-	emit('move', props.folder);
-}
-
-function onMouseover() {
-	hover.value = true;
-}
-
-function onMouseout() {
-	hover.value = false;
-}
-
-function onDragover(ev: DragEvent) {
-	if (!ev.dataTransfer) return;
-
-	// 自分自身がドラッグされている場合
-	if (isDragging.value) {
-		// 自分自身にはドロップさせない
-		ev.dataTransfer.dropEffect = 'none';
-		return;
-	}
-
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
-
-	if (isFile || isDriveFile || isDriveFolder) {
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	} else {
-		ev.dataTransfer.dropEffect = 'none';
-	}
-}
-
-function onDragenter() {
-	if (!isDragging.value) draghover.value = true;
-}
-
-function onDragleave() {
-	draghover.value = false;
-}
-
-function onDrop(ev: DragEvent) {
-	draghover.value = false;
-
-	if (!ev.dataTransfer) return;
-
-	// ファイルだったら
-	if (ev.dataTransfer.files.length > 0) {
-		for (const file of Array.from(ev.dataTransfer.files)) {
-			emit('upload', file, props.folder);
-		}
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		const file = JSON.parse(driveFile);
-		emit('removeFile', file.id);
-		os.api('drive/files/update', {
-			fileId: file.id,
-			folderId: props.folder.id,
-		});
-	}
-	//#endregion
-
-	//#region ドライブのフォルダ
-	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
-	if (driveFolder != null && driveFolder !== '') {
-		const folder = JSON.parse(driveFolder);
-
-		// 移動先が自分自身ならreject
-		if (folder.id === props.folder.id) return;
-
-		emit('removeFolder', folder.id);
-		os.api('drive/folders/update', {
-			folderId: folder.id,
-			parentId: props.folder.id,
-		}).then(() => {
-			// noop
-		}).catch(err => {
-			switch (err) {
-				case 'detected-circular-definition':
-					os.alert({
-						title: i18n.ts.unableToProcess,
-						text: i18n.ts.circularReferenceFolder,
-					});
-					break;
-				default:
-					os.alert({
-						type: 'error',
-						text: i18n.ts.somethingHappened,
-					});
-			}
-		});
-	}
-	//#endregion
-}
-
-function onDragstart(ev: DragEvent) {
-	if (!ev.dataTransfer) return;
-
-	ev.dataTransfer.effectAllowed = 'move';
-	ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
-	isDragging.value = true;
-
-	// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
-	// (=あなたの子供が、ドラッグを開始しましたよ)
-	emit('dragstart');
-}
-
-function onDragend() {
-	isDragging.value = false;
-	emit('dragend');
-}
-
-function go() {
-	emit('move', props.folder.id);
-}
-
-function rename() {
-	os.inputText({
-		title: i18n.ts.renameFolder,
-		placeholder: i18n.ts.inputNewFolderName,
-		default: props.folder.name,
-	}).then(({ canceled, result: name }) => {
-		if (canceled) return;
-		os.api('drive/folders/update', {
-			folderId: props.folder.id,
-			name: name,
-		});
-	});
-}
-
-function deleteFolder() {
-	os.api('drive/folders/delete', {
-		folderId: props.folder.id,
-	}).then(() => {
-		if (defaultStore.state.uploadFolder === props.folder.id) {
-			defaultStore.set('uploadFolder', null);
-		}
-	}).catch(err => {
-		switch (err.id) {
-			case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
-				os.alert({
-					type: 'error',
-					title: i18n.ts.unableToDelete,
-					text: i18n.ts.hasChildFilesOrFolders,
-				});
-				break;
-			default:
-				os.alert({
-					type: 'error',
-					text: i18n.ts.unableToDelete,
-				});
-		}
-	});
-}
-
-function setAsUploadFolder() {
-	defaultStore.set('uploadFolder', props.folder.id);
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu([{
-		text: i18n.ts.openInWindow,
-		icon: 'ti ti-app-window',
-		action: () => {
-			os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
-				initialFolder: props.folder,
-			}, {
-			}, 'closed');
-		},
-	}, null, {
-		text: i18n.ts.rename,
-		icon: 'ti ti-forms',
-		action: rename,
-	}, null, {
-		text: i18n.ts.delete,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: deleteFolder,
-	}], ev);
-}
-</script>
-
-<style lang="scss" scoped>
-.rghtznwe {
-	position: relative;
-	padding: 8px;
-	height: 64px;
-	background: var(--driveFolderBg);
-	border-radius: 4px;
-
-	&, * {
-		cursor: pointer;
-	}
-
-	*:not(.checkbox) {
-		pointer-events: none;
-	}
-
-	> .checkbox {
-		position: absolute;
-		bottom: 8px;
-		right: 8px;
-		width: 16px;
-		height: 16px;
-		background: #fff;
-		border: solid 1px #000;
-
-		&.checked {
-			background: var(--accent);
-		}
-	}
-
-	&.draghover {
-		&:after {
-			content: "";
-			pointer-events: none;
-			position: absolute;
-			top: -4px;
-			right: -4px;
-			bottom: -4px;
-			left: -4px;
-			border: 2px dashed var(--focus);
-			border-radius: 4px;
-		}
-	}
-
-	> .name {
-		margin: 0;
-		font-size: 0.9em;
-		color: var(--desktopDriveFolderFg);
-
-		> i {
-			margin-right: 4px;
-			margin-left: 2px;
-			text-align: left;
-		}
-	}
-
-	> .upload {
-		margin: 4px 4px;
-		font-size: 0.8em;
-		text-align: right;
-		color: var(--desktopDriveFolderFg);
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDrive.navFolder.vue b/packages/client/src/components/MkDrive.navFolder.vue
deleted file mode 100644
index dbbfef5f05..0000000000
--- a/packages/client/src/components/MkDrive.navFolder.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<template>
-<div class="drylbebk"
-	:class="{ draghover }"
-	@click="onClick"
-	@dragover.prevent.stop="onDragover"
-	@dragenter="onDragenter"
-	@dragleave="onDragleave"
-	@drop.stop="onDrop"
->
-	<i v-if="folder == null" class="ti ti-cloud"></i>
-	<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	folder?: Misskey.entities.DriveFolder;
-	parentFolder: Misskey.entities.DriveFolder | null;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'move', v?: Misskey.entities.DriveFolder): void;
-	(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
-	(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
-	(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
-}>();
-
-const hover = ref(false);
-const draghover = ref(false);
-
-function onClick() {
-	emit('move', props.folder);
-}
-
-function onMouseover() {
-	hover.value = true;
-}
-
-function onMouseout() {
-	hover.value = false;
-}
-
-function onDragover(ev: DragEvent) {
-	if (!ev.dataTransfer) return;
-
-	// このフォルダがルートかつカレントディレクトリならドロップ禁止
-	if (props.folder == null && props.parentFolder == null) {
-		ev.dataTransfer.dropEffect = 'none';
-	}
-
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
-
-	if (isFile || isDriveFile || isDriveFolder) {
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	} else {
-		ev.dataTransfer.dropEffect = 'none';
-	}
-
-	return false;
-}
-
-function onDragenter() {
-	if (props.folder || props.parentFolder) draghover.value = true;
-}
-
-function onDragleave() {
-	if (props.folder || props.parentFolder) draghover.value = false;
-}
-
-function onDrop(ev: DragEvent) {
-	draghover.value = false;
-
-	if (!ev.dataTransfer) return;
-
-	// ファイルだったら
-	if (ev.dataTransfer.files.length > 0) {
-		for (const file of Array.from(ev.dataTransfer.files)) {
-			emit('upload', file, props.folder);
-		}
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		const file = JSON.parse(driveFile);
-		emit('removeFile', file.id);
-		os.api('drive/files/update', {
-			fileId: file.id,
-			folderId: props.folder ? props.folder.id : null,
-		});
-	}
-	//#endregion
-
-	//#region ドライブのフォルダ
-	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
-	if (driveFolder != null && driveFolder !== '') {
-		const folder = JSON.parse(driveFolder);
-		// 移動先が自分自身ならreject
-		if (props.folder && folder.id === props.folder.id) return;
-		emit('removeFolder', folder.id);
-		os.api('drive/folders/update', {
-			folderId: folder.id,
-			parentId: props.folder ? props.folder.id : null,
-		});
-	}
-	//#endregion
-}
-</script>
-
-<style lang="scss" scoped>
-.drylbebk {
-	> * {
-		pointer-events: none;
-	}
-
-	&.draghover {
-		background: #eee;
-	}
-
-	> i {
-		margin-right: 4px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDrive.vue b/packages/client/src/components/MkDrive.vue
deleted file mode 100644
index 4053870950..0000000000
--- a/packages/client/src/components/MkDrive.vue
+++ /dev/null
@@ -1,801 +0,0 @@
-<template>
-<div class="yfudmmck">
-	<nav>
-		<div class="path" @contextmenu.prevent.stop="() => {}">
-			<XNavFolder
-				:class="{ current: folder == null }"
-				:parent-folder="folder"
-				@move="move"
-				@upload="upload"
-				@remove-file="removeFile"
-				@remove-folder="removeFolder"
-			/>
-			<template v-for="f in hierarchyFolders">
-				<span class="separator"><i class="ti ti-chevron-right"></i></span>
-				<XNavFolder
-					:folder="f"
-					:parent-folder="folder"
-					@move="move"
-					@upload="upload"
-					@remove-file="removeFile"
-					@remove-folder="removeFolder"
-				/>
-			</template>
-			<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
-			<span v-if="folder != null" class="folder current">{{ folder.name }}</span>
-		</div>
-		<button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
-	</nav>
-	<div
-		ref="main" class="main"
-		:class="{ uploading: uploadings.length > 0, fetching }"
-		@dragover.prevent.stop="onDragover"
-		@dragenter="onDragenter"
-		@dragleave="onDragleave"
-		@drop.prevent.stop="onDrop"
-		@contextmenu.stop="onContextmenu"
-	>
-		<div ref="contents" class="contents">
-			<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
-				<XFolder
-					v-for="(f, i) in folders"
-					:key="f.id"
-					v-anim="i"
-					class="folder"
-					:folder="f"
-					:select-mode="select === 'folder'"
-					:is-selected="selectedFolders.some(x => x.id === f.id)"
-					@chosen="chooseFolder"
-					@move="move"
-					@upload="upload"
-					@remove-file="removeFile"
-					@remove-folder="removeFolder"
-					@dragstart="isDragSource = true"
-					@dragend="isDragSource = false"
-				/>
-				<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
-				<div v-for="(n, i) in 16" :key="i" class="padding"></div>
-				<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
-			</div>
-			<div v-show="files.length > 0" ref="filesContainer" class="files">
-				<XFile
-					v-for="(file, i) in files"
-					:key="file.id"
-					v-anim="i"
-					class="file"
-					:file="file"
-					:select-mode="select === 'file'"
-					:is-selected="selectedFiles.some(x => x.id === file.id)"
-					@chosen="chooseFile"
-					@dragstart="isDragSource = true"
-					@dragend="isDragSource = false"
-				/>
-				<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
-				<div v-for="(n, i) in 16" :key="i" class="padding"></div>
-				<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
-			</div>
-			<div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
-				<p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
-				<p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
-				<p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
-			</div>
-		</div>
-		<MkLoading v-if="fetching"/>
-	</div>
-	<div v-if="draghover" class="dropzone"></div>
-	<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import MkButton from './MkButton.vue';
-import XNavFolder from '@/components/MkDrive.navFolder.vue';
-import XFolder from '@/components/MkDrive.folder.vue';
-import XFile from '@/components/MkDrive.file.vue';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { uploadFile, uploads } from '@/scripts/upload';
-
-const props = withDefaults(defineProps<{
-	initialFolder?: Misskey.entities.DriveFolder;
-	type?: string;
-	multiple?: boolean;
-	select?: 'file' | 'folder' | null;
-}>(), {
-	multiple: false,
-	select: null,
-});
-
-const emit = defineEmits<{
-	(ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void;
-	(ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
-	(ev: 'move-root'): void;
-	(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
-	(ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
-}>();
-
-const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
-const fileInput = ref<HTMLInputElement>();
-
-const folder = ref<Misskey.entities.DriveFolder | null>(null);
-const files = ref<Misskey.entities.DriveFile[]>([]);
-const folders = ref<Misskey.entities.DriveFolder[]>([]);
-const moreFiles = ref(false);
-const moreFolders = ref(false);
-const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
-const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
-const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
-const uploadings = uploads;
-const connection = stream.useChannel('drive');
-const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
-
-// ドロップされようとしているか
-const draghover = ref(false);
-
-// 自身の所有するアイテムがドラッグをスタートさせたか
-// (自分自身の階層にドロップできないようにするためのフラグ)
-const isDragSource = ref(false);
-
-const fetching = ref(true);
-
-const ilFilesObserver = new IntersectionObserver(
-	(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
-);
-
-watch(folder, () => emit('cd', folder.value));
-
-function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
-	addFile(file, true);
-}
-
-function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) {
-	const current = folder.value ? folder.value.id : null;
-	if (current !== file.folderId) {
-		removeFile(file);
-	} else {
-		addFile(file, true);
-	}
-}
-
-function onStreamDriveFileDeleted(fileId: string) {
-	removeFile(fileId);
-}
-
-function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) {
-	addFolder(createdFolder, true);
-}
-
-function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) {
-	const current = folder.value ? folder.value.id : null;
-	if (current !== updatedFolder.parentId) {
-		removeFolder(updatedFolder);
-	} else {
-		addFolder(updatedFolder, true);
-	}
-}
-
-function onStreamDriveFolderDeleted(folderId: string) {
-	removeFolder(folderId);
-}
-
-function onDragover(ev: DragEvent): any {
-	if (!ev.dataTransfer) return;
-
-	// ドラッグ元が自分自身の所有するアイテムだったら
-	if (isDragSource.value) {
-		// 自分自身にはドロップさせない
-		ev.dataTransfer.dropEffect = 'none';
-		return;
-	}
-
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
-	if (isFile || isDriveFile || isDriveFolder) {
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	} else {
-		ev.dataTransfer.dropEffect = 'none';
-	}
-
-	return false;
-}
-
-function onDragenter() {
-	if (!isDragSource.value) draghover.value = true;
-}
-
-function onDragleave() {
-	draghover.value = false;
-}
-
-function onDrop(ev: DragEvent): any {
-	draghover.value = false;
-
-	if (!ev.dataTransfer) return;
-
-	// ドロップされてきたものがファイルだったら
-	if (ev.dataTransfer.files.length > 0) {
-		for (const file of Array.from(ev.dataTransfer.files)) {
-			upload(file, folder.value);
-		}
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		const file = JSON.parse(driveFile);
-		if (files.value.some(f => f.id === file.id)) return;
-		removeFile(file.id);
-		os.api('drive/files/update', {
-			fileId: file.id,
-			folderId: folder.value ? folder.value.id : null,
-		});
-	}
-	//#endregion
-
-	//#region ドライブのフォルダ
-	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
-	if (driveFolder != null && driveFolder !== '') {
-		const droppedFolder = JSON.parse(driveFolder);
-
-		// 移動先が自分自身ならreject
-		if (folder.value && droppedFolder.id === folder.value.id) return false;
-		if (folders.value.some(f => f.id === droppedFolder.id)) return false;
-		removeFolder(droppedFolder.id);
-		os.api('drive/folders/update', {
-			folderId: droppedFolder.id,
-			parentId: folder.value ? folder.value.id : null,
-		}).then(() => {
-			// noop
-		}).catch(err => {
-			switch (err) {
-				case 'detected-circular-definition':
-					os.alert({
-						title: i18n.ts.unableToProcess,
-						text: i18n.ts.circularReferenceFolder,
-					});
-					break;
-				default:
-					os.alert({
-						type: 'error',
-						text: i18n.ts.somethingHappened,
-					});
-			}
-		});
-	}
-	//#endregion
-}
-
-function selectLocalFile() {
-	fileInput.value?.click();
-}
-
-function urlUpload() {
-	os.inputText({
-		title: i18n.ts.uploadFromUrl,
-		type: 'url',
-		placeholder: i18n.ts.uploadFromUrlDescription,
-	}).then(({ canceled, result: url }) => {
-		if (canceled || !url) return;
-		os.api('drive/files/upload-from-url', {
-			url: url,
-			folderId: folder.value ? folder.value.id : undefined,
-		});
-
-		os.alert({
-			title: i18n.ts.uploadFromUrlRequested,
-			text: i18n.ts.uploadFromUrlMayTakeTime,
-		});
-	});
-}
-
-function createFolder() {
-	os.inputText({
-		title: i18n.ts.createFolder,
-		placeholder: i18n.ts.folderName,
-	}).then(({ canceled, result: name }) => {
-		if (canceled) return;
-		os.api('drive/folders/create', {
-			name: name,
-			parentId: folder.value ? folder.value.id : undefined,
-		}).then(createdFolder => {
-			addFolder(createdFolder, true);
-		});
-	});
-}
-
-function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
-	os.inputText({
-		title: i18n.ts.renameFolder,
-		placeholder: i18n.ts.inputNewFolderName,
-		default: folderToRename.name,
-	}).then(({ canceled, result: name }) => {
-		if (canceled) return;
-		os.api('drive/folders/update', {
-			folderId: folderToRename.id,
-			name: name,
-		}).then(updatedFolder => {
-			// FIXME: 画面を更新するために自分自身に移動
-			move(updatedFolder);
-		});
-	});
-}
-
-function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
-	os.api('drive/folders/delete', {
-		folderId: folderToDelete.id,
-	}).then(() => {
-		// 削除時に親フォルダに移動
-		move(folderToDelete.parentId);
-	}).catch(err => {
-		switch (err.id) {
-			case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
-				os.alert({
-					type: 'error',
-					title: i18n.ts.unableToDelete,
-					text: i18n.ts.hasChildFilesOrFolders,
-				});
-				break;
-			default:
-				os.alert({
-					type: 'error',
-					text: i18n.ts.unableToDelete,
-				});
-		}
-	});
-}
-
-function onChangeFileInput() {
-	if (!fileInput.value?.files) return;
-	for (const file of Array.from(fileInput.value.files)) {
-		upload(file, folder.value);
-	}
-}
-
-function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) {
-	uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
-		addFile(res, true);
-	});
-}
-
-function chooseFile(file: Misskey.entities.DriveFile) {
-	const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
-	if (props.multiple) {
-		if (isAlreadySelected) {
-			selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
-		} else {
-			selectedFiles.value.push(file);
-		}
-		emit('change-selection', selectedFiles.value);
-	} else {
-		if (isAlreadySelected) {
-			emit('selected', file);
-		} else {
-			selectedFiles.value = [file];
-			emit('change-selection', [file]);
-		}
-	}
-}
-
-function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
-	const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id);
-	if (props.multiple) {
-		if (isAlreadySelected) {
-			selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id);
-		} else {
-			selectedFolders.value.push(folderToChoose);
-		}
-		emit('change-selection', selectedFolders.value);
-	} else {
-		if (isAlreadySelected) {
-			emit('selected', folderToChoose);
-		} else {
-			selectedFolders.value = [folderToChoose];
-			emit('change-selection', [folderToChoose]);
-		}
-	}
-}
-
-function move(target?: Misskey.entities.DriveFolder) {
-	if (!target) {
-		goRoot();
-		return;
-	} else if (typeof target === 'object') {
-		target = target.id;
-	}
-
-	fetching.value = true;
-
-	os.api('drive/folders/show', {
-		folderId: target,
-	}).then(folderToMove => {
-		folder.value = folderToMove;
-		hierarchyFolders.value = [];
-
-		const dive = folderToDive => {
-			hierarchyFolders.value.unshift(folderToDive);
-			if (folderToDive.parent) dive(folderToDive.parent);
-		};
-
-		if (folderToMove.parent) dive(folderToMove.parent);
-
-		emit('open-folder', folderToMove);
-		fetch();
-	});
-}
-
-function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
-	const current = folder.value ? folder.value.id : null;
-	if (current !== folderToAdd.parentId) return;
-
-	if (folders.value.some(f => f.id === folderToAdd.id)) {
-		const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id);
-		folders.value[exist] = folderToAdd;
-		return;
-	}
-
-	if (unshift) {
-		folders.value.unshift(folderToAdd);
-	} else {
-		folders.value.push(folderToAdd);
-	}
-}
-
-function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
-	const current = folder.value ? folder.value.id : null;
-	if (current !== fileToAdd.folderId) return;
-
-	if (files.value.some(f => f.id === fileToAdd.id)) {
-		const exist = files.value.map(f => f.id).indexOf(fileToAdd.id);
-		files.value[exist] = fileToAdd;
-		return;
-	}
-
-	if (unshift) {
-		files.value.unshift(fileToAdd);
-	} else {
-		files.value.push(fileToAdd);
-	}
-}
-
-function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
-	const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove;
-	folders.value = folders.value.filter(f => f.id !== folderIdToRemove);
-}
-
-function removeFile(file: Misskey.entities.DriveFile | string) {
-	const fileId = typeof file === 'object' ? file.id : file;
-	files.value = files.value.filter(f => f.id !== fileId);
-}
-
-function appendFile(file: Misskey.entities.DriveFile) {
-	addFile(file);
-}
-
-function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
-	addFolder(folderToAppend);
-}
-/*
-function prependFile(file: Misskey.entities.DriveFile) {
-	addFile(file, true);
-}
-
-function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) {
-	addFolder(folderToPrepend, true);
-}
-*/
-function goRoot() {
-	// 既にrootにいるなら何もしない
-	if (folder.value == null) return;
-
-	folder.value = null;
-	hierarchyFolders.value = [];
-	emit('move-root');
-	fetch();
-}
-
-async function fetch() {
-	folders.value = [];
-	files.value = [];
-	moreFolders.value = false;
-	moreFiles.value = false;
-	fetching.value = true;
-
-	const foldersMax = 30;
-	const filesMax = 30;
-
-	const foldersPromise = os.api('drive/folders', {
-		folderId: folder.value ? folder.value.id : null,
-		limit: foldersMax + 1,
-	}).then(fetchedFolders => {
-		if (fetchedFolders.length === foldersMax + 1) {
-			moreFolders.value = true;
-			fetchedFolders.pop();
-		}
-		return fetchedFolders;
-	});
-
-	const filesPromise = os.api('drive/files', {
-		folderId: folder.value ? folder.value.id : null,
-		type: props.type,
-		limit: filesMax + 1,
-	}).then(fetchedFiles => {
-		if (fetchedFiles.length === filesMax + 1) {
-			moreFiles.value = true;
-			fetchedFiles.pop();
-		}
-		return fetchedFiles;
-	});
-
-	const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]);
-
-	for (const x of fetchedFolders) appendFolder(x);
-	for (const x of fetchedFiles) appendFile(x);
-
-	fetching.value = false;
-}
-
-function fetchMoreFiles() {
-	fetching.value = true;
-
-	const max = 30;
-
-	// ファイル一覧取得
-	os.api('drive/files', {
-		folderId: folder.value ? folder.value.id : null,
-		type: props.type,
-		untilId: files.value[files.value.length - 1].id,
-		limit: max + 1,
-	}).then(files => {
-		if (files.length === max + 1) {
-			moreFiles.value = true;
-			files.pop();
-		} else {
-			moreFiles.value = false;
-		}
-		for (const x of files) appendFile(x);
-		fetching.value = false;
-	});
-}
-
-function getMenu() {
-	return [{
-		type: 'switch',
-		text: i18n.ts.keepOriginalUploading,
-		ref: keepOriginal,
-	}, null, {
-		text: i18n.ts.addFile,
-		type: 'label',
-	}, {
-		text: i18n.ts.upload,
-		icon: 'ti ti-upload',
-		action: () => { selectLocalFile(); },
-	}, {
-		text: i18n.ts.fromUrl,
-		icon: 'ti ti-link',
-		action: () => { urlUpload(); },
-	}, null, {
-		text: folder.value ? folder.value.name : i18n.ts.drive,
-		type: 'label',
-	}, folder.value ? {
-		text: i18n.ts.renameFolder,
-		icon: 'ti ti-forms',
-		action: () => { renameFolder(folder.value); },
-	} : undefined, folder.value ? {
-		text: i18n.ts.deleteFolder,
-		icon: 'ti ti-trash',
-		action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
-	} : undefined, {
-		text: i18n.ts.createFolder,
-		icon: 'ti ti-folder-plus',
-		action: () => { createFolder(); },
-	}];
-}
-
-function showMenu(ev: MouseEvent) {
-	os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu(getMenu(), ev);
-}
-
-onMounted(() => {
-	if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
-		nextTick(() => {
-			ilFilesObserver.observe(loadMoreFiles.value?.$el);
-		});
-	}
-
-	connection.on('fileCreated', onStreamDriveFileCreated);
-	connection.on('fileUpdated', onStreamDriveFileUpdated);
-	connection.on('fileDeleted', onStreamDriveFileDeleted);
-	connection.on('folderCreated', onStreamDriveFolderCreated);
-	connection.on('folderUpdated', onStreamDriveFolderUpdated);
-	connection.on('folderDeleted', onStreamDriveFolderDeleted);
-
-	if (props.initialFolder) {
-		move(props.initialFolder);
-	} else {
-		fetch();
-	}
-});
-
-onActivated(() => {
-	if (defaultStore.state.enableInfiniteScroll) {
-		nextTick(() => {
-			ilFilesObserver.observe(loadMoreFiles.value?.$el);
-		});
-	}
-});
-
-onBeforeUnmount(() => {
-	connection.dispose();
-	ilFilesObserver.disconnect();
-});
-</script>
-
-<style lang="scss" scoped>
-.yfudmmck {
-	display: flex;
-	flex-direction: column;
-	height: 100%;
-
-	> nav {
-		display: flex;
-		z-index: 2;
-		width: 100%;
-		padding: 0 8px;
-		box-sizing: border-box;
-		overflow: auto;
-		font-size: 0.9em;
-		box-shadow: 0 1px 0 var(--divider);
-
-		&, * {
-			user-select: none;
-		}
-
-		> .path {
-			display: inline-block;
-			vertical-align: bottom;
-			line-height: 42px;
-			white-space: nowrap;
-
-			> * {
-				display: inline-block;
-				margin: 0;
-				padding: 0 8px;
-				line-height: 42px;
-				cursor: pointer;
-
-				* {
-					pointer-events: none;
-				}
-
-				&:hover {
-					text-decoration: underline;
-				}
-
-				&.current {
-					font-weight: bold;
-					cursor: default;
-
-					&:hover {
-						text-decoration: none;
-					}
-				}
-
-				&.separator {
-					margin: 0;
-					padding: 0;
-					opacity: 0.5;
-					cursor: default;
-
-					> i {
-						margin: 0;
-					}
-				}
-			}
-		}
-
-		> .menu {
-			margin-left: auto;
-			padding: 0 12px;
-		}
-	}
-
-	> .main {
-		flex: 1;
-		overflow: auto;
-		padding: var(--margin);
-
-		&, * {
-			user-select: none;
-		}
-
-		&.fetching {
-			cursor: wait !important;
-
-			* {
-				pointer-events: none;
-			}
-
-			> .contents {
-				opacity: 0.5;
-			}
-		}
-
-		&.uploading {
-			height: calc(100% - 38px - 100px);
-		}
-
-		> .contents {
-
-			> .folders,
-			> .files {
-				display: flex;
-				flex-wrap: wrap;
-
-				> .folder,
-				> .file {
-					flex-grow: 1;
-					width: 128px;
-					margin: 4px;
-					box-sizing: border-box;
-				}
-
-				> .padding {
-					flex-grow: 1;
-					pointer-events: none;
-					width: 128px + 8px;
-				}
-			}
-
-			> .empty {
-				padding: 16px;
-				text-align: center;
-				pointer-events: none;
-				opacity: 0.5;
-
-				> p {
-					margin: 0;
-				}
-			}
-		}
-	}
-
-	> .dropzone {
-		position: absolute;
-		left: 0;
-		top: 38px;
-		width: 100%;
-		height: calc(100% - 38px);
-		border: dashed 2px var(--focus);
-		pointer-events: none;
-	}
-
-	> input {
-		display: none;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDriveFileThumbnail.vue b/packages/client/src/components/MkDriveFileThumbnail.vue
deleted file mode 100644
index 33379ed5ca..0000000000
--- a/packages/client/src/components/MkDriveFileThumbnail.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<template>
-<div ref="thumbnail" class="zdjebgpv">
-	<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
-	<i v-else-if="is === 'image'" class="ti ti-photo icon"></i>
-	<i v-else-if="is === 'video'" class="ti ti-video icon"></i>
-	<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i>
-	<i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i>
-	<i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i>
-	<i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i>
-	<i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i>
-	<i v-else class="ti ti-file icon"></i>
-
-	<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as Misskey from 'misskey-js';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-
-const props = defineProps<{
-	file: Misskey.entities.DriveFile;
-	fit: string;
-}>();
-
-const is = computed(() => {
-	if (props.file.type.startsWith('image/')) return 'image';
-	if (props.file.type.startsWith('video/')) return 'video';
-	if (props.file.type === 'audio/midi') return 'midi';
-	if (props.file.type.startsWith('audio/')) return 'audio';
-	if (props.file.type.endsWith('/csv')) return 'csv';
-	if (props.file.type.endsWith('/pdf')) return 'pdf';
-	if (props.file.type.startsWith('text/')) return 'textfile';
-	if ([
-		'application/zip',
-		'application/x-cpio',
-		'application/x-bzip',
-		'application/x-bzip2',
-		'application/java-archive',
-		'application/x-rar-compressed',
-		'application/x-tar',
-		'application/gzip',
-		'application/x-7z-compressed',
-	].some(archiveType => archiveType === props.file.type)) return 'archive';
-	return 'unknown';
-});
-
-const isThumbnailAvailable = computed(() => {
-	return props.file.thumbnailUrl
-		? (is.value === 'image' as const || is.value === 'video')
-		: false;
-});
-</script>
-
-<style lang="scss" scoped>
-.zdjebgpv {
-	position: relative;
-	display: flex;
-	background: var(--panel);
-	border-radius: 8px;
-	overflow: clip;
-
-	> .icon-sub {
-		position: absolute;
-		width: 30%;
-		height: auto;
-		margin: 0;
-		right: 4%;
-		bottom: 4%;
-	}
-
-	> .icon {
-		pointer-events: none;
-		margin: auto;
-		font-size: 32px;
-		color: #777;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkDriveSelectDialog.vue b/packages/client/src/components/MkDriveSelectDialog.vue
deleted file mode 100644
index 3ee821b539..0000000000
--- a/packages/client/src/components/MkDriveSelectDialog.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="800"
-	:height="500"
-	:with-ok-button="true"
-	:ok-button-disabled="(type === 'file') && (selected.length === 0)"
-	@click="cancel()"
-	@close="cancel()"
-	@ok="ok()"
-	@closed="emit('closed')"
->
-	<template #header>
-		{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
-		<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
-	</template>
-	<XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import XDrive from '@/components/MkDrive.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-withDefaults(defineProps<{
-	type?: 'file' | 'folder';
-	multiple: boolean;
-}>(), {
-	type: 'file',
-});
-
-const emit = defineEmits<{
-	(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
-	(ev: 'closed'): void;
-}>();
-
-const dialog = ref<InstanceType<typeof XModalWindow>>();
-
-const selected = ref<Misskey.entities.DriveFile[]>([]);
-
-function ok() {
-	emit('done', selected.value);
-	dialog.value?.close();
-}
-
-function cancel() {
-	emit('done');
-	dialog.value?.close();
-}
-
-function onChangeSelection(files: Misskey.entities.DriveFile[]) {
-	selected.value = files;
-}
-</script>
diff --git a/packages/client/src/components/MkDriveWindow.vue b/packages/client/src/components/MkDriveWindow.vue
deleted file mode 100644
index 617200321b..0000000000
--- a/packages/client/src/components/MkDriveWindow.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-<XWindow
-	ref="window"
-	:initial-width="800"
-	:initial-height="500"
-	:can-resize="true"
-	@closed="emit('closed')"
->
-	<template #header>
-		{{ i18n.ts.drive }}
-	</template>
-	<XDrive :initial-folder="initialFolder"/>
-</XWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as Misskey from 'misskey-js';
-import XDrive from '@/components/MkDrive.vue';
-import XWindow from '@/components/MkWindow.vue';
-import { i18n } from '@/i18n';
-
-defineProps<{
-	initialFolder?: Misskey.entities.DriveFolder;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-</script>
diff --git a/packages/client/src/components/MkEmojiPicker.section.vue b/packages/client/src/components/MkEmojiPicker.section.vue
deleted file mode 100644
index f6ba7abfc4..0000000000
--- a/packages/client/src/components/MkEmojiPicker.section.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<template>
-<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
-<section>
-	<header class="_acrylic" @click="shown = !shown">
-		<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
-	</header>
-	<div v-if="shown" class="body">
-		<button
-			v-for="emoji in emojis"
-			:key="emoji"
-			class="_button item"
-			@click="emit('chosen', emoji, $event)"
-		>
-			<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
-		</button>
-	</div>
-</section>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-
-const props = defineProps<{
-	emojis: string[];
-	initialShown?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'chosen', v: string, event: MouseEvent): void;
-}>();
-
-const shown = ref(!!props.initialShown);
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/components/MkEmojiPicker.vue b/packages/client/src/components/MkEmojiPicker.vue
deleted file mode 100644
index 814f71168a..0000000000
--- a/packages/client/src/components/MkEmojiPicker.vue
+++ /dev/null
@@ -1,569 +0,0 @@
-<template>
-<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
-	<input ref="search" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()">
-	<div ref="emojis" class="emojis">
-		<section class="result">
-			<div v-if="searchResultCustom.length > 0" class="body">
-				<button
-					v-for="emoji in searchResultCustom"
-					:key="emoji.id"
-					class="_button item"
-					:title="emoji.name"
-					tabindex="0"
-					@click="chosen(emoji, $event)"
-				>
-					<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
-					<img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
-				</button>
-			</div>
-			<div v-if="searchResultUnicode.length > 0" class="body">
-				<button
-					v-for="emoji in searchResultUnicode"
-					:key="emoji.name"
-					class="_button item"
-					:title="emoji.name"
-					tabindex="0"
-					@click="chosen(emoji, $event)"
-				>
-					<MkEmoji class="emoji" :emoji="emoji.char"/>
-				</button>
-			</div>
-		</section>
-
-		<div v-if="tab === 'index'" class="group index">
-			<section v-if="showPinned">
-				<div class="body">
-					<button
-						v-for="emoji in pinned"
-						:key="emoji"
-						class="_button item"
-						tabindex="0"
-						@click="chosen(emoji, $event)"
-					>
-						<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
-					</button>
-				</div>
-			</section>
-
-			<section>
-				<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
-				<div class="body">
-					<button
-						v-for="emoji in recentlyUsedEmojis"
-						:key="emoji"
-						class="_button item"
-						@click="chosen(emoji, $event)"
-					>
-						<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
-					</button>
-				</div>
-			</section>
-		</div>
-		<div v-once class="group">
-			<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
-			<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection>
-		</div>
-		<div v-once class="group">
-			<header class="_acrylic">{{ i18n.ts.emoji }}</header>
-			<XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
-		</div>
-	</div>
-	<div class="tabs">
-		<button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ti ti-asterisk ti-fw"></i></button>
-		<button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ti ti-mood-happy ti-fw"></i></button>
-		<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button>
-		<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed, watch, onMounted } from 'vue';
-import * as Misskey from 'misskey-js';
-import XSection from '@/components/MkEmojiPicker.section.vue';
-import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import Ripple from '@/components/MkRipple.vue';
-import * as os from '@/os';
-import { isTouchUsing } from '@/scripts/touch';
-import { deviceKind } from '@/scripts/device-kind';
-import { emojiCategories, instance } from '@/instance';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-
-const props = withDefaults(defineProps<{
-	showPinned?: boolean;
-	asReactionPicker?: boolean;
-	maxHeight?: number;
-	asDrawer?: boolean;
-}>(), {
-	showPinned: true,
-});
-
-const emit = defineEmits<{
-	(ev: 'chosen', v: string): void;
-}>();
-
-const search = ref<HTMLInputElement>();
-const emojis = ref<HTMLDivElement>();
-
-const {
-	reactions: pinned,
-	reactionPickerSize,
-	reactionPickerWidth,
-	reactionPickerHeight,
-	disableShowingAnimatedImages,
-	recentlyUsedEmojis,
-} = defaultStore.reactiveState;
-
-const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
-const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
-const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
-const customEmojiCategories = emojiCategories;
-const customEmojis = instance.emojis;
-const q = ref<string>('');
-const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
-const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
-const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
-
-watch(q, () => {
-	if (emojis.value) emojis.value.scrollTop = 0;
-
-	if (q.value === '') {
-		searchResultCustom.value = [];
-		searchResultUnicode.value = [];
-		return;
-	}
-
-	const newQ = q.value.replace(/:/g, '').toLowerCase();
-
-	const searchCustom = () => {
-		const max = 8;
-		const emojis = customEmojis;
-		const matches = new Set<Misskey.entities.CustomEmoji>();
-
-		const exactMatch = emojis.find(emoji => emoji.name === newQ);
-		if (exactMatch) matches.add(exactMatch);
-
-		if (newQ.includes(' ')) { // AND検索
-			const keywords = newQ.split(' ');
-
-			// 名前にキーワードが含まれている
-			for (const emoji of emojis) {
-				if (keywords.every(keyword => emoji.name.includes(keyword))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			// 名前またはエイリアスにキーワードが含まれている
-			for (const emoji of emojis) {
-				if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-		} else {
-			for (const emoji of emojis) {
-				if (emoji.name.startsWith(newQ)) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.aliases.some(alias => alias.startsWith(newQ))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.name.includes(newQ)) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.aliases.some(alias => alias.includes(newQ))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-		}
-
-		return matches;
-	};
-
-	const searchUnicode = () => {
-		const max = 8;
-		const emojis = emojilist;
-		const matches = new Set<UnicodeEmojiDef>();
-
-		const exactMatch = emojis.find(emoji => emoji.name === newQ);
-		if (exactMatch) matches.add(exactMatch);
-
-		if (newQ.includes(' ')) { // AND検索
-			const keywords = newQ.split(' ');
-
-			// 名前にキーワードが含まれている
-			for (const emoji of emojis) {
-				if (keywords.every(keyword => emoji.name.includes(keyword))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			// 名前またはエイリアスにキーワードが含まれている
-			for (const emoji of emojis) {
-				if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-		} else {
-			for (const emoji of emojis) {
-				if (emoji.name.startsWith(newQ)) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.name.includes(newQ)) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-			if (matches.size >= max) return matches;
-
-			for (const emoji of emojis) {
-				if (emoji.keywords.some(keyword => keyword.includes(newQ))) {
-					matches.add(emoji);
-					if (matches.size >= max) break;
-				}
-			}
-		}
-
-		return matches;
-	};
-
-	searchResultCustom.value = Array.from(searchCustom());
-	searchResultUnicode.value = Array.from(searchUnicode());
-});
-
-function focus() {
-	if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
-		search.value?.focus({
-			preventScroll: true,
-		});
-	}
-}
-
-function reset() {
-	if (emojis.value) emojis.value.scrollTop = 0;
-	q.value = '';
-}
-
-function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string {
-	return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
-}
-
-function chosen(emoji: any, ev?: MouseEvent) {
-	const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
-	if (el) {
-		const rect = el.getBoundingClientRect();
-		const x = rect.left + (el.offsetWidth / 2);
-		const y = rect.top + (el.offsetHeight / 2);
-		os.popup(Ripple, { x, y }, {}, 'end');
-	}
-
-	const key = getKey(emoji);
-	emit('chosen', key);
-
-	// 最近使った絵文字更新
-	if (!pinned.value.includes(key)) {
-		let recents = defaultStore.state.recentlyUsedEmojis;
-		recents = recents.filter((emoji: any) => emoji !== key);
-		recents.unshift(key);
-		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
-	}
-}
-
-function input(): void {
-	// Using custom input event instead of v-model to respond immediately on
-	// Android, where composition happens on all languages
-	// (v-model does not update during composition)
-	q.value = search.value?.value.trim() ?? '';
-}
-
-function paste(event: ClipboardEvent): void {
-	const pasted = event.clipboardData?.getData('text') ?? '';
-	if (done(pasted)) {
-		event.preventDefault();
-	}
-}
-
-function done(query?: string): boolean | void {
-	if (query == null) query = q.value;
-	if (query == null || typeof query !== 'string') return;
-
-	const q2 = query.replace(/:/g, '');
-	const exactMatchCustom = customEmojis.find(emoji => emoji.name === q2);
-	if (exactMatchCustom) {
-		chosen(exactMatchCustom);
-		return true;
-	}
-	const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2);
-	if (exactMatchUnicode) {
-		chosen(exactMatchUnicode);
-		return true;
-	}
-	if (searchResultCustom.value.length > 0) {
-		chosen(searchResultCustom.value[0]);
-		return true;
-	}
-	if (searchResultUnicode.value.length > 0) {
-		chosen(searchResultUnicode.value[0]);
-		return true;
-	}
-}
-
-onMounted(() => {
-	focus();
-});
-
-defineExpose({
-	focus,
-	reset,
-});
-</script>
-
-<style lang="scss" scoped>
-.omfetrab {
-	$pad: 8px;
-
-	display: flex;
-	flex-direction: column;
-
-	&.s1 {
-		--eachSize: 40px;
-	}
-
-	&.s2 {
-		--eachSize: 45px;
-	}
-
-	&.s3 {
-		--eachSize: 50px;
-	}
-
-	&.w1 {
-		width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
-		--columns: 1fr 1fr 1fr 1fr 1fr;
-	}
-
-	&.w2 {
-		width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
-		--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
-	}
-
-	&.w3 {
-		width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
-		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
-	}
-
-	&.w4 {
-		width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
-		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
-	}
-
-	&.w5 {
-		width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
-		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
-	}
-
-	&.h1 {
-		height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
-	}
-
-	&.h2 {
-		height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
-	}
-
-	&.h3 {
-		height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
-	}
-
-	&.h4 {
-		height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
-	}
-
-	&.asDrawer {
-		width: 100% !important;
-
-		> .emojis {
-			::v-deep(section) {
-				> header {
-					height: 32px;
-					line-height: 32px;
-					padding: 0 12px;
-					font-size: 15px;
-				}
-
-				> .body {
-					display: grid;
-					grid-template-columns: var(--columns);
-					font-size: 30px;
-
-					> .item {
-						aspect-ratio: 1 / 1;
-						width: auto;
-						height: auto;
-						min-width: 0;
-					}
-				}
-			}
-		}
-	}
-
-	> .search {
-		width: 100%;
-		padding: 12px;
-		box-sizing: border-box;
-		font-size: 1em;
-		outline: none;
-		border: none;
-		background: transparent;
-		color: var(--fg);
-
-		&:not(.filled) {
-			order: 1;
-			z-index: 2;
-			box-shadow: 0px -1px 0 0px var(--divider);
-		}
-	}
-
-	> .tabs {
-		display: flex;
-		display: none;
-
-		> .tab {
-			flex: 1;
-			height: 38px;
-			border-top: solid 0.5px var(--divider);
-
-			&.active {
-				border-top: solid 1px var(--accent);
-				color: var(--accent);
-			}
-		}
-	}
-
-	> .emojis {
-		height: 100%;
-		overflow-y: auto;
-		overflow-x: hidden;
-
-		scrollbar-width: none;
-
-		&::-webkit-scrollbar {
-			display: none;
-		}
-
-		> .group {
-			&:not(.index) {
-				padding: 4px 0 8px 0;
-				border-top: solid 0.5px var(--divider);
-			}
-
-			> header {
-				/*position: sticky;
-				top: 0;
-				left: 0;*/
-				height: 32px;
-				line-height: 32px;
-				z-index: 2;
-				padding: 0 8px;
-				font-size: 12px;
-			}
-		}
-
-		::v-deep(section) {
-			> header {
-				position: sticky;
-				top: 0;
-				left: 0;
-				height: 32px;
-				line-height: 32px;
-				z-index: 1;
-				padding: 0 8px;
-				font-size: 12px;
-				cursor: pointer;
-
-				&:hover {
-					color: var(--accent);
-				}
-			}
-
-			> .body {
-				position: relative;
-				padding: $pad;
-
-				> .item {
-					position: relative;
-					padding: 0;
-					width: var(--eachSize);
-					height: var(--eachSize);
-					contain: strict;
-					border-radius: 4px;
-					font-size: 24px;
-
-					&:focus-visible {
-						outline: solid 2px var(--focus);
-						z-index: 1;
-					}
-
-					&:hover {
-						background: rgba(0, 0, 0, 0.05);
-					}
-
-					&:active {
-						background: var(--accent);
-						box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
-					}
-
-					> .emoji {
-						height: 1.25em;
-						vertical-align: -.25em;
-						pointer-events: none;
-					}
-				}
-			}
-
-			&.result {
-				border-bottom: solid 0.5px var(--divider);
-
-				&:empty {
-					display: none;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkEmojiPickerDialog.vue b/packages/client/src/components/MkEmojiPickerDialog.vue
deleted file mode 100644
index 3b41f9d75b..0000000000
--- a/packages/client/src/components/MkEmojiPickerDialog.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<template>
-<MkModal
-	ref="modal"
-	v-slot="{ type, maxHeight }"
-	:z-priority="'middle'"
-	:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
-	:transparent-bg="true"
-	:manual-showing="manualShowing"
-	:src="src"
-	@click="modal?.close()"
-	@opening="opening"
-	@close="emit('close')"
-	@closed="emit('closed')"
->
-	<MkEmojiPicker
-		ref="picker"
-		class="ryghynhb _popup _shadow"
-		:class="{ drawer: type === 'drawer' }"
-		:show-pinned="showPinned"
-		:as-reaction-picker="asReactionPicker"
-		:as-drawer="type === 'drawer'"
-		:max-height="maxHeight"
-		@chosen="chosen"
-	/>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
-import { defaultStore } from '@/store';
-
-withDefaults(defineProps<{
-	manualShowing?: boolean | null;
-	src?: HTMLElement;
-	showPinned?: boolean;
-	asReactionPicker?: boolean;
-}>(), {
-	manualShowing: null,
-	showPinned: true,
-	asReactionPicker: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'done', v: any): void;
-	(ev: 'close'): void;
-	(ev: 'closed'): void;
-}>();
-
-const modal = ref<InstanceType<typeof MkModal>>();
-const picker = ref<InstanceType<typeof MkEmojiPicker>>();
-
-function chosen(emoji: any) {
-	emit('done', emoji);
-	modal.value?.close();
-}
-
-function opening() {
-	picker.value?.reset();
-	picker.value?.focus();
-}
-</script>
-
-<style lang="scss" scoped>
-.ryghynhb {
-	&.drawer {
-		border-radius: 24px;
-		border-bottom-right-radius: 0;
-		border-bottom-left-radius: 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkEmojiPickerWindow.vue b/packages/client/src/components/MkEmojiPickerWindow.vue
deleted file mode 100644
index 523e4ba695..0000000000
--- a/packages/client/src/components/MkEmojiPickerWindow.vue
+++ /dev/null
@@ -1,180 +0,0 @@
-<template>
-<MkWindow ref="window"
-	:initial-width="null"
-	:initial-height="null"
-	:can-resize="false"
-	:mini="true"
-	:front="true"
-	@closed="emit('closed')"
->
-	<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
-</MkWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkWindow from '@/components/MkWindow.vue';
-import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
-
-withDefaults(defineProps<{
-	src?: HTMLElement;
-	showPinned?: boolean;
-	asReactionPicker?: boolean;
-}>(), {
-	showPinned: true,
-});
-
-const emit = defineEmits<{
-	(ev: 'chosen', v: any): void;
-	(ev: 'closed'): void;
-}>();
-
-function chosen(emoji: any) {
-	emit('chosen', emoji);
-}
-</script>
-
-<style lang="scss" scoped>
-.omfetrab {
-	$pad: 8px;
-	--eachSize: 40px;
-
-	display: flex;
-	flex-direction: column;
-	contain: content;
-
-	&.big {
-		--eachSize: 44px;
-	}
-
-	&.w1 {
-		width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
-	}
-
-	&.w2 {
-		width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
-	}
-
-	&.w3 {
-		width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
-	}
-
-	&.h1 {
-		--height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
-	}
-
-	&.h2 {
-		--height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
-	}
-
-	&.h3 {
-		--height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
-	}
-
-	> .search {
-		width: 100%;
-		padding: 12px;
-		box-sizing: border-box;
-		font-size: 1em;
-		outline: none;
-		border: none;
-		background: transparent;
-		color: var(--fg);
-
-		&:not(.filled) {
-			order: 1;
-			z-index: 2;
-			box-shadow: 0px -1px 0 0px var(--divider);
-		}
-	}
-
-	> .emojis {
-		height: var(--height);
-		overflow-y: auto;
-		overflow-x: hidden;
-
-		scrollbar-width: none;
-
-		&::-webkit-scrollbar {
-			display: none;
-		}
-
-		> .index {
-			min-height: var(--height);
-			position: relative;
-			border-bottom: solid 0.5px var(--divider);
-				
-			> .arrow {
-				position: absolute;
-				bottom: 0;
-				left: 0;
-				width: 100%;
-				padding: 16px 0;
-				text-align: center;
-				opacity: 0.5;
-				pointer-events: none;
-			}
-		}
-
-		section {
-			> header {
-				position: sticky;
-				top: 0;
-				left: 0;
-				z-index: 1;
-				padding: 8px;
-				font-size: 12px;
-			}
-
-			> div {
-				padding: $pad;
-
-				> button {
-					position: relative;
-					padding: 0;
-					width: var(--eachSize);
-					height: var(--eachSize);
-					border-radius: 4px;
-
-					&:focus-visible {
-						outline: solid 2px var(--focus);
-						z-index: 1;
-					}
-
-					&:hover {
-						background: rgba(0, 0, 0, 0.05);
-					}
-
-					&:active {
-						background: var(--accent);
-						box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
-					}
-
-					> * {
-						font-size: 24px;
-						height: 1.25em;
-						vertical-align: -.25em;
-						pointer-events: none;
-					}
-				}
-			}
-
-			&.result {
-				border-bottom: solid 0.5px var(--divider);
-
-				&:empty {
-					display: none;
-				}
-			}
-
-			&.unicode {
-				min-height: 384px;
-			}
-
-			&.custom {
-				min-height: 64px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkFeaturedPhotos.vue b/packages/client/src/components/MkFeaturedPhotos.vue
deleted file mode 100644
index e58b5d2849..0000000000
--- a/packages/client/src/components/MkFeaturedPhotos.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-<template>
-<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os';
-
-const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
-
-os.api('meta', { detail: true }).then(gotMeta => {
-	meta.value = gotMeta;
-});
-</script>
-
-<style lang="scss" scoped>
-.xfbouadm {
-	background-position: center;
-	background-size: cover;
-}
-</style>
diff --git a/packages/client/src/components/MkFileCaptionEditWindow.vue b/packages/client/src/components/MkFileCaptionEditWindow.vue
deleted file mode 100644
index 73875251f0..0000000000
--- a/packages/client/src/components/MkFileCaptionEditWindow.vue
+++ /dev/null
@@ -1,175 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="400"
-	:height="450"
-	:with-ok-button="true"
-	:ok-button-disabled="false"
-	@ok="ok()"
-	@close="dialog.close()"
-	@closed="emit('closed')"
->
-	<template #header>{{ i18n.ts.describeFile }}</template>
-	<div>
-		<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" style="height: 100px;"/>
-		<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
-			<template #label>{{ i18n.ts.caption }}</template>
-		</MkTextarea>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as Misskey from 'misskey-js';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	file: Misskey.entities.DriveFile;
-	default: string;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'done', v: string): void;
-	(ev: 'closed'): void;
-}>();
-
-const dialog = $ref<InstanceType<typeof XModalWindow>>();
-
-let caption = $ref(props.default);
-
-async function ok() {
-	emit('done', caption);
-	dialog.close();
-}
-</script>
-
-<style lang="scss" scoped>
-.container {
-	display: flex;
-	width: 100%;
-	height: 100%;
-	flex-direction: row;
-	overflow: scroll;
-	position: fixed;
-	left: 0;
-	top: 0;
-}
-@media (max-width: 850px) {
-	.container {
-		flex-direction: column;
-	}
-	.top-caption {
-		padding-bottom: 8px;
-	}
-}
-.fullwidth {
-	width: 100%;
-	margin: auto;
-}
-.mk-dialog {
-	position: relative;
-	padding: 32px;
-	min-width: 320px;
-	max-width: 480px;
-	box-sizing: border-box;
-	text-align: center;
-	background: var(--panel);
-	border-radius: var(--radius);
-	margin: auto;
-
-	> header {
-		margin: 0 0 8px 0;
-		position: relative;
-
-		> .title {
-			font-weight: bold;
-			font-size: 20px;
-		}
-
-		> .text-count {
-			opacity: 0.7;
-			position: absolute;
-			right: 0;
-		}
-	}
-
-	> .buttons {
-		margin-top: 16px;
-
-		> * {
-			margin: 0 8px;
-		}
-	}
-
-	> textarea {
-		display: block;
-		box-sizing: border-box;
-		padding: 0 24px;
-		margin: 0;
-		width: 100%;
-		font-size: 16px;
-		border: none;
-		border-radius: 0;
-		background: transparent;
-		color: var(--fg);
-		font-family: inherit;
-		max-width: 100%;
-		min-width: 100%;
-		min-height: 90px;
-
-		&:focus-visible {
-			outline: none;
-		}
-
-		&:disabled {
-			opacity: 0.5;
-		}
-	}
-}
-.hdrwpsaf {
-	display: flex;
-	flex-direction: column;
-	height: 100%;
-
-	> header,
-	> footer {
-		align-self: center;
-		display: inline-block;
-		padding: 6px 9px;
-		font-size: 90%;
-		background: rgba(0, 0, 0, 0.5);
-		border-radius: 6px;
-		color: #fff;
-	}
-
-	> header {
-		margin-bottom: 8px;
-		opacity: 0.9;
-	}
-
-	> img {
-		display: block;
-		flex: 1;
-		min-height: 0;
-		object-fit: contain;
-		width: 100%;
-		cursor: zoom-out;
-		image-orientation: from-image;
-	}
-
-	> footer {
-		margin-top: 8px;
-		opacity: 0.8;
-
-		> span + span {
-			margin-left: 0.5em;
-			padding-left: 0.5em;
-			border-left: solid 1px rgba(255, 255, 255, 0.5);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkFileListForAdmin.vue b/packages/client/src/components/MkFileListForAdmin.vue
deleted file mode 100644
index 4910506a95..0000000000
--- a/packages/client/src/components/MkFileListForAdmin.vue
+++ /dev/null
@@ -1,117 +0,0 @@
-<template>
-<div>
-	<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
-		<MkA
-			v-for="file in items"
-			:key="file.id"
-			v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`"
-			:to="`/admin/file/${file.id}`"
-			class="file _button"
-		>
-			<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
-			<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-			<div v-if="viewMode === 'list'" class="body">
-				<div>
-					<small style="opacity: 0.7;">{{ file.name }}</small>
-				</div>
-				<div>
-					<MkAcct v-if="file.user" :user="file.user"/>
-					<div v-else>{{ i18n.ts.system }}</div>
-				</div>
-				<div>
-					<span style="margin-right: 1em;">{{ file.type }}</span>
-					<span>{{ bytes(file.size) }}</span>
-				</div>
-				<div>
-					<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
-				</div>
-			</div>
-		</MkA>
-	</MkPagination>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import MkPagination from '@/components/MkPagination.vue';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	pagination: any;
-	viewMode: 'grid' | 'list';
-}>();
-</script>
-
-<style lang="scss" scoped>
-@keyframes sensitive-blink {
-	0% { opacity: 1; }
-	50% { opacity: 0; }
-}
-
-.urempief {
-	margin-top: var(--margin);
-
-	&.list {
-		> .file {
-			display: flex;
-			width: 100%;
-			box-sizing: border-box;
-			text-align: left;
-			align-items: center;
-
-			&:hover {
-				color: var(--accent);
-			}
-
-			> .thumbnail {
-				width: 128px;
-				height: 128px;
-			}
-
-			> .body {
-				margin-left: 0.3em;
-				padding: 8px;
-				flex: 1;
-
-				@media (max-width: 500px) {
-					font-size: 14px;
-				}
-			}
-		}
-	}
-
-	&.grid {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
-		grid-gap: 12px;
-		margin: var(--margin) 0;
-
-		> .file {
-			position: relative;
-			aspect-ratio: 1;
-		
-			> .thumbnail {
-				width: 100%;
-				height: 100%;
-			}
-
-			> .sensitive-label {
-				position: absolute;
-				z-index: 10;
-				top: 8px;
-				left: 8px;
-				padding: 2px 4px;
-				background: #ff0000bf;
-				color: #fff;
-				border-radius: 4px;
-				font-size: 85%;
-				animation: sensitive-blink 1s infinite;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkFolder.vue b/packages/client/src/components/MkFolder.vue
deleted file mode 100644
index 9e83b07cd7..0000000000
--- a/packages/client/src/components/MkFolder.vue
+++ /dev/null
@@ -1,159 +0,0 @@
-<template>
-<div v-size="{ max: [500] }" class="ssazuxis">
-	<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
-		<div class="title"><slot name="header"></slot></div>
-		<div class="divider"></div>
-		<button class="_button">
-			<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
-			<template v-else><i class="ti ti-chevron-down"></i></template>
-		</button>
-	</header>
-	<transition
-		:name="$store.state.animation ? 'folder-toggle' : ''"
-		@enter="enter"
-		@after-enter="afterEnter"
-		@leave="leave"
-		@after-leave="afterLeave"
-	>
-		<div v-show="showBody">
-			<slot></slot>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import tinycolor from 'tinycolor2';
-
-const localStoragePrefix = 'ui:folder:';
-
-export default defineComponent({
-	props: {
-		expanded: {
-			type: Boolean,
-			required: false,
-			default: true,
-		},
-		persistKey: {
-			type: String,
-			required: false,
-			default: null,
-		},
-	},
-	data() {
-		return {
-			bg: null,
-			showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
-		};
-	},
-	watch: {
-		showBody() {
-			if (this.persistKey) {
-				localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
-			}
-		},
-	},
-	mounted() {
-		function getParentBg(el: Element | null): string {
-			if (el == null || el.tagName === 'BODY') return 'var(--bg)';
-			const bg = el.style.background || el.style.backgroundColor;
-			if (bg) {
-				return bg;
-			} else {
-				return getParentBg(el.parentElement);
-			}
-		}
-		const rawBg = getParentBg(this.$el);
-		const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
-		bg.setAlpha(0.85);
-		this.bg = bg.toRgbString();
-	},
-	methods: {
-		toggleContent(show: boolean) {
-			this.showBody = show;
-		},
-
-		enter(el) {
-			const elementHeight = el.getBoundingClientRect().height;
-			el.style.height = 0;
-			el.offsetHeight; // reflow
-			el.style.height = elementHeight + 'px';
-		},
-		afterEnter(el) {
-			el.style.height = null;
-		},
-		leave(el) {
-			const elementHeight = el.getBoundingClientRect().height;
-			el.style.height = elementHeight + 'px';
-			el.offsetHeight; // reflow
-			el.style.height = 0;
-		},
-		afterLeave(el) {
-			el.style.height = null;
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.folder-toggle-enter-active, .folder-toggle-leave-active {
-	overflow-y: hidden;
-	transition: opacity 0.5s, height 0.5s !important;
-}
-.folder-toggle-enter-from {
-	opacity: 0;
-}
-.folder-toggle-leave-to {
-	opacity: 0;
-}
-
-.ssazuxis {
-	position: relative;
-
-	> header {
-		display: flex;
-		position: relative;
-		z-index: 10;
-		position: sticky;
-		top: var(--stickyTop, 0px);
-		padding: var(--x-padding);
-		-webkit-backdrop-filter: var(--blur, blur(8px));
-		backdrop-filter: var(--blur, blur(20px));
-
-		> .title {
-			display: grid;
-			place-content: center;
-			margin: 0;
-			padding: 12px 16px 12px 0;
-
-			> i {
-				margin-right: 6px;
-			}
-
-			&:empty {
-				display: none;
-			}
-		}
-
-		> .divider {
-			flex: 1;
-			margin: auto;
-			height: 1px;
-			background: var(--divider);
-		}
-
-		> button {
-			padding: 12px 0 12px 16px;
-		}
-	}
-
-	&.max-width_500px {
-		> header {
-			> .title {
-				padding: 8px 10px 8px 0;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkFollowButton.vue b/packages/client/src/components/MkFollowButton.vue
deleted file mode 100644
index ee256d9263..0000000000
--- a/packages/client/src/components/MkFollowButton.vue
+++ /dev/null
@@ -1,187 +0,0 @@
-<template>
-<button
-	class="kpoogebi _button"
-	:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
-	:disabled="wait"
-	@click="onClick"
->
-	<template v-if="!wait">
-		<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
-			<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i>
-		</template>
-		<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
-			<!-- つまりリモートフォローの場合。 -->
-			<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
-		</template>
-		<template v-else-if="isFollowing">
-			<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
-		</template>
-		<template v-else-if="!isFollowing && user.isLocked">
-			<span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
-		</template>
-		<template v-else-if="!isFollowing && !user.isLocked">
-			<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
-		</template>
-	</template>
-	<template v-else>
-		<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
-	</template>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { onBeforeUnmount, onMounted } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	user: Misskey.entities.UserDetailed,
-	full?: boolean,
-	large?: boolean,
-}>(), {
-	full: false,
-	large: false,
-});
-
-let isFollowing = $ref(props.user.isFollowing);
-let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
-let wait = $ref(false);
-const connection = stream.useChannel('main');
-
-if (props.user.isFollowing == null) {
-	os.api('users/show', {
-		userId: props.user.id,
-	})
-		.then(onFollowChange);
-}
-
-function onFollowChange(user: Misskey.entities.UserDetailed) {
-	if (user.id === props.user.id) {
-		isFollowing = user.isFollowing;
-		hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
-	}
-}
-
-async function onClick() {
-	wait = true;
-
-	try {
-		if (isFollowing) {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
-			});
-
-			if (canceled) return;
-
-			await os.api('following/delete', {
-				userId: props.user.id,
-			});
-		} else {
-			if (hasPendingFollowRequestFromYou) {
-				await os.api('following/requests/cancel', {
-					userId: props.user.id,
-				});
-				hasPendingFollowRequestFromYou = false;
-			} else {
-				await os.api('following/create', {
-					userId: props.user.id,
-				});
-				hasPendingFollowRequestFromYou = true;
-			}
-		}
-	} catch (err) {
-		console.error(err);
-	} finally {
-		wait = false;
-	}
-}
-
-onMounted(() => {
-	connection.on('follow', onFollowChange);
-	connection.on('unfollow', onFollowChange);
-});
-
-onBeforeUnmount(() => {
-	connection.dispose();
-});
-</script>
-
-<style lang="scss" scoped>
-.kpoogebi {
-	position: relative;
-	display: inline-block;
-	font-weight: bold;
-	color: var(--accent);
-	background: transparent;
-	border: solid 1px var(--accent);
-	padding: 0;
-	height: 31px;
-	font-size: 16px;
-	border-radius: 32px;
-	background: #fff;
-
-	&.full {
-		padding: 0 8px 0 12px;
-		font-size: 14px;
-	}
-
-	&.large {
-		font-size: 16px;
-		height: 38px;
-		padding: 0 12px 0 16px;
-	}
-
-	&:not(.full) {
-		width: 31px;
-	}
-
-	&:focus-visible {
-		&:after {
-			content: "";
-			pointer-events: none;
-			position: absolute;
-			top: -5px;
-			right: -5px;
-			bottom: -5px;
-			left: -5px;
-			border: 2px solid var(--focus);
-			border-radius: 32px;
-		}
-	}
-
-	&:hover {
-		//background: mix($primary, #fff, 20);
-	}
-
-	&:active {
-		//background: mix($primary, #fff, 40);
-	}
-
-	&.active {
-		color: #fff;
-		background: var(--accent);
-
-		&:hover {
-			background: var(--accentLighten);
-			border-color: var(--accentLighten);
-		}
-
-		&:active {
-			background: var(--accentDarken);
-			border-color: var(--accentDarken);
-		}
-	}
-
-	&.wait {
-		cursor: wait !important;
-		opacity: 0.7;
-	}
-
-	> span {
-		margin-right: 6px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkForgotPassword.vue b/packages/client/src/components/MkForgotPassword.vue
deleted file mode 100644
index 1b55451c94..0000000000
--- a/packages/client/src/components/MkForgotPassword.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<template>
-<XModalWindow ref="dialog"
-	:width="370"
-	:height="400"
-	@close="dialog.close()"
-	@closed="emit('closed')"
->
-	<template #header>{{ i18n.ts.forgotPassword }}</template>
-
-	<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
-		<div class="main _formRoot">
-			<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
-				<template #label>{{ i18n.ts.username }}</template>
-				<template #prefix>@</template>
-			</MkInput>
-
-			<MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required>
-				<template #label>{{ i18n.ts.emailAddress }}</template>
-				<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
-			</MkInput>
-
-			<MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
-		</div>
-		<div class="sub">
-			<MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
-		</div>
-	</form>
-	<div v-else class="bafecedb">
-		{{ i18n.ts._forgotPassword.contactAdmin }}
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const emit = defineEmits<{
-	(ev: 'done'): void;
-	(ev: 'closed'): void;
-}>();
-
-let dialog: InstanceType<typeof XModalWindow> = $ref();
-
-let username = $ref('');
-let email = $ref('');
-let processing = $ref(false);
-
-async function onSubmit() {
-	processing = true;
-	await os.apiWithDialog('request-reset-password', {
-		username,
-		email,
-	});
-	emit('done');
-	dialog.close();
-}
-</script>
-
-<style lang="scss" scoped>
-.bafeceda {
-	> .main {
-		padding: 24px;
-	}
-
-	> .sub {
-		border-top: solid 0.5px var(--divider);
-		padding: 24px;
-	}
-}
-
-.bafecedb {
-	padding: 24px;
-}
-</style>
diff --git a/packages/client/src/components/MkFormDialog.vue b/packages/client/src/components/MkFormDialog.vue
deleted file mode 100644
index b2bf76a8c7..0000000000
--- a/packages/client/src/components/MkFormDialog.vue
+++ /dev/null
@@ -1,127 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="450"
-	:can-close="false"
-	:with-ok-button="true"
-	:ok-button-disabled="false"
-	@click="cancel()"
-	@ok="ok()"
-	@close="cancel()"
-	@closed="$emit('closed')"
->
-	<template #header>
-		{{ title }}
-	</template>
-
-	<MkSpacer :margin-min="20" :margin-max="32">
-		<div class="xkpnjxcv _formRoot">
-			<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
-				<FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
-				</FormInput>
-				<FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
-				</FormInput>
-				<FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
-				</FormTextarea>
-				<FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]" class="_formBlock">
-					<span v-text="form[item].label || item"></span>
-					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
-				</FormSwitch>
-				<FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option>
-				</FormSelect>
-				<FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
-				</FormRadios>
-				<FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock">
-					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
-					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
-				</FormRange>
-				<MkButton v-else-if="form[item].type === 'button'" class="_formBlock" @click="form[item].action($event, values)">
-					<span v-text="form[item].content || item"></span>
-				</MkButton>
-			</template>
-		</div>
-	</MkSpacer>
-</XModalWindow>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import FormInput from './form/input.vue';
-import FormTextarea from './form/textarea.vue';
-import FormSwitch from './form/switch.vue';
-import FormSelect from './form/select.vue';
-import FormRange from './form/range.vue';
-import MkButton from './MkButton.vue';
-import FormRadios from './form/radios.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-
-export default defineComponent({
-	components: {
-		XModalWindow,
-		FormInput,
-		FormTextarea,
-		FormSwitch,
-		FormSelect,
-		FormRange,
-		MkButton,
-		FormRadios,
-	},
-
-	props: {
-		title: {
-			type: String,
-			required: true,
-		},
-		form: {
-			type: Object,
-			required: true,
-		},
-	},
-
-	emits: ['done'],
-
-	data() {
-		return {
-			values: {},
-		};
-	},
-
-	created() {
-		for (const item in this.form) {
-			this.values[item] = this.form[item].default ?? null;
-		}
-	},
-
-	methods: {
-		ok() {
-			this.$emit('done', {
-				result: this.values,
-			});
-			this.$refs.dialog.close();
-		},
-
-		cancel() {
-			this.$emit('done', {
-				canceled: true,
-			});
-			this.$refs.dialog.close();
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.xkpnjxcv {
-
-}
-</style>
diff --git a/packages/client/src/components/MkFormula.vue b/packages/client/src/components/MkFormula.vue
deleted file mode 100644
index 65a2fee930..0000000000
--- a/packages/client/src/components/MkFormula.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<template>
-<XFormula :formula="formula" :block="block"/>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import * as os from '@/os';
-
-export default defineComponent({
-	components: {
-		XFormula: defineAsyncComponent(() => import('@/components/MkFormulaCore.vue')),
-	},
-	props: {
-		formula: {
-			type: String,
-			required: true,
-		},
-		block: {
-			type: Boolean,
-			required: true,
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/components/MkFormulaCore.vue b/packages/client/src/components/MkFormulaCore.vue
deleted file mode 100644
index 6028db9e64..0000000000
--- a/packages/client/src/components/MkFormulaCore.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<!-- eslint-disable vue/no-v-html -->
-<template>
-<div v-if="block" v-html="compiledFormula"></div>
-<span v-else v-html="compiledFormula"></span>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import katex from 'katex';
-
-export default defineComponent({
-	props: {
-		formula: {
-			type: String,
-			required: true,
-		},
-		block: {
-			type: Boolean,
-			required: true,
-		},
-	},
-	computed: {
-		compiledFormula(): any {
-			return katex.renderToString(this.formula, {
-				throwOnError: false,
-			} as any);
-		},
-	},
-});
-</script>
-
-<style>
-@import "../../node_modules/katex/dist/katex.min.css";
-</style>
diff --git a/packages/client/src/components/MkGalleryPostPreview.vue b/packages/client/src/components/MkGalleryPostPreview.vue
deleted file mode 100644
index a133f6431b..0000000000
--- a/packages/client/src/components/MkGalleryPostPreview.vue
+++ /dev/null
@@ -1,115 +0,0 @@
-<template>
-<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
-	<div class="thumbnail">
-		<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
-	</div>
-	<article>
-		<header>
-			<MkAvatar :user="post.user" class="avatar"/>
-		</header>
-		<footer>
-			<span class="title">{{ post.title }}</span>
-		</footer>
-	</article>
-</MkA>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { userName } from '@/filters/user';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import * as os from '@/os';
-
-const props = defineProps<{
-	post: any;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.ttasepnz {
-	display: block;
-	position: relative;
-	height: 200px;
-
-	&:hover {
-		text-decoration: none;
-		color: var(--accent);
-
-		> .thumbnail {
-			transform: scale(1.1);
-		}
-
-		> article {
-			> footer {
-				&:before {
-					opacity: 1;
-				}
-			}
-		}
-	}
-
-	> .thumbnail {
-		width: 100%;
-		height: 100%;
-		position: absolute;
-		transition: all 0.5s ease;
-
-		> .img {
-			width: 100%;
-			height: 100%;
-			object-fit: cover;
-		}
-	}
-
-	> article {
-		position: absolute;
-		z-index: 1;
-		width: 100%;
-		height: 100%;
-
-		> header {
-			position: absolute;
-			top: 0;
-			width: 100%;
-			padding: 12px;
-			box-sizing: border-box;
-			display: flex;
-
-			> .avatar {
-				margin-left: auto;
-				width: 32px;
-				height: 32px;
-			}
-		}
-
-		> footer {
-			position: absolute;
-			bottom: 0;
-			width: 100%;
-			padding: 16px;
-			box-sizing: border-box;
-			color: #fff;
-			text-shadow: 0 0 8px #000;
-			background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
-
-			&:before {
-				content: "";
-				display: block;
-				position: absolute;
-				z-index: -1;
-				top: 0;
-				left: 0;
-				width: 100%;
-				height: 100%;
-				background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
-				opacity: 0;
-				transition: opacity 0.5s ease;
-			}
-
-			> .title {
-				font-weight: bold;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkGoogle.vue b/packages/client/src/components/MkGoogle.vue
deleted file mode 100644
index d104cd4cd4..0000000000
--- a/packages/client/src/components/MkGoogle.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<template>
-<div class="mk-google">
-	<input v-model="query" type="search" :placeholder="q">
-	<button @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-
-const props = defineProps<{
-	q: string;
-}>();
-
-const query = ref(props.q);
-
-const search = () => {
-	window.open(`https://www.google.com/search?q=${query.value}`, '_blank');
-};
-</script>
-
-<style lang="scss" scoped>
-.mk-google {
-	display: flex;
-	margin: 8px 0;
-
-	> input {
-		flex-shrink: 1;
-		padding: 10px;
-		width: 100%;
-		height: 40px;
-		font-size: 16px;
-		border: solid 1px var(--divider);
-		border-radius: 4px 0 0 4px;
-		-webkit-appearance: textfield;
-	}
-
-	> button {
-		flex-shrink: 0;
-		margin: 0;
-		padding: 0 16px;
-		border: solid 1px var(--divider);
-		border-left: none;
-		border-radius: 0 4px 4px 0;
-
-		&:active {
-			box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkImageViewer.vue b/packages/client/src/components/MkImageViewer.vue
deleted file mode 100644
index f074b1a2f2..0000000000
--- a/packages/client/src/components/MkImageViewer.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-<template>
-<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
-	<div class="xubzgfga">
-		<header>{{ image.name }}</header>
-		<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
-		<footer>
-			<span>{{ image.type }}</span>
-			<span>{{ bytes(image.size) }}</span>
-			<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
-		</footer>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import bytes from '@/filters/bytes';
-import number from '@/filters/number';
-import MkModal from '@/components/MkModal.vue';
-
-const props = withDefaults(defineProps<{
-	image: misskey.entities.DriveFile;
-}>(), {
-});
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const modal = $ref<InstanceType<typeof MkModal>>();
-</script>
-
-<style lang="scss" scoped>
-.xubzgfga {
-	display: flex;
-	flex-direction: column;
-	height: 100%;
-
-	> header,
-	> footer {
-		align-self: center;
-		display: inline-block;
-		padding: 6px 9px;
-		font-size: 90%;
-		background: rgba(0, 0, 0, 0.5);
-		border-radius: 6px;
-		color: #fff;
-	}
-
-	> header {
-		margin-bottom: 8px;
-		opacity: 0.9;
-	}
-
-	> img {
-		display: block;
-		flex: 1;
-		min-height: 0;
-		object-fit: contain;
-		width: 100%;
-		cursor: zoom-out;
-		image-orientation: from-image;
-	}
-
-	> footer {
-		margin-top: 8px;
-		opacity: 0.8;
-
-		> span + span {
-			margin-left: 0.5em;
-			padding-left: 0.5em;
-			border-left: solid 1px rgba(255, 255, 255, 0.5);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkImgWithBlurhash.vue b/packages/client/src/components/MkImgWithBlurhash.vue
deleted file mode 100644
index 80d7c201a4..0000000000
--- a/packages/client/src/components/MkImgWithBlurhash.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<template>
-<div class="xubzgfgb" :class="{ cover }" :title="title">
-	<canvas v-if="!loaded" ref="canvas" :width="size" :height="size" :title="title"/>
-	<img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import { decode } from 'blurhash';
-
-const props = withDefaults(defineProps<{
-	src?: string | null;
-	hash?: string;
-	alt?: string;
-	title?: string | null;
-	size?: number;
-	cover?: boolean;
-}>(), {
-	src: null,
-	alt: '',
-	title: null,
-	size: 64,
-	cover: true,
-});
-
-const canvas = $ref<HTMLCanvasElement>();
-let loaded = $ref(false);
-
-function draw() {
-	if (props.hash == null) return;
-	const pixels = decode(props.hash, props.size, props.size);
-	const ctx = canvas.getContext('2d');
-	const imageData = ctx!.createImageData(props.size, props.size);
-	imageData.data.set(pixels);
-	ctx!.putImageData(imageData, 0, 0);
-}
-
-function onLoad() {
-	loaded = true;
-}
-
-onMounted(() => {
-	draw();
-});
-</script>
-
-<style lang="scss" scoped>
-.xubzgfgb {
-	position: relative;
-	width: 100%;
-	height: 100%;
-
-	> canvas,
-	> img {
-		display: block;
-		width: 100%;
-		height: 100%;
-	}
-
-	> canvas {
-		position: absolute;
-		object-fit: cover;
-	}
-
-	> img {
-		object-fit: contain;
-	}
-
-	&.cover {
-		> img {
-			object-fit: cover;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkInfo.vue b/packages/client/src/components/MkInfo.vue
deleted file mode 100644
index 7aaf2c5bcb..0000000000
--- a/packages/client/src/components/MkInfo.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<template>
-<div class="fpezltsf" :class="{ warn }">
-	<i v-if="warn" class="ti ti-alert-triangle"></i>
-	<i v-else class="ti ti-info-circle"></i>
-	<slot></slot>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	warn?: boolean;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.fpezltsf {
-	padding: 12px 14px;
-	font-size: 90%;
-	background: var(--infoBg);
-	color: var(--infoFg);
-	border-radius: var(--radius);
-
-	&.warn {
-		background: var(--infoWarnBg);
-		color: var(--infoWarnFg);
-	}
-
-	> i {
-		margin-right: 4px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkInstanceCardMini.vue b/packages/client/src/components/MkInstanceCardMini.vue
deleted file mode 100644
index 4625de40af..0000000000
--- a/packages/client/src/components/MkInstanceCardMini.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<template>
-<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]">
-	<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
-	<div class="body">
-		<span class="host">{{ instance.name ?? instance.host }}</span>
-		<span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span>
-	</div>
-	<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import * as misskey from 'misskey-js';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const props = defineProps<{
-	instance: misskey.entities.Instance;
-}>();
-
-let chartValues = $ref<number[] | null>(null);
-
-os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
-	// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
-	res.requests.received.splice(0, 1);
-	chartValues = res.requests.received;
-});
-
-function getInstanceIcon(instance): string {
-	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
-}
-</script>
-
-<style lang="scss" module>
-.root {
-	$bodyTitleHieght: 18px;
-	$bodyInfoHieght: 16px;
-
-	display: flex;
-	align-items: center;
-	padding: 16px;
-	background: var(--panel);
-	border-radius: 8px;
-
-	> :global(.icon) {
-		display: block;
-		width: ($bodyTitleHieght + $bodyInfoHieght);
-		height: ($bodyTitleHieght + $bodyInfoHieght);
-		object-fit: cover;
-		border-radius: 4px;
-		margin-right: 10px;
-	}
-
-	> :global(.body) {
-		flex: 1;
-		overflow: hidden;
-		font-size: 0.9em;
-		color: var(--fg);
-		padding-right: 8px;
-
-		> :global(.host) {
-			display: block;
-			width: 100%;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			line-height: $bodyTitleHieght;
-		}
-
-		> :global(.sub) {
-			display: block;
-			width: 100%;
-			font-size: 80%;
-			opacity: 0.7;
-			line-height: $bodyInfoHieght;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-	}
-
-	> :global(.chart) {
-		height: 30px;
-	}
-
-	&:global(.yellow) {
-		--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;
-	}
-
-	&:global(.red) {
-		--c: rgb(255 0 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;
-	}
-
-	&:global(.gray) {
-		--c: var(--bg);
-		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/client/src/components/MkInstanceStats.vue b/packages/client/src/components/MkInstanceStats.vue
deleted file mode 100644
index 41f6f9ffd5..0000000000
--- a/packages/client/src/components/MkInstanceStats.vue
+++ /dev/null
@@ -1,255 +0,0 @@
-<template>
-<div :class="$style.root">
-	<MkFolder class="item">
-		<template #header>Chart</template>
-		<div :class="$style.chart">
-			<div class="selects">
-				<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
-					<optgroup :label="i18n.ts.federation">
-						<option value="federation">{{ i18n.ts._charts.federation }}</option>
-						<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.users">
-						<option value="users">{{ i18n.ts._charts.usersIncDec }}</option>
-						<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option>
-						<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.notes">
-						<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
-						<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
-						<option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
-						<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.drive">
-						<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option>
-						<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option>
-					</optgroup>
-				</MkSelect>
-				<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
-					<option value="hour">{{ i18n.ts.perHour }}</option>
-					<option value="day">{{ i18n.ts.perDay }}</option>
-				</MkSelect>
-			</div>
-			<div class="chart _panel">
-				<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
-			</div>
-		</div>
-	</MkFolder>
-
-	<MkFolder class="item">
-		<template #header>Active users heatmap</template>
-		<div class="_panel" :class="$style.heatmap">
-			<MkActiveUsersHeatmap/>
-		</div>
-	</MkFolder>
-
-	<MkFolder class="item">
-		<template #header>Federation</template>
-		<div :class="$style.federation">
-			<div class="pies">
-				<div class="sub">
-					<div class="title">Sub</div>
-					<canvas ref="subDoughnutEl"></canvas>
-				</div>
-				<div class="pub">
-					<div class="title">Pub</div>
-					<canvas ref="pubDoughnutEl"></canvas>
-				</div>
-			</div>
-		</div>
-	</MkFolder>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	DoughnutController,
-} from 'chart.js';
-import MkSelect from '@/components/form/select.vue';
-import MkChart from '@/components/MkChart.vue';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
-import MkFolder from '@/components/MkFolder.vue';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	DoughnutController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
-
-const props = withDefaults(defineProps<{
-	chartLimit?: number;
-	detailed?: boolean;
-}>(), {
-	chartLimit: 90,
-});
-
-const chartSpan = $ref<'hour' | 'day'>('hour');
-const chartSrc = $ref('active-users');
-let subDoughnutEl = $ref<HTMLCanvasElement>();
-let pubDoughnutEl = $ref<HTMLCanvasElement>();
-
-const { handler: externalTooltipHandler1 } = useChartTooltip({
-	position: 'middle',
-});
-const { handler: externalTooltipHandler2 } = useChartTooltip({
-	position: 'middle',
-});
-
-function createDoughnut(chartEl, tooltip, data) {
-	const chartInstance = new Chart(chartEl, {
-		type: 'doughnut',
-		data: {
-			labels: data.map(x => x.name),
-			datasets: [{
-				backgroundColor: data.map(x => x.color),
-				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
-				borderWidth: 2,
-				hoverOffset: 0,
-				data: data.map(x => x.value),
-			}],
-		},
-		options: {
-			maintainAspectRatio: false,
-			layout: {
-				padding: {
-					left: 16,
-					right: 16,
-					top: 16,
-					bottom: 16,
-				},
-			},
-			onClick: (ev) => {
-				const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
-				if (hit && data[hit.index].onClick) {
-					data[hit.index].onClick();
-				}
-			},
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: tooltip,
-				},
-			},
-		},
-	});
-
-	return chartInstance;
-}
-
-onMounted(() => {
-	os.apiGet('federation/stats', { limit: 30 }).then(fedStats => {
-		createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followersCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }]));
-
-		createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followingCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }]));
-	});
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	&:global {
-		> .item {
-			margin-bottom: 16px;
-		}
-	}
-}
-
-.chart {
-	&:global {
-		> .selects {
-			display: flex;
-			margin-bottom: 12px;
-		}
-
-		> .chart {
-			padding: 16px;
-		}
-	}
-}
-
-.heatmap {
-	padding: 16px;
-	margin-bottom: 16px;
-}
-
-.federation {
-	&:global {
-		> .pies {
-			display: flex;
-			gap: 16px;
-
-			> .sub, > .pub {
-				flex: 1;
-				min-width: 0;
-				position: relative;
-				background: var(--panel);
-				border-radius: var(--radius);
-				padding: 24px;
-				max-height: 300px;
-
-				> .title {
-					position: absolute;
-					top: 24px;
-					left: 24px;
-				}
-			}
-
-			@media (max-width: 600px) {
-				flex-direction: column;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkInstanceTicker.vue b/packages/client/src/components/MkInstanceTicker.vue
deleted file mode 100644
index 646172fe8d..0000000000
--- a/packages/client/src/components/MkInstanceTicker.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<template>
-<div :class="$style.root" :style="bg">
-	<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
-	<div :class="$style.name">{{ instance.name }}</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { instanceName } from '@/config';
-import { instance as Instance } from '@/instance';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const props = defineProps<{
-	instance?: {
-		faviconUrl?: string
-		name: string
-		themeColor?: string
-	}
-}>();
-
-// if no instance data is given, this is for the local instance
-const instance = props.instance ?? {
-	name: instanceName,
-	themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
-};
-
-const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico');
-
-const themeColor = instance.themeColor ?? '#777777';
-
-const bg = {
-	background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
-};
-</script>
-
-<style lang="scss" module>
-$height: 2ex;
-
-.root {
-	display: flex;
-	align-items: center;
-	height: $height;
-	border-radius: 4px 0 0 4px;
-	overflow: clip;
-	color: #fff;
-	text-shadow: /* .866 ≈ sin(60deg) */
-		1px 0 1px #000,
-		.866px .5px 1px #000,
-		.5px .866px 1px #000,
-		0 1px 1px #000,
-		-.5px .866px 1px #000,
-		-.866px .5px 1px #000,
-		-1px 0 1px #000,
-		-.866px -.5px 1px #000,
-		-.5px -.866px 1px #000,
-		0 -1px 1px #000,
-		.5px -.866px 1px #000,
-		.866px -.5px 1px #000;
-	mask-image: linear-gradient(90deg,
-		rgb(0,0,0),
-		rgb(0,0,0) calc(100% - 16px),
-		rgba(0,0,0,0) 100%
-	);
-}
-
-.icon {
-	height: $height;
-	flex-shrink: 0;
-}
-
-.name {
-	margin-left: 4px;
-	line-height: 1;
-	font-size: 0.9em;
-	font-weight: bold;
-	white-space: nowrap;
-	overflow: visible;
-}
-</style>
diff --git a/packages/client/src/components/MkKeyValue.vue b/packages/client/src/components/MkKeyValue.vue
deleted file mode 100644
index ff69c79641..0000000000
--- a/packages/client/src/components/MkKeyValue.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<template>
-<div class="alqyeyti" :class="{ oneline }">
-	<div class="key">
-		<slot name="key"></slot>
-	</div>
-	<div class="value">
-		<slot name="value"></slot>
-		<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	copy?: string | null;
-	oneline?: boolean;
-}>(), {
-	copy: null,
-	oneline: false,
-});
-
-const copy_ = () => {
-	copyToClipboard(props.copy);
-	os.success();
-};
-</script>
-
-<style lang="scss" scoped>
-.alqyeyti {
-	> .key {
-		font-size: 0.85em;
-		padding: 0 0 0.25em 0;
-		opacity: 0.75;
-	}
-
-	&.oneline {
-		display: flex;
-
-		> .key {
-			width: 30%;
-			font-size: 1em;
-			padding: 0 8px 0 0;
-		}
-
-		> .value {
-			width: 70%;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkLaunchPad.vue b/packages/client/src/components/MkLaunchPad.vue
deleted file mode 100644
index 1ccc648c72..0000000000
--- a/packages/client/src/components/MkLaunchPad.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
-	<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
-		<div class="main">
-			<template v-for="item in items">
-				<button v-if="item.action" v-click-anime class="_button" @click="$event => { item.action($event); close(); }">
-					<i class="icon" :class="item.icon"></i>
-					<div class="text">{{ item.text }}</div>
-					<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-				</button>
-				<MkA v-else v-click-anime :to="item.to" @click.passive="close()">
-					<i class="icon" :class="item.icon"></i>
-					<div class="text">{{ item.text }}</div>
-					<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-				</MkA>
-			</template>
-		</div>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import { navbarItemDef } from '@/navbar';
-import { instanceName } from '@/config';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { deviceKind } from '@/scripts/device-kind';
-import * as os from '@/os';
-
-const props = withDefaults(defineProps<{
-	src?: HTMLElement;
-	anchor?: { x: string; y: string; };
-}>(), {
-	anchor: () => ({ x: 'right', y: 'center' }),
-});
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' :
-	deviceKind === 'smartphone' ? 'drawer' :
-	'dialog';
-
-const modal = $ref<InstanceType<typeof MkModal>>();
-
-const menu = defaultStore.state.menu;
-
-const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
-	type: def.to ? 'link' : 'button',
-	text: i18n.ts[def.title],
-	icon: def.icon,
-	to: def.to,
-	action: def.action,
-	indicate: def.indicated,
-}));
-
-function close() {
-	modal.close();
-}
-</script>
-
-<style lang="scss" scoped>
-.szkkfdyq {
-	max-height: 100%;
-	width: min(460px, 100vw);
-	padding: 24px;
-	box-sizing: border-box;
-	overflow: auto;
-	overscroll-behavior: contain;
-	text-align: left;
-	border-radius: 16px;
-
-	&.asDrawer {
-		width: 100%;
-		padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
-		border-radius: 24px;
-		border-bottom-right-radius: 0;
-		border-bottom-left-radius: 0;
-		text-align: center;
-	}
-
-	> .main, > .sub {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
-
-		> * {
-			position: relative;
-			display: flex;
-			flex-direction: column;
-			align-items: center;
-			justify-content: center;
-			vertical-align: bottom;
-			height: 100px;
-			border-radius: 10px;
-
-			&:hover {
-				color: var(--accent);
-				background: var(--accentedBg);
-				text-decoration: none;
-			}
-
-			> .icon {
-				font-size: 24px;
-				height: 24px;
-			}
-
-			> .text {
-				margin-top: 12px;
-				font-size: 0.8em;
-				line-height: 1.5em;
-			}
-
-			> .indicator {
-				position: absolute;
-				top: 32px;
-				left: 32px;
-				color: var(--indicator);
-				font-size: 8px;
-				animation: blink 1s infinite;
-
-				@media (max-width: 500px) {
-					top: 16px;
-					left: 16px;
-				}
-			}
-		}
-	}
-
-	> .sub {
-		margin-top: 8px;
-		padding-top: 8px;
-		border-top: solid 0.5px var(--divider);
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkLink.vue b/packages/client/src/components/MkLink.vue
deleted file mode 100644
index 6148ec6195..0000000000
--- a/packages/client/src/components/MkLink.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<component
-	:is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
-	:title="url"
->
-	<slot></slot>
-	<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
-</component>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import { url as local } from '@/config';
-import { useTooltip } from '@/scripts/use-tooltip';
-import * as os from '@/os';
-
-const props = withDefaults(defineProps<{
-	url: string;
-	rel?: null | string;
-}>(), {
-});
-
-const self = props.url.startsWith(local);
-const attr = self ? 'to' : 'href';
-const target = self ? null : '_blank';
-
-const el = $ref();
-
-useTooltip($$(el), (showing) => {
-	os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
-		showing,
-		url: props.url,
-		source: el,
-	}, {}, 'closed');
-});
-</script>
-
-<style lang="scss" scoped>
-.xlcxczvw {
-	word-break: break-all;
-
-	> .icon {
-		padding-left: 2px;
-		font-size: .9em;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMarquee.vue b/packages/client/src/components/MkMarquee.vue
deleted file mode 100644
index 5ca04b0b48..0000000000
--- a/packages/client/src/components/MkMarquee.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script lang="ts">
-import { h, onMounted, onUnmounted, ref, watch } from 'vue';
-
-export default {
-	name: 'MarqueeText',
-	props: {
-		duration: {
-			type: Number,
-			default: 15,
-		},
-		repeat: {
-			type: Number,
-			default: 2,
-		},
-		paused: {
-			type: Boolean,
-			default: false,
-		},
-		reverse: {
-			type: Boolean,
-			default: false,
-		},
-	},
-	setup(props) {
-		const contentEl = ref();
-
-		function calc() {
-			const eachLength = contentEl.value.offsetWidth / props.repeat;
-			const factor = 3000;
-			const duration = props.duration / ((1 / eachLength) * factor);
-
-			contentEl.value.style.animationDuration = `${duration}s`;
-		}
-
-		watch(() => props.duration, calc);
-
-		onMounted(() => {
-			calc();
-		});
-
-		onUnmounted(() => {
-		});
-
-		return {
-			contentEl,
-		};
-	},
-	render({
-		$slots, $style, $props: {
-			duration, repeat, paused, reverse,
-		},
-	}) {
-		return h('div', { class: [$style.wrap] }, [
-			h('span', {
-				ref: 'contentEl',
-				class: [
-					paused
-						? $style.paused
-						: undefined,
-					$style.content,
-				],
-			}, Array(repeat).fill(
-				h('span', {
-					class: $style.text,
-					style: {
-						animationDirection: reverse
-							? 'reverse'
-							: undefined,
-					},
-				}, $slots.default()),
-			)),
-		]);
-	},
-};
-</script>
-
-<style lang="scss" module>
-.wrap {
-	overflow: clip;
-	animation-play-state: running;
-
-	&:hover {
-		animation-play-state: paused;
-	}
-}
-.content {
-	display: inline-block;
-	white-space: nowrap;
-	animation-play-state: inherit;
-}
-.text {
-	display: inline-block;
-	animation-name: marquee;
-	animation-timing-function: linear;
-	animation-iteration-count: infinite;
-	animation-duration: inherit;
-	animation-play-state: inherit;
-}
-.paused .text {
-	animation-play-state: paused;
-}
-@keyframes marquee {
-	0% { transform:translateX(0); }
-	100% { transform:translateX(-100%); }
-}
-</style>
diff --git a/packages/client/src/components/MkMediaBanner.vue b/packages/client/src/components/MkMediaBanner.vue
deleted file mode 100644
index aa06c00fc6..0000000000
--- a/packages/client/src/components/MkMediaBanner.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<template>
-<div class="mk-media-banner">
-	<div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false">
-		<span class="icon"><i class="ti ti-alert-triangle"></i></span>
-		<b>{{ $ts.sensitive }}</b>
-		<span>{{ $ts.clickToShow }}</span>
-	</div>
-	<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
-		<audio
-			ref="audioEl"
-			class="audio"
-			:src="media.url"
-			:title="media.name"
-			controls
-			preload="metadata"
-			@volumechange="volumechange"
-		/>
-	</div>
-	<a
-		v-else class="download"
-		:href="media.url"
-		:title="media.name"
-		:download="media.name"
-	>
-		<span class="icon"><i class="ti ti-download"></i></span>
-		<b>{{ media.name }}</b>
-	</a>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as misskey from 'misskey-js';
-import { ColdDeviceStorage } from '@/store';
-
-const props = withDefaults(defineProps<{
-	media: misskey.entities.DriveFile;
-}>(), {
-});
-
-const audioEl = $ref<HTMLAudioElement | null>();
-let hide = $ref(true);
-
-function volumechange() {
-	if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
-}
-
-onMounted(() => {
-	if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-media-banner {
-	width: 100%;
-	border-radius: 4px;
-	margin-top: 4px;
-	overflow: hidden;
-
-	> .download,
-	> .sensitive {
-		display: flex;
-		align-items: center;
-		font-size: 12px;
-		padding: 8px 12px;
-		white-space: nowrap;
-
-		> * {
-			display: block;
-		}
-
-		> b {
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-
-		> *:not(:last-child) {
-			margin-right: .2em;
-		}
-
-		> .icon {
-			font-size: 1.6em;
-		}
-	}
-
-	> .download {
-		background: var(--noteAttachedFile);
-	}
-
-	> .sensitive {
-		background: #111;
-		color: #fff;
-	}
-
-	> .audio {
-		.audio {
-			display: block;
-			width: 100%;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMediaImage.vue b/packages/client/src/components/MkMediaImage.vue
deleted file mode 100644
index 56570eaa05..0000000000
--- a/packages/client/src/components/MkMediaImage.vue
+++ /dev/null
@@ -1,130 +0,0 @@
-<template>
-<div v-if="hide" class="qjewsnkg" @click="hide = false">
-	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
-	<div class="text">
-		<div class="wrapper">
-			<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
-			<span style="display: block;">{{ $ts.clickToShow }}</span>
-		</div>
-	</div>
-</div>
-<div v-else class="gqnyydlz">
-	<a
-		:href="image.url"
-		:title="image.name"
-	>
-		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
-		<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
-	</a>
-	<button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { watch } from 'vue';
-import * as misskey from 'misskey-js';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store';
-
-const props = defineProps<{
-	image: misskey.entities.DriveFile;
-	raw?: boolean;
-}>();
-
-let hide = $ref(true);
-
-const url = (props.raw || defaultStore.state.loadRawImages)
-	? props.image.url
-	: defaultStore.state.disableShowingAnimatedImages
-		? getStaticImageUrl(props.image.thumbnailUrl)
-		: props.image.thumbnailUrl;
-
-// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
-watch(() => props.image, () => {
-	hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore');
-}, {
-	deep: true,
-	immediate: true,
-});
-</script>
-
-<style lang="scss" scoped>
-.qjewsnkg {
-	position: relative;
-
-	> .bg {
-		filter: brightness(0.5);
-	}
-
-	> .text {
-		position: absolute;
-		left: 0;
-		top: 0;
-		width: 100%;
-		height: 100%;
-		z-index: 1;
-		display: flex;
-		justify-content: center;
-		align-items: center;
-
-		> .wrapper {
-			display: table-cell;
-			text-align: center;
-			font-size: 0.8em;
-			color: #fff;
-		}
-	}
-}
-
-.gqnyydlz {
-	position: relative;
-	//box-shadow: 0 0 0 1px var(--divider) inset;
-	background: var(--bg);
-
-	> .hide {
-		display: block;
-		position: absolute;
-		border-radius: 6px;
-		background-color: var(--accentedBg);
-		-webkit-backdrop-filter: var(--blur, blur(15px));
-		backdrop-filter: var(--blur, blur(15px));
-		color: var(--accent);
-		font-size: 0.8em;
-		padding: 6px 8px;
-		text-align: center;
-		top: 12px;
-		right: 12px;
-
-		> i {
-			display: block;
-		}
-	}
-
-	> a {
-		display: block;
-		cursor: zoom-in;
-		overflow: hidden;
-		width: 100%;
-		height: 100%;
-		background-position: center;
-		background-size: contain;
-		background-repeat: no-repeat;
-
-		> .gif {
-			background-color: var(--fg);
-			border-radius: 6px;
-			color: var(--accentLighten);
-			display: inline-block;
-			font-size: 14px;
-			font-weight: bold;
-			left: 12px;
-			opacity: .5;
-			padding: 0 6px;
-			text-align: center;
-			top: 12px;
-			pointer-events: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMediaList.vue b/packages/client/src/components/MkMediaList.vue
deleted file mode 100644
index c6f8612182..0000000000
--- a/packages/client/src/components/MkMediaList.vue
+++ /dev/null
@@ -1,189 +0,0 @@
-<template>
-<div class="hoawjimk">
-	<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
-	<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
-		<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length">
-			<template v-for="media in mediaList.filter(media => previewable(media))">
-				<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
-				<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
-			</template>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
-import * as misskey from 'misskey-js';
-import PhotoSwipeLightbox from 'photoswipe/lightbox';
-import PhotoSwipe from 'photoswipe';
-import 'photoswipe/style.css';
-import XBanner from '@/components/MkMediaBanner.vue';
-import XImage from '@/components/MkMediaImage.vue';
-import XVideo from '@/components/MkMediaVideo.vue';
-import * as os from '@/os';
-import { FILE_TYPE_BROWSERSAFE } from '@/const';
-import { defaultStore } from '@/store';
-
-const props = defineProps<{
-	mediaList: misskey.entities.DriveFile[];
-	raw?: boolean;
-}>();
-
-const gallery = ref(null);
-const pswpZIndex = os.claimZIndex('middle');
-
-onMounted(() => {
-	const lightbox = new PhotoSwipeLightbox({
-		dataSource: props.mediaList
-			.filter(media => {
-				if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
-				return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
-			})
-			.map(media => {
-				const item = {
-					src: media.url,
-					w: media.properties.width,
-					h: media.properties.height,
-					alt: media.name,
-				};
-				if (media.properties.orientation != null && media.properties.orientation >= 5) {
-					[item.w, item.h] = [item.h, item.w];
-				}
-				return item;
-			}),
-		gallery: gallery.value,
-		children: '.image',
-		thumbSelector: '.image',
-		loop: false,
-		padding: window.innerWidth > 500 ? {
-			top: 32,
-			bottom: 32,
-			left: 32,
-			right: 32,
-		} : {
-			top: 0,
-			bottom: 0,
-			left: 0,
-			right: 0,
-		},
-		imageClickAction: 'close',
-		tapAction: 'toggle-controls',
-		pswpModule: PhotoSwipe,
-	});
-
-	lightbox.on('itemData', (ev) => {
-		const { itemData } = ev;
-
-		// element is children
-		const { element } = itemData;
-
-		const id = element.dataset.id;
-		const file = props.mediaList.find(media => media.id === id);
-
-		itemData.src = file.url;
-		itemData.w = Number(file.properties.width);
-		itemData.h = Number(file.properties.height);
-		if (file.properties.orientation != null && file.properties.orientation >= 5) {
-			[itemData.w, itemData.h] = [itemData.h, itemData.w];
-		}
-		itemData.msrc = file.thumbnailUrl;
-		itemData.thumbCropped = true;
-	});
-
-	lightbox.init();
-});
-
-const previewable = (file: misskey.entities.DriveFile): boolean => {
-	if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
-	// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
-	return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
-};
-</script>
-
-<style lang="scss" scoped>
-.hoawjimk {
-	> .gird-container {
-		position: relative;
-		width: 100%;
-		margin-top: 4px;
-
-		&:before {
-			content: '';
-			display: block;
-			padding-top: 56.25% // 16:9;
-		}
-
-		> div {
-			position: absolute;
-			top: 0;
-			right: 0;
-			bottom: 0;
-			left: 0;
-			display: grid;
-			grid-gap: 8px;
-
-			> * {
-				overflow: hidden;
-				border-radius: 6px;
-			}
-
-			&[data-count="1"] {
-				grid-template-rows: 1fr;
-			}
-
-			&[data-count="2"] {
-				grid-template-columns: 1fr 1fr;
-				grid-template-rows: 1fr;
-			}
-
-			&[data-count="3"] {
-				grid-template-columns: 1fr 0.5fr;
-				grid-template-rows: 1fr 1fr;
-
-				> *:nth-child(1) {
-					grid-row: 1 / 3;
-				}
-
-				> *:nth-child(3) {
-					grid-column: 2 / 3;
-					grid-row: 2 / 3;
-				}
-			}
-
-			&[data-count="4"] {
-				grid-template-columns: 1fr 1fr;
-				grid-template-rows: 1fr 1fr;
-			}
-
-			> *:nth-child(1) {
-				grid-column: 1 / 2;
-				grid-row: 1 / 2;
-			}
-
-			> *:nth-child(2) {
-				grid-column: 2 / 3;
-				grid-row: 1 / 2;
-			}
-
-			> *:nth-child(3) {
-				grid-column: 1 / 2;
-				grid-row: 2 / 3;
-			}
-
-			> *:nth-child(4) {
-				grid-column: 2 / 3;
-				grid-row: 2 / 3;
-			}
-		}
-	}
-}
-</style>
-
-<style lang="scss">
-.pswp {
-	// なぜか機能しない
-  //z-index: v-bind(pswpZIndex);
-	z-index: 2000000;
-}
-</style>
diff --git a/packages/client/src/components/MkMediaVideo.vue b/packages/client/src/components/MkMediaVideo.vue
deleted file mode 100644
index df0bf84116..0000000000
--- a/packages/client/src/components/MkMediaVideo.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-<template>
-<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false">
-	<div>
-		<b><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
-		<span>{{ $ts.clickToShow }}</span>
-	</div>
-</div>
-<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
-	<video
-		:poster="video.thumbnailUrl"
-		:title="video.comment"
-		:alt="video.comment"
-		preload="none"
-		controls
-		@contextmenu.stop
-	>
-		<source 
-			:src="video.url" 
-			:type="video.type"
-		>
-	</video>
-	<i class="ti ti-eye-off" @click="hide = true"></i>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import * as misskey from 'misskey-js';
-import { defaultStore } from '@/store';
-
-const props = defineProps<{
-	video: misskey.entities.DriveFile;
-}>();
-
-const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore'));
-</script>
-
-<style lang="scss" scoped>
-.kkjnbbplepmiyuadieoenjgutgcmtsvu {
-	position: relative;
-
-	> i {
-		display: block;
-		position: absolute;
-		border-radius: 6px;
-		background-color: var(--fg);
-		color: var(--accentLighten);
-		font-size: 14px;
-		opacity: .5;
-		padding: 3px 6px;
-		text-align: center;
-		cursor: pointer;
-		top: 12px;
-		right: 12px;
-	}
-
-	> video {
-		display: flex;
-		justify-content: center;
-		align-items: center;
-
-		font-size: 3.5em;
-		overflow: hidden;
-		background-position: center;
-		background-size: cover;
-		width: 100%;
-		height: 100%;
-	}
-}
-
-.icozogqfvdetwohsdglrbswgrejoxbdj {
-	display: flex;
-	justify-content: center;
-	align-items: center;
-	background: #111;
-	color: #fff;
-
-	> div {
-		display: table-cell;
-		text-align: center;
-		font-size: 12px;
-
-		> b {
-			display: block;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMention.vue b/packages/client/src/components/MkMention.vue
deleted file mode 100644
index 3091b435e4..0000000000
--- a/packages/client/src/components/MkMention.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }">
-	<img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
-	<span class="main">
-		<span class="username">@{{ username }}</span>
-		<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
-	</span>
-</MkA>
-<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
-	<span class="main">
-		<span class="username">@{{ username }}</span>
-		<span class="host">@{{ toUnicode(host) }}</span>
-	</span>
-</a>
-</template>
-
-<script lang="ts" setup>
-import { toUnicode } from 'punycode';
-import { } from 'vue';
-import tinycolor from 'tinycolor2';
-import { host as localHost } from '@/config';
-import { $i } from '@/account';
-
-const props = defineProps<{
-	username: string;
-	host: string;
-}>();
-
-const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
-
-const url = `/${canonical}`;
-
-const isMe = $i && (
-	`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
-);
-
-const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
-bg.setAlpha(0.1);
-const bgCss = bg.toRgbString();
-</script>
-
-<style lang="scss" scoped>
-.akbvjaqn {
-	display: inline-block;
-	padding: 4px 8px 4px 4px;
-	border-radius: 999px;
-	color: var(--mention);
-
-	&.isMe {
-		color: var(--mentionMe);
-	}
-
-	> .icon {
-		width: 1.5em;
-		height: 1.5em;
-		object-fit: cover;
-		margin: 0 0.2em 0 0;
-		vertical-align: bottom;
-		border-radius: 100%;
-	}
-
-	> .main > .host {
-		opacity: 0.5;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMenu.child.vue b/packages/client/src/components/MkMenu.child.vue
deleted file mode 100644
index 3ada4afbdc..0000000000
--- a/packages/client/src/components/MkMenu.child.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<template>
-<div ref="el" class="sfhdhdhr">
-	<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { on } from 'events';
-import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue';
-import MkMenu from './MkMenu.vue';
-import { MenuItem } from '@/types/menu';
-import * as os from '@/os';
-
-const props = defineProps<{
-	items: MenuItem[];
-	targetElement: HTMLElement;
-	rootElement: HTMLElement;
-	width?: number;
-	viaKeyboard?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-	(ev: 'actioned'): void;
-}>();
-
-const el = ref<HTMLElement>();
-const align = 'left';
-
-function setPosition() {
-	const rootRect = props.rootElement.getBoundingClientRect();
-	const rect = props.targetElement.getBoundingClientRect();
-	const left = props.targetElement.offsetWidth;
-	const top = (rect.top - rootRect.top) - 8;
-	el.value.style.left = left + 'px';
-	el.value.style.top = top + 'px';
-}
-
-function onChildClosed(actioned?: boolean) {
-	if (actioned) {
-		emit('actioned');
-	} else {
-		emit('closed');
-	}
-}
-
-onMounted(() => {
-	setPosition();
-	nextTick(() => {
-		setPosition();
-	});
-});
-
-defineExpose({
-	checkHit: (ev: MouseEvent) => {
-		return (ev.target === el.value || el.value.contains(ev.target));
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.sfhdhdhr {
-	position: absolute;
-}
-</style>
diff --git a/packages/client/src/components/MkMenu.vue b/packages/client/src/components/MkMenu.vue
deleted file mode 100644
index 64d18b6b7c..0000000000
--- a/packages/client/src/components/MkMenu.vue
+++ /dev/null
@@ -1,367 +0,0 @@
-<template>
-<div>
-	<div
-		ref="itemsEl" v-hotkey="keymap"
-		class="rrevdjwt _popup _shadow"
-		:class="{ center: align === 'center', asDrawer }"
-		:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
-		@contextmenu.self="e => e.preventDefault()"
-	>
-		<template v-for="(item, i) in items2">
-			<div v-if="item === null" class="divider"></div>
-			<span v-else-if="item.type === 'label'" class="label item">
-				<span>{{ item.text }}</span>
-			</span>
-			<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
-				<span><MkEllipsis/></span>
-			</span>
-			<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
-				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
-				<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
-				<span>{{ item.text }}</span>
-				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-			</MkA>
-			<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
-				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
-				<span>{{ item.text }}</span>
-				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-			</a>
-			<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
-				<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
-				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-			</button>
-			<span v-else-if="item.type === 'switch'" :tabindex="i" class="item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
-				<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
-			</span>
-			<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button item parent" :class="{ childShowing: childShowingItem === item }" @mouseenter="showChildren(item, $event)">
-				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
-				<span>{{ item.text }}</span>
-				<span class="caret"><i class="ti ti-caret-right ti-fw"></i></span>
-			</button>
-			<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
-				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
-				<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
-				<span>{{ item.text }}</span>
-				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
-			</button>
-		</template>
-		<span v-if="items2.length === 0" class="none item">
-			<span>{{ i18n.ts.none }}</span>
-		</span>
-	</div>
-	<div v-if="childMenu" class="child">
-		<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
-import { focusPrev, focusNext } from '@/scripts/focus';
-import FormSwitch from '@/components/form/switch.vue';
-import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
-
-const props = defineProps<{
-	items: MenuItem[];
-	viaKeyboard?: boolean;
-	asDrawer?: boolean;
-	align?: 'center' | string;
-	width?: number;
-	maxHeight?: number;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'close', actioned?: boolean): void;
-}>();
-
-let itemsEl = $ref<HTMLDivElement>();
-
-let items2: InnerMenuItem[] = $ref([]);
-
-let child = $ref<InstanceType<typeof XChild>>();
-
-let keymap = $computed(() => ({
-	'up|k|shift+tab': focusUp,
-	'down|j|tab': focusDown,
-	'esc': close,
-}));
-
-let childShowingItem = $ref<MenuItem | null>();
-
-watch(() => props.items, () => {
-	const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined);
-
-	for (let i = 0; i < items.length; i++) {
-		const item = items[i];
-
-		if (item && 'then' in item) { // if item is Promise
-			items[i] = { type: 'pending' };
-			item.then(actualItem => {
-				items2[i] = actualItem;
-			});
-		}
-	}
-
-	items2 = items as InnerMenuItem[];
-}, {
-	immediate: true,
-});
-
-let childMenu = $ref<MenuItem[] | null>();
-let childTarget = $ref<HTMLElement | null>();
-
-function closeChild() {
-	childMenu = null;
-	childShowingItem = null;
-}
-
-function childActioned() {
-	closeChild();
-	close(true);
-}
-
-function onGlobalMousedown(event: MouseEvent) {
-	if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return;
-	if (child && child.checkHit(event)) return;
-	closeChild();
-}
-
-let childCloseTimer: null | number = null;
-function onItemMouseEnter(item) {
-	childCloseTimer = window.setTimeout(() => {
-		closeChild();
-	}, 300);
-}
-function onItemMouseLeave(item) {
-	if (childCloseTimer) window.clearTimeout(childCloseTimer);
-}
-
-async function showChildren(item: MenuItem, ev: MouseEvent) {
-	if (props.asDrawer) {
-		os.popupMenu(item.children, ev.currentTarget ?? ev.target);
-		close();
-	} else {
-		childTarget = ev.currentTarget ?? ev.target;
-		childMenu = item.children;
-		childShowingItem = item;
-	}
-}
-
-function clicked(fn: MenuAction, ev: MouseEvent) {
-	fn(ev);
-	close(true);
-}
-
-function close(actioned = false) {
-	emit('close', actioned);
-}
-
-function focusUp() {
-	focusPrev(document.activeElement);
-}
-
-function focusDown() {
-	focusNext(document.activeElement);
-}
-
-onMounted(() => {
-	if (props.viaKeyboard) {
-		nextTick(() => {
-			focusNext(itemsEl.children[0], true, false);
-		});
-	}
-
-	// TODO: アクティブな要素までスクロール
-	//itemsEl.scrollTo();
-
-	document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
-});
-
-onBeforeUnmount(() => {
-	document.removeEventListener('mousedown', onGlobalMousedown);
-});
-</script>
-
-<style lang="scss" scoped>
-.rrevdjwt {
-	padding: 8px 0;
-	box-sizing: border-box;
-	min-width: 200px;
-	overflow: auto;
-	overscroll-behavior: contain;
-
-	&.center {
-		> .item {
-			text-align: center;
-		}
-	}
-
-	> .item {
-		display: block;
-		position: relative;
-		padding: 6px 16px;
-		width: 100%;
-		box-sizing: border-box;
-		white-space: nowrap;
-		font-size: 0.9em;
-		line-height: 20px;
-		text-align: left;
-		overflow: hidden;
-		text-overflow: ellipsis;
-
-		&:before {
-			content: "";
-			display: block;
-			position: absolute;
-			top: 0;
-			left: 0;
-			right: 0;
-			margin: auto;
-			width: calc(100% - 16px);
-			height: 100%;
-			border-radius: 6px;
-		}
-
-		> * {
-			position: relative;
-		}
-
-		&:not(:disabled):hover {
-			color: var(--accent);
-			text-decoration: none;
-
-			&:before {
-				background: var(--accentedBg);
-			}
-		}
-
-		&.danger {
-			color: #ff2a2a;
-
-			&:hover {
-				color: #fff;
-
-				&:before {
-					background: #ff4242;
-				}
-			}
-
-			&:active {
-				color: #fff;
-
-				&:before {
-					background: #d42e2e;
-				}
-			}
-		}
-
-		&.active {
-			color: var(--fgOnAccent);
-			opacity: 1;
-
-			&:before {
-				background: var(--accent);
-			}
-		}
-
-		&:not(:active):focus-visible {
-			box-shadow: 0 0 0 2px var(--focus) inset;
-		}
-
-		&.label {
-			pointer-events: none;
-			font-size: 0.7em;
-			padding-bottom: 4px;
-
-			> span {
-				opacity: 0.7;
-			}
-		}
-
-		&.pending {
-			pointer-events: none;
-			opacity: 0.7;
-		}
-
-		&.none {
-			pointer-events: none;
-			opacity: 0.7;
-		}
-
-		&.parent {
-			display: flex;
-			align-items: center;
-			cursor: default;
-
-			> .caret {
-				margin-left: auto;
-			}
-
-			&.childShowing {
-				color: var(--accent);
-				text-decoration: none;
-
-				&:before {
-					background: var(--accentedBg);
-				}
-			}
-		}
-
-		> i {
-			margin-right: 5px;
-			width: 20px;
-		}
-
-		> .avatar {
-			margin-right: 5px;
-			width: 20px;
-			height: 20px;
-		}
-
-		> .indicator {
-			position: absolute;
-			top: 5px;
-			left: 13px;
-			color: var(--indicator);
-			font-size: 12px;
-			animation: blink 1s infinite;
-		}
-	}
-
-	> .divider {
-		margin: 8px 0;
-		border-top: solid 0.5px var(--divider);
-	}
-
-	&.asDrawer {
-		padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 12px) 0;
-		width: 100%;
-		border-radius: 24px;
-		border-bottom-right-radius: 0;
-		border-bottom-left-radius: 0;
-
-		> .item {
-			font-size: 1em;
-			padding: 12px 24px;
-
-			&:before {
-				width: calc(100% - 24px);
-				border-radius: 12px;
-			}
-
-			> i {
-				margin-right: 14px;
-				width: 24px;
-			}
-		}
-
-		> .divider {
-			margin: 12px 0;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkMiniChart.vue b/packages/client/src/components/MkMiniChart.vue
deleted file mode 100644
index c64ce163f9..0000000000
--- a/packages/client/src/components/MkMiniChart.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<template>
-<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible">
-	<defs>
-		<linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
-			<stop offset="0%" :stop-color="color" stop-opacity="0"></stop>
-			<stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop>
-		</linearGradient>
-	</defs>
-	<polygon
-		:points="polygonPoints"
-		:style="`stroke: none; fill: url(#${ gradientId });`"
-	/>
-	<polyline
-		:points="polylinePoints"
-		fill="none"
-		:stroke="color"
-		stroke-width="2"
-	/>
-	<circle
-		:cx="headX"
-		:cy="headY"
-		r="3"
-		:fill="color"
-	/>
-</svg>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
-import tinycolor from 'tinycolor2';
-import { useInterval } from '@/scripts/use-interval';
-
-const props = defineProps<{
-	src: number[];
-}>();
-
-const viewBoxX = 50;
-const viewBoxY = 50;
-const gradientId = uuid();
-let polylinePoints = $ref('');
-let polygonPoints = $ref('');
-let headX = $ref<number | null>(null);
-let headY = $ref<number | null>(null);
-let clock = $ref<number | null>(null);
-const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent'));
-const color = accent.toRgbString();
-
-function draw(): void {
-	const stats = props.src.slice().reverse();
-	const peak = Math.max.apply(null, stats) || 1;
-
-	const _polylinePoints = stats.map((n, i) => [
-		i * (viewBoxX / (stats.length - 1)),
-		(1 - (n / peak)) * viewBoxY,
-	]);
-
-	polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
-
-	polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`;
-
-	headX = _polylinePoints[_polylinePoints.length - 1][0];
-	headY = _polylinePoints[_polylinePoints.length - 1][1];
-}
-
-watch(() => props.src, draw, { immediate: true });
-
-// Vueが何故かWatchを発動させない場合があるので
-useInterval(draw, 1000, {
-	immediate: false,
-	afterMounted: true,
-});
-</script>
diff --git a/packages/client/src/components/MkModal.vue b/packages/client/src/components/MkModal.vue
deleted file mode 100644
index 2305a02794..0000000000
--- a/packages/client/src/components/MkModal.vue
+++ /dev/null
@@ -1,406 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
-	<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
-		<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
-		<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
-			<slot :max-height="maxHeight" :type="type"></slot>
-		</div>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted, watch, provide } from 'vue';
-import * as os from '@/os';
-import { isTouchUsing } from '@/scripts/touch';
-import { defaultStore } from '@/store';
-import { deviceKind } from '@/scripts/device-kind';
-
-function getFixedContainer(el: Element | null): Element | null {
-	if (el == null || el.tagName === 'BODY') return null;
-	const position = window.getComputedStyle(el).getPropertyValue('position');
-	if (position === 'fixed') {
-		return el;
-	} else {
-		return getFixedContainer(el.parentElement);
-	}
-}
-
-type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer';
-
-const props = withDefaults(defineProps<{
-	manualShowing?: boolean | null;
-	anchor?: { x: string; y: string; };
-	src?: HTMLElement;
-	preferType?: ModalTypes | 'auto';
-	zPriority?: 'low' | 'middle' | 'high';
-	noOverlap?: boolean;
-	transparentBg?: boolean;
-}>(), {
-	manualShowing: null,
-	src: null,
-	anchor: () => ({ x: 'center', y: 'bottom' }),
-	preferType: 'auto',
-	zPriority: 'low',
-	noOverlap: true,
-	transparentBg: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'opening'): void;
-	(ev: 'opened'): void;
-	(ev: 'click'): void;
-	(ev: 'esc'): void;
-	(ev: 'close'): void;
-	(ev: 'closed'): void;
-}>();
-
-provide('modal', true);
-
-let maxHeight = $ref<number>();
-let fixed = $ref(false);
-let transformOrigin = $ref('center');
-let showing = $ref(true);
-let content = $ref<HTMLElement>();
-const zIndex = os.claimZIndex(props.zPriority);
-const type = $computed(() => {
-	if (props.preferType === 'auto') {
-		if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
-			return 'drawer';
-		} else {
-			return props.src != null ? 'popup' : 'dialog';
-		}
-	} else {
-		return props.preferType!;
-	}
-});
-
-let contentClicking = false;
-
-const close = () => {
-	// eslint-disable-next-line vue/no-mutating-props
-	if (props.src) props.src.style.pointerEvents = 'auto';
-	showing = false;
-	emit('close');
-};
-
-const onBgClick = () => {
-	if (contentClicking) return;
-	emit('click');
-};
-
-if (type === 'drawer') {
-	maxHeight = window.innerHeight / 1.5;
-}
-
-const keymap = {
-	'esc': () => emit('esc'),
-};
-
-const MARGIN = 16;
-
-const align = () => {
-	if (props.src == null) return;
-	if (type === 'drawer') return;
-	if (type === 'dialog') return;
-
-	if (content == null) return;
-
-	const srcRect = props.src.getBoundingClientRect();
-
-	const width = content!.offsetWidth;
-	const height = content!.offsetHeight;
-
-	let left;
-	let top;
-
-	const x = srcRect.left + (fixed ? 0 : window.pageXOffset);
-	const y = srcRect.top + (fixed ? 0 : window.pageYOffset);
-
-	if (props.anchor.x === 'center') {
-		left = x + (props.src.offsetWidth / 2) - (width / 2);
-	} else if (props.anchor.x === 'left') {
-		// TODO
-	} else if (props.anchor.x === 'right') {
-		left = x + props.src.offsetWidth;
-	}
-
-	if (props.anchor.y === 'center') {
-		top = (y - (height / 2));
-	} else if (props.anchor.y === 'top') {
-		// TODO
-	} else if (props.anchor.y === 'bottom') {
-		top = y + props.src.offsetHeight;
-	}
-
-	if (fixed) {
-		// 画面から横にはみ出る場合
-		if (left + width > window.innerWidth) {
-			left = window.innerWidth - width;
-		}
-
-		const underSpace = (window.innerHeight - MARGIN) - top;
-		const upperSpace = (srcRect.top - MARGIN);
-
-		// 画面から縦にはみ出る場合
-		if (top + height > (window.innerHeight - MARGIN)) {
-			if (props.noOverlap && props.anchor.x === 'center') {
-				if (underSpace >= (upperSpace / 3)) {
-					maxHeight = underSpace;
-				} else {
-					maxHeight = upperSpace;
-					top = (upperSpace + MARGIN) - height;
-				}
-			} else {
-				top = (window.innerHeight - MARGIN) - height;
-			}
-		} else {
-			maxHeight = underSpace;
-		}
-	} else {
-		// 画面から横にはみ出る場合
-		if (left + width - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - width + window.pageXOffset - 1;
-		}
-
-		const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset);
-		const upperSpace = (srcRect.top - MARGIN);
-
-		// 画面から縦にはみ出る場合
-		if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
-			if (props.noOverlap && props.anchor.x === 'center') {
-				if (underSpace >= (upperSpace / 3)) {
-					maxHeight = underSpace;
-				} else {
-					maxHeight = upperSpace;
-					top = window.pageYOffset + ((upperSpace + MARGIN) - height);
-				}
-			} else {
-				top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1;
-			}
-		} else {
-			maxHeight = underSpace;
-		}
-	}
-
-	if (top < 0) {
-		top = MARGIN;
-	}
-
-	if (left < 0) {
-		left = 0;
-	}
-
-	let transformOriginX = 'center';
-	let transformOriginY = 'center';
-
-	if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) {
-		transformOriginY = 'top';
-	} else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) {
-		transformOriginY = 'bottom';
-	}
-
-	if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) {
-		transformOriginX = 'left';
-	} else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) {
-		transformOriginX = 'right';
-	}
-
-	transformOrigin = `${transformOriginX} ${transformOriginY}`;
-
-	content.style.left = left + 'px';
-	content.style.top = top + 'px';
-};
-
-const onOpened = () => {
-	emit('opened');
-
-	// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
-	const el = content!.children[0];
-	el.addEventListener('mousedown', ev => {
-		contentClicking = true;
-		window.addEventListener('mouseup', ev => {
-			// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
-			window.setTimeout(() => {
-				contentClicking = false;
-			}, 100);
-		}, { passive: true, once: true });
-	}, { passive: true });
-};
-
-onMounted(() => {
-	watch(() => props.src, async () => {
-		if (props.src) {
-			// eslint-disable-next-line vue/no-mutating-props
-			props.src.style.pointerEvents = 'none';
-		}
-		fixed = (type === 'drawer') || (getFixedContainer(props.src) != null);
-
-		await nextTick();
-		
-		align();
-	}, { immediate: true });
-
-	nextTick(() => {
-		new ResizeObserver((entries, observer) => {
-			align();
-		}).observe(content!);
-	});
-});
-
-defineExpose({
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.modal-enter-active, .modal-leave-active {
-	> .bg {
-		transition: opacity 0.2s !important;
-	}
-
-	> .content {
-		transform-origin: var(--transformOrigin);
-		transition: opacity 0.2s, transform 0.2s !important;
-	}
-}
-.modal-enter-from, .modal-leave-to {
-	> .bg {
-		opacity: 0;
-	}
-
-	> .content {
-		pointer-events: none;
-		opacity: 0;
-		transform-origin: var(--transformOrigin);
-		transform: scale(0.9);
-	}
-}
-
-.modal-popup-enter-active, .modal-popup-leave-active {
-	> .bg {
-		transition: opacity 0.2s !important;
-	}
-
-	> .content {
-		transform-origin: var(--transformOrigin);
-		transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
-	}
-}
-.modal-popup-enter-from, .modal-popup-leave-to {
-	> .bg {
-		opacity: 0;
-	}
-
-	> .content {
-		pointer-events: none;
-		opacity: 0;
-		transform-origin: var(--transformOrigin);
-		transform: scale(0.9);
-	}
-}
-
-.modal-drawer-enter-active {
-	> .bg {
-		transition: opacity 0.2s !important;
-	}
-
-	> .content {
-		transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
-	}
-}
-.modal-drawer-leave-active {
-	> .bg {
-		transition: opacity 0.2s !important;
-	}
-
-	> .content {
-		transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
-	}
-}
-.modal-drawer-enter-from, .modal-drawer-leave-to {
-	> .bg {
-		opacity: 0;
-	}
-
-	> .content {
-		pointer-events: none;
-		transform: translateY(100%);
-	}
-}
-
-.qzhlnise {
-	> .bg {
-		&.transparent {
-			background: transparent;
-			-webkit-backdrop-filter: none;
-			backdrop-filter: none;
-		}
-	}
-
-	&.dialog {
-		> .content {
-			position: fixed;
-			top: 0;
-			bottom: 0;
-			left: 0;
-			right: 0;
-			margin: auto;
-			padding: 32px;
-			// TODO: mask-imageはiOSだとやたら重い。なんとかしたい
-			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
-			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
-			overflow: auto;
-			display: flex;
-
-			@media (max-width: 500px) {
-				padding: 16px;
-				-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
-				mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
-			}
-
-			> ::v-deep(*) {
-				margin: auto;
-			}
-
-			&.top {
-				> ::v-deep(*) {
-					margin-top: 0;
-				}
-			}
-		}
-	}
-
-	&.popup {
-		> .content {
-			position: absolute;
-
-			&.fixed {
-				position: fixed;
-			}
-		}
-	}
-
-	&.drawer {
-		position: fixed;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		overflow: clip;
-
-		> .content {
-			position: fixed;
-			bottom: 0;
-			left: 0;
-			right: 0;
-			margin: auto;
-
-			> ::v-deep(*) {
-				margin: auto;
-			}
-		}
-	}
-
-}
-</style>
diff --git a/packages/client/src/components/MkModalPageWindow.vue b/packages/client/src/components/MkModalPageWindow.vue
deleted file mode 100644
index ced8a7a714..0000000000
--- a/packages/client/src/components/MkModalPageWindow.vue
+++ /dev/null
@@ -1,181 +0,0 @@
-<template>
-<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
-	<div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
-		<div class="header" @contextmenu="onContextmenu">
-			<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
-			<span v-else style="display: inline-block; width: 20px"></span>
-			<span v-if="pageMetadata?.value" class="title">
-				<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
-				<span>{{ pageMetadata?.value.title }}</span>
-			</span>
-			<button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button>
-		</div>
-		<div class="body">
-			<MkStickyContainer>
-				<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
-				<RouterView :router="router"/>
-			</MkStickyContainer>
-		</div>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, provide } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import { popout as _popout } from '@/scripts/popout';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { url } from '@/config';
-import * as os from '@/os';
-import { mainRouter, routes } from '@/router';
-import { i18n } from '@/i18n';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-import { Router } from '@/nirax';
-
-const props = defineProps<{
-	initialPath: string;
-}>();
-
-defineEmits<{
-	(ev: 'closed'): void;
-	(ev: 'click'): void;
-}>();
-
-const router = new Router(routes, props.initialPath);
-
-router.addListener('push', ctx => {
-	
-});
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-let rootEl = $ref();
-let modal = $ref<InstanceType<typeof MkModal>>();
-let path = $ref(props.initialPath);
-let width = $ref(860);
-let height = $ref(660);
-const history = [];
-
-provide('router', router);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-});
-provide('shouldOmitHeaderTitle', true);
-provide('shouldHeaderThin', true);
-
-const pageUrl = $computed(() => url + path);
-const contextmenu = $computed(() => {
-	return [{
-		type: 'label',
-		text: path,
-	}, {
-		icon: 'ti ti-player-eject',
-		text: i18n.ts.showInPage,
-		action: expand,
-	}, {
-		icon: 'ti ti-window-maximize',
-		text: i18n.ts.popout,
-		action: popout,
-	}, null, {
-		icon: 'ti ti-external-link',
-		text: i18n.ts.openInNewTab,
-		action: () => {
-			window.open(pageUrl, '_blank');
-			modal.close();
-		},
-	}, {
-		icon: 'ti ti-link',
-		text: i18n.ts.copyLink,
-		action: () => {
-			copyToClipboard(pageUrl);
-		},
-	}];
-});
-
-function navigate(path, record = true) {
-	if (record) history.push(router.getCurrentPath());
-	router.push(path);
-}
-
-function back() {
-	navigate(history.pop(), false);
-}
-
-function expand() {
-	mainRouter.push(path);
-	modal.close();
-}
-
-function popout() {
-	_popout(path, rootEl);
-	modal.close();
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu(contextmenu, ev);
-}
-</script>
-
-<style lang="scss" scoped>
-.hrmcaedk {
-	overflow: hidden;
-	display: flex;
-	flex-direction: column;
-	contain: content;
-	border-radius: var(--radius);
-
-	--root-margin: 24px;
-
-	@media (max-width: 500px) {
-		--root-margin: 16px;
-	}
-
-	> .header {
-		$height: 52px;
-		$height-narrow: 42px;
-		display: flex;
-		flex-shrink: 0;
-		height: $height;
-		line-height: $height;
-		font-weight: bold;
-		white-space: nowrap;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		background: var(--windowHeader);
-		-webkit-backdrop-filter: var(--blur, blur(15px));
-		backdrop-filter: var(--blur, blur(15px));
-
-		> button {
-			height: $height;
-			width: $height;
-
-			&:hover {
-				color: var(--fgHighlighted);
-			}
-		}
-
-		@media (max-width: 500px) {
-			height: $height-narrow;
-			line-height: $height-narrow;
-			padding-left: 16px;
-
-			> button {
-				height: $height-narrow;
-				width: $height-narrow;
-			}
-		}
-
-		> .title {
-			flex: 1;
-
-			> .icon {
-				margin-right: 0.5em;
-			}
-		}
-	}
-
-	> .body {
-		overflow: auto;
-		background: var(--bg);
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkModalWindow.vue b/packages/client/src/components/MkModalWindow.vue
deleted file mode 100644
index d977ca6e9c..0000000000
--- a/packages/client/src/components/MkModalWindow.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<template>
-<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
-	<div ref="rootEl" class="ebkgoccj _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
-		<div ref="headerEl" class="header">
-			<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
-			<span class="title">
-				<slot name="header"></slot>
-			</span>
-			<button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
-			<button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
-		</div>
-		<div class="body">
-			<slot :width="bodyWidth" :height="bodyHeight"></slot>
-		</div>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted } from 'vue';
-import MkModal from './MkModal.vue';
-
-const props = withDefaults(defineProps<{
-	withOkButton: boolean;
-	okButtonDisabled: boolean;
-	width: number;
-	height: number | null;
-	scroll: boolean;
-}>(), {
-	withOkButton: false,
-	okButtonDisabled: false,
-	width: 400,
-	height: null,
-	scroll: true,
-});
-
-const emit = defineEmits<{
-	(event: 'click'): void;
-	(event: 'close'): void;
-	(event: 'closed'): void;
-	(event: 'ok'): void;
-}>();
-
-let modal = $ref<InstanceType<typeof MkModal>>();
-let rootEl = $ref<HTMLElement>();
-let headerEl = $ref<HTMLElement>();
-let bodyWidth = $ref(0);
-let bodyHeight = $ref(0);
-
-const close = () => {
-	modal.close();
-};
-
-const onBgClick = () => {
-	emit('click');
-};
-
-const onKeydown = (evt) => {
-	if (evt.which === 27) { // Esc
-		evt.preventDefault();
-		evt.stopPropagation();
-		close();
-	}
-};
-
-const ro = new ResizeObserver((entries, observer) => {
-	bodyWidth = rootEl.offsetWidth;
-	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
-});
-
-onMounted(() => {
-	bodyWidth = rootEl.offsetWidth;
-	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
-	ro.observe(rootEl);
-});
-
-onUnmounted(() => {
-	ro.disconnect();
-});
-
-defineExpose({
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.ebkgoccj {
-	overflow: hidden;
-	display: flex;
-	flex-direction: column;
-	contain: content;
-	border-radius: var(--radius);
-
-	--root-margin: 24px;
-
-	@media (max-width: 500px) {
-		--root-margin: 16px;
-	}
-
-	> .header {
-		$height: 46px;
-		$height-narrow: 42px;
-		display: flex;
-		flex-shrink: 0;
-		background: var(--windowHeader);
-		-webkit-backdrop-filter: var(--blur, blur(15px));
-		backdrop-filter: var(--blur, blur(15px));
-
-		> button {
-			height: $height;
-			width: $height;
-
-			@media (max-width: 500px) {
-				height: $height-narrow;
-				width: $height-narrow;
-			}
-		}
-
-		> .title {
-			flex: 1;
-			line-height: $height;
-			padding-left: 32px;
-			font-weight: bold;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			pointer-events: none;
-
-			@media (max-width: 500px) {
-				line-height: $height-narrow;
-				padding-left: 16px;
-			}
-		}
-
-		> button + .title {
-			padding-left: 0;
-		}
-	}
-
-	> .body {
-		flex: 1;
-		overflow: auto;
-		background: var(--panel);
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue
deleted file mode 100644
index a4100e1f2c..0000000000
--- a/packages/client/src/components/MkNote.vue
+++ /dev/null
@@ -1,658 +0,0 @@
-<template>
-<div
-	v-if="!muted"
-	v-show="!isDeleted"
-	ref="el"
-	v-hotkey="keymap"
-	v-size="{ max: [500, 450, 350, 300] }"
-	class="tkcbzcuz"
-	:tabindex="!isDeleted ? '-1' : null"
-	:class="{ renote: isRenote }"
->
-	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
-	<div v-if="pinned" class="info"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
-	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>
-	<div v-if="appearNote._featuredId_" class="info"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>
-	<div v-if="isRenote" class="renote">
-		<MkAvatar class="avatar" :user="note.user"/>
-		<i class="ti ti-repeat"></i>
-		<I18n :src="i18n.ts.renotedBy" tag="span">
-			<template #user>
-				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
-					<MkUserName :user="note.user"/>
-				</MkA>
-			</template>
-		</I18n>
-		<div class="info">
-			<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
-				<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
-				<MkTime :time="note.createdAt"/>
-			</button>
-			<MkVisibility :note="note"/>
-		</div>
-	</div>
-	<article class="article" @contextmenu.stop="onContextmenu">
-		<MkAvatar class="avatar" :user="appearNote.user"/>
-		<div class="main">
-			<XNoteHeader class="header" :note="appearNote" :mini="true"/>
-			<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
-			<div class="body">
-				<p v-if="appearNote.cw != null" class="cw">
-					<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-					<XCwButton v-model="showContent" :note="appearNote"/>
-				</p>
-				<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
-					<div class="text">
-						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
-						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
-						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-						<a v-if="appearNote.renote != null" class="rp">RN:</a>
-						<div v-if="translating || translation" class="translation">
-							<MkLoading v-if="translating" mini/>
-							<div v-else class="translated">
-								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
-								<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-							</div>
-						</div>
-					</div>
-					<div v-if="appearNote.files.length > 0" class="files">
-						<XMediaList :media-list="appearNote.files"/>
-					</div>
-					<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
-					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
-					<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false">
-						<span>{{ i18n.ts.showMore }}</span>
-					</button>
-					<button v-else-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true">
-						<span>{{ i18n.ts.showLess }}</span>
-					</button>
-				</div>
-				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
-			</div>
-			<footer class="footer">
-				<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
-				<button class="button _button" @click="reply()">
-					<i class="ti ti-arrow-back-up"></i>
-					<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
-				</button>
-				<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
-				<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
-					<i class="ti ti-plus"></i>
-				</button>
-				<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
-					<i class="ti ti-minus"></i>
-				</button>
-				<button ref="menuButton" class="button _button" @click="menu()">
-					<i class="ti ti-dots"></i>
-				</button>
-			</footer>
-		</div>
-	</article>
-</div>
-<div v-else class="muted" @click="muted = false">
-	<I18n :src="i18n.ts.userSaysSomething" tag="small">
-		<template #name>
-			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
-				<MkUserName :user="appearNote.user"/>
-			</MkA>
-		</template>
-	</I18n>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue';
-import * as mfm from 'mfm-js';
-import * as misskey from 'misskey-js';
-import MkNoteSub from '@/components/MkNoteSub.vue';
-import XNoteHeader from '@/components/MkNoteHeader.vue';
-import XNoteSimple from '@/components/MkNoteSimple.vue';
-import XReactionsViewer from '@/components/MkReactionsViewer.vue';
-import XMediaList from '@/components/MkMediaList.vue';
-import XCwButton from '@/components/MkCwButton.vue';
-import XPoll from '@/components/MkPoll.vue';
-import XRenoteButton from '@/components/MkRenoteButton.vue';
-import MkUrlPreview from '@/components/MkUrlPreview.vue';
-import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
-import MkVisibility from '@/components/MkVisibility.vue';
-import { pleaseLogin } from '@/scripts/please-login';
-import { focusPrev, focusNext } from '@/scripts/focus';
-import { checkWordMute } from '@/scripts/check-word-mute';
-import { userPage } from '@/filters/user';
-import * as os from '@/os';
-import { defaultStore, noteViewInterruptors } from '@/store';
-import { reactionPicker } from '@/scripts/reaction-picker';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { getNoteMenu } from '@/scripts/get-note-menu';
-import { useNoteCapture } from '@/scripts/use-note-capture';
-import { deepClone } from '@/scripts/clone';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-	pinned?: boolean;
-}>();
-
-const inChannel = inject('inChannel', null);
-
-let note = $ref(deepClone(props.note));
-
-// plugin
-if (noteViewInterruptors.length > 0) {
-	onMounted(async () => {
-		let result = deepClone(note);
-		for (const interruptor of noteViewInterruptors) {
-			result = await interruptor.handler(result);
-		}
-		note = result;
-	});
-}
-
-const isRenote = (
-	note.renote != null &&
-	note.text == null &&
-	note.fileIds.length === 0 &&
-	note.poll == null
-);
-
-const el = ref<HTMLElement>();
-const menuButton = ref<HTMLElement>();
-const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
-const renoteTime = ref<HTMLElement>();
-const reactButton = ref<HTMLElement>();
-let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
-const isMyRenote = $i && ($i.id === note.userId);
-const showContent = ref(false);
-const isLong = (appearNote.cw == null && appearNote.text != null && (
-	(appearNote.text.split('\n').length > 9) ||
-	(appearNote.text.length > 500)
-));
-const collapsed = ref(appearNote.cw == null && isLong);
-const isDeleted = ref(false);
-const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
-const translation = ref(null);
-const translating = ref(false);
-const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
-const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
-
-const keymap = {
-	'r': () => reply(true),
-	'e|a|plus': () => react(true),
-	'q': () => renoteButton.value.renote(true),
-	'up|k|shift+tab': focusBefore,
-	'down|j|tab': focusAfter,
-	'esc': blur,
-	'm|o': () => menu(true),
-	's': () => showContent.value !== showContent.value,
-};
-
-useNoteCapture({
-	rootEl: el,
-	note: $$(appearNote),
-	isDeletedRef: isDeleted,
-});
-
-function reply(viaKeyboard = false): void {
-	pleaseLogin();
-	os.post({
-		reply: appearNote,
-		animation: !viaKeyboard,
-	}, () => {
-		focus();
-	});
-}
-
-function react(viaKeyboard = false): void {
-	pleaseLogin();
-	blur();
-	reactionPicker.show(reactButton.value, reaction => {
-		os.api('notes/reactions/create', {
-			noteId: appearNote.id,
-			reaction: reaction,
-		});
-	}, () => {
-		focus();
-	});
-}
-
-function undoReact(note): void {
-	const oldReaction = note.myReaction;
-	if (!oldReaction) return;
-	os.api('notes/reactions/delete', {
-		noteId: note.id,
-	});
-}
-
-const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
-
-function onContextmenu(ev: MouseEvent): void {
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target)) return;
-	if (window.getSelection().toString() !== '') return;
-
-	if (defaultStore.state.useReactionPickerForContextMenu) {
-		ev.preventDefault();
-		react();
-	} else {
-		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus);
-	}
-}
-
-function menu(viaKeyboard = false): void {
-	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
-		viaKeyboard,
-	}).then(focus);
-}
-
-function showRenoteMenu(viaKeyboard = false): void {
-	if (!isMyRenote) return;
-	os.popupMenu([{
-		text: i18n.ts.unrenote,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: () => {
-			os.api('notes/delete', {
-				noteId: note.id,
-			});
-			isDeleted.value = true;
-		},
-	}], renoteTime.value, {
-		viaKeyboard: viaKeyboard,
-	});
-}
-
-function focus() {
-	el.value.focus();
-}
-
-function blur() {
-	el.value.blur();
-}
-
-function focusBefore() {
-	focusPrev(el.value);
-}
-
-function focusAfter() {
-	focusNext(el.value);
-}
-
-function readPromo() {
-	os.api('promo/read', {
-		noteId: appearNote.id,
-	});
-	isDeleted.value = true;
-}
-</script>
-
-<style lang="scss" scoped>
-.tkcbzcuz {
-	position: relative;
-	transition: box-shadow 0.1s ease;
-	font-size: 1.05em;
-	overflow: clip;
-	contain: content;
-
-	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
-	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
-	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
-	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
-	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか？)
-	//content-visibility: auto;
-  //contain-intrinsic-size: 0 128px;
-
-	&:focus-visible {
-		outline: none;
-
-		&:after {
-			content: "";
-			pointer-events: none;
-			display: block;
-			position: absolute;
-			z-index: 10;
-			top: 0;
-			left: 0;
-			right: 0;
-			bottom: 0;
-			margin: auto;
-			width: calc(100% - 8px);
-			height: calc(100% - 8px);
-			border: dashed 1px var(--focus);
-			border-radius: var(--radius);
-			box-sizing: border-box;
-		}
-	}
-
-	&:hover > .article > .main > .footer > .button {
-		opacity: 1;
-	}
-
-	> .info {
-		display: flex;
-		align-items: center;
-		padding: 16px 32px 8px 32px;
-		line-height: 24px;
-		font-size: 90%;
-		white-space: pre;
-		color: #d28a3f;
-
-		> i {
-			margin-right: 4px;
-		}
-
-		> .hide {
-			margin-left: auto;
-			color: inherit;
-		}
-	}
-
-	> .info + .article {
-		padding-top: 8px;
-	}
-
-	> .reply-to {
-		opacity: 0.7;
-		padding-bottom: 0;
-	}
-
-	> .renote {
-		display: flex;
-		align-items: center;
-		padding: 16px 32px 8px 32px;
-		line-height: 28px;
-		white-space: pre;
-		color: var(--renote);
-
-		> .avatar {
-			flex-shrink: 0;
-			display: inline-block;
-			width: 28px;
-			height: 28px;
-			margin: 0 8px 0 0;
-			border-radius: 6px;
-		}
-
-		> i {
-			margin-right: 4px;
-		}
-
-		> span {
-			overflow: hidden;
-			flex-shrink: 1;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-
-			> .name {
-				font-weight: bold;
-			}
-		}
-
-		> .info {
-			margin-left: auto;
-			font-size: 0.9em;
-
-			> .time {
-				flex-shrink: 0;
-				color: inherit;
-
-				> .dropdownIcon {
-					margin-right: 4px;
-				}
-			}
-		}
-	}
-
-	> .renote + .article {
-		padding-top: 8px;
-	}
-
-	> .article {
-		display: flex;
-		padding: 28px 32px 18px;
-
-		> .avatar {
-			flex-shrink: 0;
-			display: block;
-			margin: 0 14px 8px 0;
-			width: 58px;
-			height: 58px;
-			position: sticky;
-			top: calc(22px + var(--stickyTop, 0px));
-			left: 0;
-		}
-
-		> .main {
-			flex: 1;
-			min-width: 0;
-
-			> .body {
-				container-type: inline-size;
-
-				> .cw {
-					cursor: default;
-					display: block;
-					margin: 0;
-					padding: 0;
-					overflow-wrap: break-word;
-
-					> .text {
-						margin-right: 8px;
-					}
-				}
-
-				> .content {
-					&.isLong {
-						> .showLess {
-							width: 100%;
-							margin-top: 1em;
-							position: sticky;
-							bottom: 1em;
-
-							> span {
-								display: inline-block;
-								background: var(--popup);
-								padding: 6px 10px;
-								font-size: 0.8em;
-								border-radius: 999px;
-								box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
-							}
-						}
-					}
-
-					&.collapsed {
-						position: relative;
-						max-height: 9em;
-						overflow: hidden;
-
-						> .fade {
-							display: block;
-							position: absolute;
-							bottom: 0;
-							left: 0;
-							width: 100%;
-							height: 64px;
-							background: linear-gradient(0deg, var(--panel), var(--X15));
-
-							> span {
-								display: inline-block;
-								background: var(--panel);
-								padding: 6px 10px;
-								font-size: 0.8em;
-								border-radius: 999px;
-								box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
-							}
-
-							&:hover {
-								> span {
-									background: var(--panelHighlight);
-								}
-							}
-						}
-					}
-
-					> .text {
-						overflow-wrap: break-word;
-
-						> .reply {
-							color: var(--accent);
-							margin-right: 0.5em;
-						}
-
-						> .rp {
-							margin-left: 4px;
-							font-style: oblique;
-							color: var(--renote);
-						}
-
-						> .translation {
-							border: solid 0.5px var(--divider);
-							border-radius: var(--radius);
-							padding: 12px;
-							margin-top: 8px;
-						}
-					}
-
-					> .url-preview {
-						margin-top: 8px;
-					}
-
-					> .poll {
-						font-size: 80%;
-					}
-
-					> .renote {
-						padding: 8px 0;
-
-						> * {
-							padding: 16px;
-							border: dashed 1px var(--renote);
-							border-radius: 8px;
-						}
-					}
-				}
-
-				> .channel {
-					opacity: 0.7;
-					font-size: 80%;
-				}
-			}
-
-			> .footer {
-				> .button {
-					margin: 0;
-					padding: 8px;
-					opacity: 0.7;
-
-					&:not(:last-child) {
-						margin-right: 28px;
-					}
-
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-
-					> .count {
-						display: inline;
-						margin: 0 0 0 8px;
-						opacity: 0.7;
-					}
-
-					&.reacted {
-						color: var(--accent);
-					}
-				}
-			}
-		}
-	}
-
-	> .reply {
-		border-top: solid 0.5px var(--divider);
-	}
-}
-
-@container (max-width: 500px) {
-	.tkcbzcuz {
-		font-size: 0.9em;
-
-		> .article {
-			> .avatar {
-				width: 50px;
-				height: 50px;
-			}
-		}
-	}
-}
-
-@container (max-width: 450px) {
-	.tkcbzcuz {
-		> .renote {
-			padding: 8px 16px 0 16px;
-		}
-
-		> .info {
-			padding: 8px 16px 0 16px;
-		}
-
-		> .article {
-			padding: 14px 16px 9px;
-
-			> .avatar {
-				margin: 0 10px 8px 0;
-				width: 46px;
-				height: 46px;
-				top: calc(14px + var(--stickyTop, 0px));
-			}
-		}
-	}
-}
-
-@container (max-width: 350px) {
-	.tkcbzcuz {
-		> .article {
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 18px;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 300px) {
-	.tkcbzcuz {
-		> .article {
-			> .avatar {
-				width: 44px;
-				height: 44px;
-			}
-
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 12px;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-.muted {
-	padding: 8px;
-	text-align: center;
-	opacity: 0.7;
-}
-</style>
diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue
deleted file mode 100644
index 7ce8e039d9..0000000000
--- a/packages/client/src/components/MkNoteDetailed.vue
+++ /dev/null
@@ -1,677 +0,0 @@
-<template>
-<div
-	v-if="!muted"
-	v-show="!isDeleted"
-	ref="el"
-	v-hotkey="keymap"
-	v-size="{ max: [500, 450, 350, 300] }"
-	class="lxwezrsl _block"
-	:tabindex="!isDeleted ? '-1' : null"
-	:class="{ renote: isRenote }"
->
-	<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
-	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
-	<div v-if="isRenote" class="renote">
-		<MkAvatar class="avatar" :user="note.user"/>
-		<i class="ti ti-repeat"></i>
-		<I18n :src="i18n.ts.renotedBy" tag="span">
-			<template #user>
-				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
-					<MkUserName :user="note.user"/>
-				</MkA>
-			</template>
-		</I18n>
-		<div class="info">
-			<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
-				<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
-				<MkTime :time="note.createdAt"/>
-			</button>
-			<MkVisibility :note="note"/>
-		</div>
-	</div>
-	<article class="article" @contextmenu.stop="onContextmenu">
-		<header class="header">
-			<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
-			<div class="body">
-				<div class="top">
-					<MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)">
-						<MkUserName :nowrap="false" :user="appearNote.user"/>
-					</MkA>
-					<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
-					<div class="info">
-						<MkVisibility :note="appearNote"/>
-					</div>
-				</div>
-				<div class="username"><MkAcct :user="appearNote.user"/></div>
-				<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
-			</div>
-		</header>
-		<div class="main">
-			<div class="body">
-				<p v-if="appearNote.cw != null" class="cw">
-					<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-					<XCwButton v-model="showContent" :note="appearNote"/>
-				</p>
-				<div v-show="appearNote.cw == null || showContent" class="content">
-					<div class="text">
-						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
-						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
-						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-						<a v-if="appearNote.renote != null" class="rp">RN:</a>
-						<div v-if="translating || translation" class="translation">
-							<MkLoading v-if="translating" mini/>
-							<div v-else class="translated">
-								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
-								<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
-							</div>
-						</div>
-					</div>
-					<div v-if="appearNote.files.length > 0" class="files">
-						<XMediaList :media-list="appearNote.files"/>
-					</div>
-					<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
-					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>
-					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
-				</div>
-				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
-			</div>
-			<footer class="footer">
-				<div class="info">
-					<MkA class="created-at" :to="notePage(appearNote)">
-						<MkTime :time="appearNote.createdAt" mode="detail"/>
-					</MkA>
-				</div>
-				<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
-				<button class="button _button" @click="reply()">
-					<i class="ti ti-arrow-back-up"></i>
-					<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
-				</button>
-				<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
-				<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
-					<i class="ti ti-plus"></i>
-				</button>
-				<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
-					<i class="ti ti-minus"></i>
-				</button>
-				<button ref="menuButton" class="button _button" @click="menu()">
-					<i class="ti ti-dots"></i>
-				</button>
-			</footer>
-		</div>
-	</article>
-	<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
-</div>
-<div v-else class="_panel muted" @click="muted = false">
-	<I18n :src="i18n.ts.userSaysSomething" tag="small">
-		<template #name>
-			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
-				<MkUserName :user="appearNote.user"/>
-			</MkA>
-		</template>
-	</I18n>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
-import * as mfm from 'mfm-js';
-import * as misskey from 'misskey-js';
-import MkNoteSub from '@/components/MkNoteSub.vue';
-import XNoteSimple from '@/components/MkNoteSimple.vue';
-import XReactionsViewer from '@/components/MkReactionsViewer.vue';
-import XMediaList from '@/components/MkMediaList.vue';
-import XCwButton from '@/components/MkCwButton.vue';
-import XPoll from '@/components/MkPoll.vue';
-import XRenoteButton from '@/components/MkRenoteButton.vue';
-import MkUrlPreview from '@/components/MkUrlPreview.vue';
-import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
-import MkVisibility from '@/components/MkVisibility.vue';
-import { pleaseLogin } from '@/scripts/please-login';
-import { checkWordMute } from '@/scripts/check-word-mute';
-import { userPage } from '@/filters/user';
-import { notePage } from '@/filters/note';
-import * as os from '@/os';
-import { defaultStore, noteViewInterruptors } from '@/store';
-import { reactionPicker } from '@/scripts/reaction-picker';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { getNoteMenu } from '@/scripts/get-note-menu';
-import { useNoteCapture } from '@/scripts/use-note-capture';
-import { deepClone } from '@/scripts/clone';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-	pinned?: boolean;
-}>();
-
-const inChannel = inject('inChannel', null);
-
-let note = $ref(deepClone(props.note));
-
-// plugin
-if (noteViewInterruptors.length > 0) {
-	onMounted(async () => {
-		let result = deepClone(note);
-		for (const interruptor of noteViewInterruptors) {
-			result = await interruptor.handler(result);
-		}
-		note = result;
-	});
-}
-
-const isRenote = (
-	note.renote != null &&
-	note.text == null &&
-	note.fileIds.length === 0 &&
-	note.poll == null
-);
-
-const el = ref<HTMLElement>();
-const menuButton = ref<HTMLElement>();
-const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
-const renoteTime = ref<HTMLElement>();
-const reactButton = ref<HTMLElement>();
-let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
-const isMyRenote = $i && ($i.id === note.userId);
-const showContent = ref(false);
-const isDeleted = ref(false);
-const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
-const translation = ref(null);
-const translating = ref(false);
-const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
-const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
-const conversation = ref<misskey.entities.Note[]>([]);
-const replies = ref<misskey.entities.Note[]>([]);
-
-const keymap = {
-	'r': () => reply(true),
-	'e|a|plus': () => react(true),
-	'q': () => renoteButton.value.renote(true),
-	'esc': blur,
-	'm|o': () => menu(true),
-	's': () => showContent.value !== showContent.value,
-};
-
-useNoteCapture({
-	rootEl: el,
-	note: $$(appearNote),
-	isDeletedRef: isDeleted,
-});
-
-function reply(viaKeyboard = false): void {
-	pleaseLogin();
-	os.post({
-		reply: appearNote,
-		animation: !viaKeyboard,
-	}, () => {
-		focus();
-	});
-}
-
-function react(viaKeyboard = false): void {
-	pleaseLogin();
-	blur();
-	reactionPicker.show(reactButton.value, reaction => {
-		os.api('notes/reactions/create', {
-			noteId: appearNote.id,
-			reaction: reaction,
-		});
-	}, () => {
-		focus();
-	});
-}
-
-function undoReact(note): void {
-	const oldReaction = note.myReaction;
-	if (!oldReaction) return;
-	os.api('notes/reactions/delete', {
-		noteId: note.id,
-	});
-}
-
-function onContextmenu(ev: MouseEvent): void {
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target)) return;
-	if (window.getSelection().toString() !== '') return;
-
-	if (defaultStore.state.useReactionPickerForContextMenu) {
-		ev.preventDefault();
-		react();
-	} else {
-		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus);
-	}
-}
-
-function menu(viaKeyboard = false): void {
-	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, {
-		viaKeyboard,
-	}).then(focus);
-}
-
-function showRenoteMenu(viaKeyboard = false): void {
-	if (!isMyRenote) return;
-	os.popupMenu([{
-		text: i18n.ts.unrenote,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: () => {
-			os.api('notes/delete', {
-				noteId: note.id,
-			});
-			isDeleted.value = true;
-		},
-	}], renoteTime.value, {
-		viaKeyboard: viaKeyboard,
-	});
-}
-
-function focus() {
-	el.value.focus();
-}
-
-function blur() {
-	el.value.blur();
-}
-
-os.api('notes/children', {
-	noteId: appearNote.id,
-	limit: 30,
-}).then(res => {
-	replies.value = res;
-});
-
-if (appearNote.replyId) {
-	os.api('notes/conversation', {
-		noteId: appearNote.replyId,
-	}).then(res => {
-		conversation.value = res.reverse();
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.lxwezrsl {
-	position: relative;
-	transition: box-shadow 0.1s ease;
-	overflow: hidden;
-	contain: content;
-
-	&:focus-visible {
-		outline: none;
-
-		&:after {
-			content: "";
-			pointer-events: none;
-			display: block;
-			position: absolute;
-			z-index: 10;
-			top: 0;
-			left: 0;
-			right: 0;
-			bottom: 0;
-			margin: auto;
-			width: calc(100% - 8px);
-			height: calc(100% - 8px);
-			border: dashed 1px var(--focus);
-			border-radius: var(--radius);
-			box-sizing: border-box;
-		}
-	}
-
-	&:hover > .article > .main > .footer > .button {
-		opacity: 1;
-	}
-
-	> .reply-to {
-		opacity: 0.7;
-		padding-bottom: 0;
-	}
-
-	> .reply-to-more {
-		opacity: 0.7;
-	}
-
-	> .renote {
-		display: flex;
-		align-items: center;
-		padding: 16px 32px 8px 32px;
-		line-height: 28px;
-		white-space: pre;
-		color: var(--renote);
-
-		> .avatar {
-			flex-shrink: 0;
-			display: inline-block;
-			width: 28px;
-			height: 28px;
-			margin: 0 8px 0 0;
-			border-radius: 6px;
-		}
-
-		> i {
-			margin-right: 4px;
-		}
-
-		> span {
-			overflow: hidden;
-			flex-shrink: 1;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-
-			> .name {
-				font-weight: bold;
-			}
-		}
-
-		> .info {
-			margin-left: auto;
-			font-size: 0.9em;
-
-			> .time {
-				flex-shrink: 0;
-				color: inherit;
-
-				> .dropdownIcon {
-					margin-right: 4px;
-				}
-			}
-		}
-	}
-
-	> .renote + .article {
-		padding-top: 8px;
-	}
-
-	> .article {
-		padding: 32px;
-		font-size: 1.2em;
-
-		> .header {
-			display: flex;
-			position: relative;
-			margin-bottom: 16px;
-			align-items: center;
-
-			> .avatar {
-				display: block;
-				flex-shrink: 0;
-				width: 58px;
-				height: 58px;
-			}
-
-			> .body {
-				flex: 1;
-				display: flex;
-				flex-direction: column;
-				justify-content: center;
-				padding-left: 16px;
-				font-size: 0.95em;
-
-				> .top {
-					> .name {
-						font-weight: bold;
-						line-height: 1.3;
-					}
-
-					> .is-bot {
-						display: inline-block;
-						margin: 0 0.5em;
-						padding: 4px 6px;
-						font-size: 80%;
-						line-height: 1;
-						border: solid 0.5px var(--divider);
-						border-radius: 4px;
-					}
-
-					> .info {
-						float: right;
-					}
-				}
-
-				> .username {
-					margin-bottom: 2px;
-					line-height: 1.3;
-					word-wrap: anywhere;
-				}
-			}
-		}
-
-		> .main {
-			> .body {
-				container-type: inline-size;
-
-				> .cw {
-					cursor: default;
-					display: block;
-					margin: 0;
-					padding: 0;
-					overflow-wrap: break-word;
-
-					> .text {
-						margin-right: 8px;
-					}
-				}
-
-				> .content {
-					> .text {
-						overflow-wrap: break-word;
-
-						> .reply {
-							color: var(--accent);
-							margin-right: 0.5em;
-						}
-
-						> .rp {
-							margin-left: 4px;
-							font-style: oblique;
-							color: var(--renote);
-						}
-
-						> .translation {
-							border: solid 0.5px var(--divider);
-							border-radius: var(--radius);
-							padding: 12px;
-							margin-top: 8px;
-						}
-					}
-
-					> .url-preview {
-						margin-top: 8px;
-					}
-
-					> .poll {
-						font-size: 80%;
-					}
-
-					> .renote {
-						padding: 8px 0;
-
-						> * {
-							padding: 16px;
-							border: dashed 1px var(--renote);
-							border-radius: 8px;
-						}
-					}
-				}
-
-				> .channel {
-					opacity: 0.7;
-					font-size: 80%;
-				}
-			}
-
-			> .footer {
-				> .info {
-					margin: 16px 0;
-					opacity: 0.7;
-					font-size: 0.9em;
-				}
-
-				> .button {
-					margin: 0;
-					padding: 8px;
-					opacity: 0.7;
-
-					&:not(:last-child) {
-						margin-right: 28px;
-					}
-
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-
-					> .count {
-						display: inline;
-						margin: 0 0 0 8px;
-						opacity: 0.7;
-					}
-
-					&.reacted {
-						color: var(--accent);
-					}
-				}
-			}
-		}
-	}
-
-	> .reply {
-		border-top: solid 0.5px var(--divider);
-	}
-
-	&.max-width_500px {
-		font-size: 0.9em;
-	}
-
-	&.max-width_450px {
-		> .renote {
-			padding: 8px 16px 0 16px;
-		}
-
-		> .article {
-			padding: 16px;
-
-			> .header {
-				> .avatar {
-					width: 50px;
-					height: 50px;
-				}
-			}
-		}
-	}
-
-	&.max-width_350px {
-		> .article {
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 18px;
-						}
-					}
-				}
-			}
-		}
-	}
-
-	&.max-width_300px {
-		font-size: 0.825em;
-
-		> .article {
-			> .header {
-				> .avatar {
-					width: 50px;
-					height: 50px;
-				}
-			}
-
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 12px;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 500px) {
-	.lxwezrsl {
-		font-size: 0.9em;
-	}
-}
-
-@container (max-width: 450px) {
-	.lxwezrsl {
-		> .renote {
-			padding: 8px 16px 0 16px;
-		}
-
-		> .article {
-			padding: 16px;
-
-			> .header {
-				> .avatar {
-					width: 50px;
-					height: 50px;
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 350px) {
-	.lxwezrsl {
-		> .article {
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 18px;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 300px) {
-	.lxwezrsl {
-		font-size: 0.825em;
-
-		> .article {
-			> .header {
-				> .avatar {
-					width: 50px;
-					height: 50px;
-				}
-			}
-
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 12px;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-.muted {
-	padding: 8px;
-	text-align: center;
-	opacity: 0.7;
-}
-</style>
diff --git a/packages/client/src/components/MkNoteHeader.vue b/packages/client/src/components/MkNoteHeader.vue
deleted file mode 100644
index 333c3ddbd9..0000000000
--- a/packages/client/src/components/MkNoteHeader.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-<template>
-<header class="kkwtjztg">
-	<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
-		<MkUserName :user="note.user"/>
-	</MkA>
-	<div v-if="note.user.isBot" class="is-bot">bot</div>
-	<div class="username"><MkAcct :user="note.user"/></div>
-	<div class="info">
-		<MkA class="created-at" :to="notePage(note)">
-			<MkTime :time="note.createdAt"/>
-		</MkA>
-		<MkVisibility :note="note"/>
-	</div>
-</header>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import MkVisibility from '@/components/MkVisibility.vue';
-import { notePage } from '@/filters/note';
-import { userPage } from '@/filters/user';
-
-defineProps<{
-	note: misskey.entities.Note;
-	pinned?: boolean;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.kkwtjztg {
-	display: flex;
-	align-items: baseline;
-	white-space: nowrap;
-
-	> .name {
-		flex-shrink: 1;
-		display: block;
-		margin: 0 .5em 0 0;
-		padding: 0;
-		overflow: hidden;
-		font-size: 1em;
-		font-weight: bold;
-		text-decoration: none;
-		text-overflow: ellipsis;
-
-		&:hover {
-			text-decoration: underline;
-		}
-	}
-
-	> .is-bot {
-		flex-shrink: 0;
-		align-self: center;
-		margin: 0 .5em 0 0;
-		padding: 1px 6px;
-		font-size: 80%;
-		border: solid 0.5px var(--divider);
-		border-radius: 3px;
-	}
-
-	> .username {
-		flex-shrink: 9999999;
-		margin: 0 .5em 0 0;
-		overflow: hidden;
-		text-overflow: ellipsis;
-	}
-
-	> .info {
-		flex-shrink: 0;
-		margin-left: auto;
-		font-size: 0.9em;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNotePreview.vue b/packages/client/src/components/MkNotePreview.vue
deleted file mode 100644
index 0c81059091..0000000000
--- a/packages/client/src/components/MkNotePreview.vue
+++ /dev/null
@@ -1,112 +0,0 @@
-<template>
-<div v-size="{ min: [350, 500] }" class="fefdfafb">
-	<MkAvatar class="avatar" :user="$i"/>
-	<div class="main">
-		<div class="header">
-			<MkUserName :user="$i"/>
-		</div>
-		<div class="body">
-			<div class="content">
-				<Mfm :text="text.trim()" :author="$i" :i="$i"/>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	text: string;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.fefdfafb {
-	display: flex;
-	margin: 0;
-	padding: 0;
-	overflow: clip;
-	font-size: 0.95em;
-
-	&.min-width_350px {
-		> .avatar {
-			margin: 0 10px 0 0;
-			width: 44px;
-			height: 44px;
-		}
-	}
-
-	&.min-width_500px {
-		> .avatar {
-			margin: 0 12px 0 0;
-			width: 48px;
-			height: 48px;
-		}
-	}
-
-	> .avatar {
-		flex-shrink: 0;
-		display: block;
-		margin: 0 10px 0 0;
-		width: 40px;
-		height: 40px;
-		border-radius: 8px;
-		pointer-events: none;
-	}
-
-	> .main {
-		flex: 1;
-		min-width: 0;
-
-		> .header {
-			margin-bottom: 2px;
-			font-weight: bold;
-		}
-
-		> .body {
-
-			> .cw {
-				cursor: default;
-				display: block;
-				margin: 0;
-				padding: 0;
-				overflow-wrap: break-word;
-
-				> .text {
-					margin-right: 8px;
-				}
-			}
-
-			> .content {
-				> .text {
-					cursor: default;
-					margin: 0;
-					padding: 0;
-				}
-			}
-		}
-	}
-}
-
-@container (min-width: 350px) {
-	.fefdfafb {
-		> .avatar {
-			margin: 0 10px 0 0;
-			width: 44px;
-			height: 44px;
-		}
-	}
-}
-
-@container (min-width: 500px) {
-	.fefdfafb {
-		> .avatar {
-			margin: 0 12px 0 0;
-			width: 48px;
-			height: 48px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNoteSimple.vue b/packages/client/src/components/MkNoteSimple.vue
deleted file mode 100644
index 96d29831d2..0000000000
--- a/packages/client/src/components/MkNoteSimple.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-<template>
-<div v-size="{ min: [350, 500] }" class="yohlumlk">
-	<MkAvatar class="avatar" :user="note.user"/>
-	<div class="main">
-		<XNoteHeader class="header" :note="note" :mini="true"/>
-		<div class="body">
-			<p v-if="note.cw != null" class="cw">
-				<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
-				<XCwButton v-model="showContent" :note="note"/>
-			</p>
-			<div v-show="note.cw == null || showContent" class="content">
-				<MkSubNoteContent class="text" :note="note"/>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import XNoteHeader from '@/components/MkNoteHeader.vue';
-import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
-import XCwButton from '@/components/MkCwButton.vue';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-	pinned?: boolean;
-}>();
-
-const showContent = $ref(false);
-</script>
-
-<style lang="scss" scoped>
-.yohlumlk {
-	display: flex;
-	margin: 0;
-	padding: 0;
-	overflow: clip;
-	font-size: 0.95em;
-
-	&.min-width_350px {
-		> .avatar {
-			margin: 0 10px 0 0;
-			width: 44px;
-			height: 44px;
-		}
-	}
-
-	&.min-width_500px {
-		> .avatar {
-			margin: 0 12px 0 0;
-			width: 48px;
-			height: 48px;
-		}
-	}
-
-	> .avatar {
-		flex-shrink: 0;
-		display: block;
-		margin: 0 10px 0 0;
-		width: 40px;
-		height: 40px;
-		border-radius: 8px;
-	}
-
-	> .main {
-		flex: 1;
-		min-width: 0;
-
-		> .header {
-			margin-bottom: 2px;
-		}
-
-		> .body {
-
-			> .cw {
-				cursor: default;
-				display: block;
-				margin: 0;
-				padding: 0;
-				overflow-wrap: break-word;
-
-				> .text {
-					margin-right: 8px;
-				}
-			}
-
-			> .content {
-				> .text {
-					cursor: default;
-					margin: 0;
-					padding: 0;
-				}
-			}
-		}
-	}
-}
-
-@container (min-width: 350px) {
-	.yohlumlk {
-		> .avatar {
-			margin: 0 10px 0 0;
-			width: 44px;
-			height: 44px;
-		}
-	}
-}
-
-@container (min-width: 500px) {
-	.yohlumlk {
-		> .avatar {
-			margin: 0 12px 0 0;
-			width: 48px;
-			height: 48px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue
deleted file mode 100644
index d03ce7c434..0000000000
--- a/packages/client/src/components/MkNoteSub.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-<template>
-<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
-	<div class="main">
-		<MkAvatar class="avatar" :user="note.user"/>
-		<div class="body">
-			<XNoteHeader class="header" :note="note" :mini="true"/>
-			<div class="body">
-				<p v-if="note.cw != null" class="cw">
-					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
-					<XCwButton v-model="showContent" :note="note"/>
-				</p>
-				<div v-show="note.cw == null || showContent" class="content">
-					<MkSubNoteContent class="text" :note="note"/>
-				</div>
-			</div>
-		</div>
-	</div>
-	<template v-if="depth < 5">
-		<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
-	</template>
-	<div v-else class="more">
-		<MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import XNoteHeader from '@/components/MkNoteHeader.vue';
-import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
-import XCwButton from '@/components/MkCwButton.vue';
-import { notePage } from '@/filters/note';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	note: misskey.entities.Note;
-	detail?: boolean;
-
-	// how many notes are in between this one and the note being viewed in detail
-	depth?: number;
-}>(), {
-	depth: 1,
-});
-
-let showContent = $ref(false);
-let replies: misskey.entities.Note[] = $ref([]);
-
-if (props.detail) {
-	os.api('notes/children', {
-		noteId: props.note.id,
-		limit: 5,
-	}).then(res => {
-		replies = res;
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.wrpstxzv {
-	padding: 16px 32px;
-	font-size: 0.9em;
-
-	&.max-width_450px {
-		padding: 14px 16px;
-	}
-
-	&.children {
-		padding: 10px 0 0 16px;
-		font-size: 1em;
-
-		&.max-width_450px {
-			padding: 10px 0 0 8px;
-		}
-	}
-
-	> .main {
-		display: flex;
-
-		> .avatar {
-			flex-shrink: 0;
-			display: block;
-			margin: 0 8px 0 0;
-			width: 38px;
-			height: 38px;
-			border-radius: 8px;
-		}
-
-		> .body {
-			flex: 1;
-			min-width: 0;
-
-			> .header {
-				margin-bottom: 2px;
-			}
-
-			> .body {
-				> .cw {
-					cursor: default;
-					display: block;
-					margin: 0;
-					padding: 0;
-					overflow-wrap: break-word;
-
-					> .text {
-						margin-right: 8px;
-					}
-				}
-
-				> .content {
-					> .text {
-						margin: 0;
-						padding: 0;
-					}
-				}
-			}
-		}
-	}
-
-	> .reply, > .more {
-		border-left: solid 0.5px var(--divider);
-		margin-top: 10px;
-	}
-
-	> .more {
-		padding: 10px 0 0 16px;
-	}
-}
-
-@container (max-width: 450px) {
-	.wrpstxzv {
-		padding: 14px 16px;
-
-		&.children {
-			padding: 10px 0 0 8px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNotes.vue b/packages/client/src/components/MkNotes.vue
deleted file mode 100644
index 5abcdc2298..0000000000
--- a/packages/client/src/components/MkNotes.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<template>
-<MkPagination ref="pagingComponent" :pagination="pagination">
-	<template #empty>
-		<div class="_fullinfo">
-			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-			<div>{{ i18n.ts.noNotes }}</div>
-		</div>
-	</template>
-
-	<template #default="{ items: notes }">
-		<div class="giivymft" :class="{ noGap }">
-			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
-				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
-			</XList>
-		</div>
-	</template>
-</MkPagination>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import XNote from '@/components/MkNote.vue';
-import XList from '@/components/MkDateSeparatedList.vue';
-import MkPagination, { Paging } from '@/components/MkPagination.vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	pagination: Paging;
-	noGap?: boolean;
-}>();
-
-const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-
-defineExpose({
-	pagingComponent,
-});
-</script>
-
-<style lang="scss" scoped>
-.giivymft {
-	&.noGap {
-		> .notes {
-			background: var(--panel);
-		}
-	}
-
-	&:not(.noGap) {
-		> .notes {
-			background: var(--bg);
-
-			.qtqtichx {
-				background: var(--panel);
-				border-radius: var(--radius);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNotification.vue b/packages/client/src/components/MkNotification.vue
deleted file mode 100644
index 8b8d3f452d..0000000000
--- a/packages/client/src/components/MkNotification.vue
+++ /dev/null
@@ -1,323 +0,0 @@
-<template>
-<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type">
-	<div class="head">
-		<MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/>
-		<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
-		<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
-		<div class="sub-icon" :class="notification.type">
-			<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
-			<i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i>
-			<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i>
-			<i v-else-if="notification.type === 'groupInvited'" class="ti ti-certificate-2"></i>
-			<i v-else-if="notification.type === 'renote'" class="ti ti-repeat"></i>
-			<i v-else-if="notification.type === 'reply'" class="ti ti-arrow-back-up"></i>
-			<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
-			<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
-			<i v-else-if="notification.type === 'pollVote'" class="ti ti-chart-arrows"></i>
-			<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
-			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
-			<XReactionIcon
-				v-else-if="notification.type === 'reaction'"
-				ref="reactionRef"
-				:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
-				:custom-emojis="notification.note.emojis"
-				:no-style="true"
-			/>
-		</div>
-	</div>
-	<div class="tail">
-		<header>
-			<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
-			<MkA v-else-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
-			<span v-else>{{ notification.header }}</span>
-			<MkTime v-if="withTime" :time="notification.createdAt" class="time"/>
-		</header>
-		<MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<i class="ti ti-quote"></i>
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-			<i class="ti ti-quote"></i>
-		</MkA>
-		<MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
-			<i class="ti ti-quote"></i>
-			<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/>
-			<i class="ti ti-quote"></i>
-		</MkA>
-		<MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-		</MkA>
-		<MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-		</MkA>
-		<MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-		</MkA>
-		<MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<i class="ti ti-quote"></i>
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-			<i class="ti ti-quote"></i>
-		</MkA>
-		<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
-			<i class="ti ti-quote"></i>
-			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
-			<i class="ti ti-quote"></i>
-		</MkA>
-		<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
-		<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
-		<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
-		<span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ i18n.ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button></div></span>
-		<span v-if="notification.type === 'app'" class="text">
-			<Mfm :text="notification.body" :nowrap="!full"/>
-		</span>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, onMounted, onUnmounted, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import XReactionIcon from '@/components/MkReactionIcon.vue';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import XReactionTooltip from '@/components/MkReactionTooltip.vue';
-import { getNoteSummary } from '@/scripts/get-note-summary';
-import { notePage } from '@/filters/note';
-import { userPage } from '@/filters/user';
-import { i18n } from '@/i18n';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { useTooltip } from '@/scripts/use-tooltip';
-
-const props = withDefaults(defineProps<{
-	notification: misskey.entities.Notification;
-	withTime?: boolean;
-	full?: boolean;
-}>(), {
-	withTime: false,
-	full: false,
-});
-
-const elRef = ref<HTMLElement>(null);
-const reactionRef = ref(null);
-
-let readObserver: IntersectionObserver | undefined;
-let connection;
-
-onMounted(() => {
-	if (!props.notification.isRead) {
-		readObserver = new IntersectionObserver((entries, observer) => {
-			if (!entries.some(entry => entry.isIntersecting)) return;
-			stream.send('readNotification', {
-				id: props.notification.id,
-			});
-			observer.disconnect();
-		});
-
-		readObserver.observe(elRef.value);
-
-		connection = stream.useChannel('main');
-		connection.on('readAllNotifications', () => readObserver.disconnect());
-
-		watch(props.notification.isRead, () => {
-			readObserver.disconnect();
-		});
-	}
-});
-
-onUnmounted(() => {
-	if (readObserver) readObserver.disconnect();
-	if (connection) connection.dispose();
-});
-
-const followRequestDone = ref(false);
-const groupInviteDone = ref(false);
-
-const acceptFollowRequest = () => {
-	followRequestDone.value = true;
-	os.api('following/requests/accept', { userId: props.notification.user.id });
-};
-
-const rejectFollowRequest = () => {
-	followRequestDone.value = true;
-	os.api('following/requests/reject', { userId: props.notification.user.id });
-};
-
-const acceptGroupInvitation = () => {
-	groupInviteDone.value = true;
-	os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id });
-};
-
-const rejectGroupInvitation = () => {
-	groupInviteDone.value = true;
-	os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
-};
-
-useTooltip(reactionRef, (showing) => {
-	os.popup(XReactionTooltip, {
-		showing,
-		reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
-		emojis: props.notification.note.emojis,
-		targetElement: reactionRef.value.$el,
-	}, {}, 'closed');
-});
-</script>
-
-<style lang="scss" scoped>
-.qglefbjs {
-	position: relative;
-	box-sizing: border-box;
-	padding: 24px 32px;
-	font-size: 0.9em;
-	overflow-wrap: break-word;
-	display: flex;
-	contain: content;
-
-	&.max-width_600px {
-		padding: 16px;
-		font-size: 0.9em;
-	}
-
-	&.max-width_500px {
-		padding: 12px;
-		font-size: 0.85em;
-	}
-
-	> .head {
-		position: sticky;
-		top: 0;
-		flex-shrink: 0;
-		width: 42px;
-		height: 42px;
-		margin-right: 8px;
-
-		> .icon {
-			display: block;
-			width: 100%;
-			height: 100%;
-			border-radius: 6px;
-		}
-
-		> .sub-icon {
-			position: absolute;
-			z-index: 1;
-			bottom: -2px;
-			right: -2px;
-			width: 20px;
-			height: 20px;
-			box-sizing: border-box;
-			border-radius: 100%;
-			background: var(--panel);
-			box-shadow: 0 0 0 3px var(--panel);
-			font-size: 12px;
-			text-align: center;
-
-			&:empty {
-				display: none;
-			}
-
-			> * {
-				color: #fff;
-				width: 100%;
-				height: 100%;
-			}
-
-			&.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited {
-				padding: 3px;
-				background: #36aed2;
-				pointer-events: none;
-			}
-
-			&.renote {
-				padding: 3px;
-				background: #36d298;
-				pointer-events: none;
-			}
-
-			&.quote {
-				padding: 3px;
-				background: #36d298;
-				pointer-events: none;
-			}
-
-			&.reply {
-				padding: 3px;
-				background: #007aff;
-				pointer-events: none;
-			}
-
-			&.mention {
-				padding: 3px;
-				background: #88a6b7;
-				pointer-events: none;
-			}
-
-			&.pollVote {
-				padding: 3px;
-				background: #88a6b7;
-				pointer-events: none;
-			}
-
-			&.pollEnded {
-				padding: 3px;
-				background: #88a6b7;
-				pointer-events: none;
-			}
-		}
-	}
-
-	> .tail {
-		flex: 1;
-		min-width: 0;
-
-		> header {
-			display: flex;
-			align-items: baseline;
-			white-space: nowrap;
-
-			> .name {
-				text-overflow: ellipsis;
-				white-space: nowrap;
-				min-width: 0;
-				overflow: hidden;
-			}
-
-			> .time {
-				margin-left: auto;
-				font-size: 0.9em;
-			}
-		}
-
-		> .text {
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-
-			> i {
-				vertical-align: super;
-				font-size: 50%;
-				opacity: 0.5;
-			}
-
-			> i:first-child {
-				margin-right: 4px;
-			}
-
-			> i:last-child {
-				margin-left: 4px;
-			}
-		}
-	}
-}
-
-@container (max-width: 600px) {
-	.qglefbjs {
-		padding: 16px;
-		font-size: 0.9em;
-	}
-}
-
-@container (max-width: 500px) {
-	.qglefbjs {
-		padding: 12px;
-		font-size: 0.85em;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNotificationSettingWindow.vue b/packages/client/src/components/MkNotificationSettingWindow.vue
deleted file mode 100644
index 75bea2976c..0000000000
--- a/packages/client/src/components/MkNotificationSettingWindow.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="400"
-	:height="450"
-	:with-ok-button="true"
-	:ok-button-disabled="false"
-	@ok="ok()"
-	@close="dialog.close()"
-	@closed="emit('closed')"
->
-	<template #header>{{ i18n.ts.notificationSetting }}</template>
-	<div class="_monolithic_">
-		<div v-if="showGlobalToggle" class="_section">
-			<MkSwitch v-model="useGlobalSetting">
-				{{ i18n.ts.useGlobalSetting }}
-				<template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template>
-			</MkSwitch>
-		</div>
-		<div v-if="!useGlobalSetting" class="_section">
-			<MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo>
-			<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
-			<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
-			<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
-		</div>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import MkSwitch from './form/switch.vue';
-import MkInfo from './MkInfo.vue';
-import MkButton from './MkButton.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import { i18n } from '@/i18n';
-
-const emit = defineEmits<{
-	(ev: 'done', v: { includingTypes: string[] | null }): void,
-	(ev: 'closed'): void,
-}>();
-
-const props = withDefaults(defineProps<{
-	includingTypes?: typeof notificationTypes[number][] | null;
-	showGlobalToggle?: boolean;
-}>(), {
-	includingTypes: () => [],
-	showGlobalToggle: true,
-});
-
-let includingTypes = $computed(() => props.includingTypes || []);
-
-const dialog = $ref<InstanceType<typeof XModalWindow>>();
-
-let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({});
-let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle);
-
-for (const ntype of notificationTypes) {
-	typesMap[ntype] = includingTypes.includes(ntype);
-}
-
-function ok() {
-	if (useGlobalSetting) {
-		emit('done', { includingTypes: null });
-	} else {
-		emit('done', {
-			includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][])
-				.filter(type => typesMap[type]),
-		});
-	}
-
-	dialog.close();
-}
-
-function disableAll() {
-	for (const type in typesMap) {
-		typesMap[type as typeof notificationTypes[number]] = false;
-	}
-}
-
-function enableAll() {
-	for (const type in typesMap) {
-		typesMap[type as typeof notificationTypes[number]] = true;
-	}
-}
-</script>
diff --git a/packages/client/src/components/MkNotificationToast.vue b/packages/client/src/components/MkNotificationToast.vue
deleted file mode 100644
index 07640792c0..0000000000
--- a/packages/client/src/components/MkNotificationToast.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<template>
-<div class="mk-notification-toast" :style="{ zIndex }">
-	<transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')">
-		<XNotification v-if="showing" :notification="notification" class="notification _acrylic"/>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import XNotification from '@/components/MkNotification.vue';
-import * as os from '@/os';
-
-defineProps<{
-	notification: any; // TODO
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const zIndex = os.claimZIndex('high');
-let showing = $ref(true);
-
-onMounted(() => {
-	window.setTimeout(() => {
-		showing = false;
-	}, 6000);
-});
-</script>
-
-<style lang="scss" scoped>
-.notification-toast-enter-active, .notification-toast-leave-active {
-	transition: opacity 0.3s, transform 0.3s !important;
-}
-.notification-toast-enter-from, .notification-toast-leave-to {
-	opacity: 0;
-	transform: translateX(-250px);
-}
-
-.mk-notification-toast {
-	position: fixed;
-	left: 0;
-	width: 250px;
-	top: 32px;
-	padding: 0 32px;
-	pointer-events: none;
-	container-type: inline-size;
-
-	@media (max-width: 700px) {
-		top: initial;
-		bottom: 112px;
-		padding: 0 16px;
-	}
-
-	@media (max-width: 500px) {
-		bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
-		padding: 0 8px;
-	}
-
-	> .notification {
-		height: 100%;
-		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
-		border-radius: 8px;
-		overflow: hidden;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkNotifications.vue b/packages/client/src/components/MkNotifications.vue
deleted file mode 100644
index 0e1cc06743..0000000000
--- a/packages/client/src/components/MkNotifications.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<template>
-<MkPagination ref="pagingComponent" :pagination="pagination">
-	<template #empty>
-		<div class="_fullinfo">
-			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-			<div>{{ i18n.ts.noNotifications }}</div>
-		</div>
-	</template>
-
-	<template #default="{ items: notifications }">
-		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
-			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
-			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
-		</XList>
-	</template>
-</MkPagination>
-</template>
-
-<script lang="ts" setup>
-import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import MkPagination, { Paging } from '@/components/MkPagination.vue';
-import XNotification from '@/components/MkNotification.vue';
-import XList from '@/components/MkDateSeparatedList.vue';
-import XNote from '@/components/MkNote.vue';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	includeTypes?: typeof notificationTypes[number][];
-	unreadOnly?: boolean;
-}>();
-
-const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-
-const pagination: Paging = {
-	endpoint: 'i/notifications' as const,
-	limit: 10,
-	params: computed(() => ({
-		includeTypes: props.includeTypes ?? undefined,
-		excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
-		unreadOnly: props.unreadOnly,
-	})),
-};
-
-const onNotification = (notification) => {
-	const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
-	if (isMuted || document.visibilityState === 'visible') {
-		stream.send('readNotification', {
-			id: notification.id,
-		});
-	}
-
-	if (!isMuted) {
-		pagingComponent.value.prepend({
-			...notification,
-			isRead: document.visibilityState === 'visible',
-		});
-	}
-};
-
-let connection;
-
-onMounted(() => {
-	connection = stream.useChannel('main');
-	connection.on('notification', onNotification);
-	connection.on('readAllNotifications', () => {
-		if (pagingComponent.value) {
-			for (const item of pagingComponent.value.queue) {
-				item.isRead = true;
-			}
-			for (const item of pagingComponent.value.items) {
-				item.isRead = true;
-			}
-		}
-	});
-	connection.on('readNotifications', notificationIds => {
-		if (pagingComponent.value) {
-			for (let i = 0; i < pagingComponent.value.queue.length; i++) {
-				if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
-					pagingComponent.value.queue[i].isRead = true;
-				}
-			}
-			for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
-				if (notificationIds.includes(pagingComponent.value.items[i].id)) {
-					pagingComponent.value.items[i].isRead = true;
-				}
-			}
-		}
-	});
-});
-
-onUnmounted(() => {
-	if (connection) connection.dispose();
-});
-</script>
-
-<style lang="scss" scoped>
-.elsfgstc {
-	background: var(--panel);
-}
-</style>
diff --git a/packages/client/src/components/MkNumberDiff.vue b/packages/client/src/components/MkNumberDiff.vue
deleted file mode 100644
index e7d4a5472a..0000000000
--- a/packages/client/src/components/MkNumberDiff.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
-	<slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot>
-</span>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import number from '@/filters/number';
-
-export default defineComponent({
-	props: {
-		value: {
-			type: Number,
-			required: true,
-		},
-	},
-
-	setup(props) {
-		const isPlus = computed(() => props.value > 0);
-		const isMinus = computed(() => props.value < 0);
-		const isZero = computed(() => props.value === 0);
-		return {
-			isPlus,
-			isMinus,
-			isZero,
-			number,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.ceaaebcd {
-	&.isPlus {
-		color: var(--success);
-	}
-
-	&.isMinus {
-		color: var(--error);
-	}
-
-	&.isZero {
-		opacity: 0.5;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkObjectView.value.vue b/packages/client/src/components/MkObjectView.value.vue
deleted file mode 100644
index 0c7230d783..0000000000
--- a/packages/client/src/components/MkObjectView.value.vue
+++ /dev/null
@@ -1,160 +0,0 @@
-<template>
-<div class="igpposuu _monospace">
-	<div v-if="value === null" class="null">null</div>
-	<div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div>
-	<div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div>
-	<div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div>
-	<div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div>
-	<div v-else-if="isArray(value)" class="array">
-		<div v-for="i in value.length" class="element">
-			{{ i }}: <XValue :value="value[i - 1]" collapsed/>
-		</div>
-	</div>
-	<div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div>
-	<div v-else-if="isObject(value)" class="object">
-		<div v-for="k in Object.keys(value)" class="kv">
-			<button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button>
-			<div class="k">{{ k }}:</div>
-			<div v-if="collapsed[k]" class="v">
-				<button class="_button" @click="collapsed[k] = !collapsed[k]">
-					<template v-if="typeof value[k] === 'string'">"..."</template>
-					<template v-else-if="isArray(value[k])">[...]</template>
-					<template v-else-if="isObject(value[k])">{...}</template>
-				</button>
-			</div>
-			<div v-else class="v"><XValue :value="value[k]"/></div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, reactive, ref } from 'vue';
-import number from '@/filters/number';
-
-export default defineComponent({
-	name: 'XValue',
-
-	props: {
-		value: {
-			required: true,
-		},
-	},
-
-	setup(props) {
-		const collapsed = reactive({});
-
-		if (isObject(props.value)) {
-			for (const key in props.value) {
-				collapsed[key] = collapsable(props.value[key]);
-			}
-		}
-
-		function isObject(v): boolean {
-			return typeof v === 'object' && !Array.isArray(v) && v !== null;
-		}
-
-		function isArray(v): boolean {
-			return Array.isArray(v);
-		}
-
-		function isEmpty(v): boolean {
-			return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0);
-		}
-
-		function collapsable(v): boolean {
-			return (isObject(v) || isArray(v)) && !isEmpty(v);
-		}
-
-		return {
-			number,
-			collapsed,
-			isObject,
-			isArray,
-			isEmpty,
-			collapsable,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.igpposuu {
-	display: inline;
-
-	> .null {
-		display: inline;
-		opacity: 0.7;
-	}
-
-	> .boolean {
-		display: inline;
-		color: var(--codeBoolean);
-
-		&.true {
-			font-weight: bold;
-		}
-
-		&.false {
-			opacity: 0.7;
-		}
-	}
-
-	> .string {
-		display: inline;
-		color: var(--codeString);
-	}
-
-	> .number {
-		display: inline;
-		color: var(--codeNumber);
-	}
-
-	> .array.empty {
-		display: inline;
-		opacity: 0.7;
-	}
-
-	> .array:not(.empty) {
-		display: inline;
-
-		> .element {
-			display: block;
-			padding-left: 16px;
-		}
-	}
-
-	> .object.empty {
-		display: inline;
-		opacity: 0.7;
-	}
-
-	> .object:not(.empty) {
-		display: inline;
-
-		> .kv {
-			display: block;
-			padding-left: 16px;
-
-			> .toggle {
-				width: 16px;
-				color: var(--accent);
-				visibility: hidden;
-
-				&.visible {
-					visibility: visible;
-				}
-			}
-
-			> .k {
-				display: inline;
-				margin-right: 8px;
-			}
-
-			> .v {
-				display: inline;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkObjectView.vue b/packages/client/src/components/MkObjectView.vue
deleted file mode 100644
index 55578a37f6..0000000000
--- a/packages/client/src/components/MkObjectView.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-<template>
-<div class="zhyxdalp">
-	<XValue :value="value" :collapsed="false"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XValue from './MkObjectView.value.vue';
-
-const props = defineProps<{
-	value: Record<string, unknown>;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.zhyxdalp {
-
-}
-</style>
diff --git a/packages/client/src/components/MkPagePreview.vue b/packages/client/src/components/MkPagePreview.vue
deleted file mode 100644
index 009582e540..0000000000
--- a/packages/client/src/components/MkPagePreview.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-<template>
-<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1">
-	<div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
-	<article>
-		<header>
-			<h1 :title="page.title">{{ page.title }}</h1>
-		</header>
-		<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
-		<footer>
-			<img class="icon" :src="page.user.avatarUrl"/>
-			<p>{{ userName(page.user) }}</p>
-		</footer>
-	</article>
-</MkA>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { userName } from '@/filters/user';
-import * as os from '@/os';
-
-export default defineComponent({
-	props: {
-		page: {
-			type: Object,
-			required: true,
-		},
-	},
-	methods: {
-		userName,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.vhpxefrj {
-	display: block;
-
-	&:hover {
-		text-decoration: none;
-		color: var(--accent);
-	}
-
-	> .thumbnail {
-		width: 100%;
-		height: 200px;
-		background-position: center;
-		background-size: cover;
-		display: flex;
-		justify-content: center;
-		align-items: center;
-
-		> button {
-			font-size: 3.5em;
-			opacity: 0.7;
-
-			&:hover {
-				font-size: 4em;
-				opacity: 0.9;
-			}
-		}
-
-		& + article {
-			left: 100px;
-			width: calc(100% - 100px);
-		}
-	}
-
-	> article {
-		padding: 16px;
-
-		> header {
-			margin-bottom: 8px;
-
-			> h1 {
-				margin: 0;
-				font-size: 1em;
-				color: var(--urlPreviewTitle);
-			}
-		}
-
-		> p {
-			margin: 0;
-			color: var(--urlPreviewText);
-			font-size: 0.8em;
-		}
-
-		> footer {
-			margin-top: 8px;
-			height: 16px;
-
-			> img {
-				display: inline-block;
-				width: 16px;
-				height: 16px;
-				margin-right: 4px;
-				vertical-align: top;
-			}
-
-			> p {
-				display: inline-block;
-				margin: 0;
-				color: var(--urlPreviewInfo);
-				font-size: 0.8em;
-				line-height: 16px;
-				vertical-align: top;
-			}
-		}
-	}
-
-	@media (max-width: 700px) {
-		> .thumbnail {
-			position: relative;
-			width: 100%;
-			height: 100px;
-
-			& + article {
-				left: 0;
-				width: 100%;
-			}
-		}
-	}
-
-	@media (max-width: 550px) {
-		font-size: 12px;
-
-		> .thumbnail {
-			height: 80px;
-		}
-
-		> article {
-			padding: 12px;
-		}
-	}
-
-	@media (max-width: 500px) {
-		font-size: 10px;
-
-		> .thumbnail {
-			height: 70px;
-		}
-
-		> article {
-			padding: 8px;
-
-			> header {
-				margin-bottom: 4px;
-			}
-
-			> footer {
-				margin-top: 4px;
-
-				> img {
-					width: 12px;
-					height: 12px;
-				}
-			}
-		}
-	}
-}
-
-</style>
diff --git a/packages/client/src/components/MkPageWindow.vue b/packages/client/src/components/MkPageWindow.vue
deleted file mode 100644
index 29d45558a7..0000000000
--- a/packages/client/src/components/MkPageWindow.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-<template>
-<XWindow
-	ref="windowEl"
-	:initial-width="500"
-	:initial-height="500"
-	:can-resize="true"
-	:close-button="true"
-	:buttons-left="buttonsLeft"
-	:buttons-right="buttonsRight"
-	:contextmenu="contextmenu"
-	@closed="$emit('closed')"
->
-	<template #header>
-		<template v-if="pageMetadata?.value">
-			<i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
-			<span>{{ pageMetadata.value.title }}</span>
-		</template>
-	</template>
-
-	<div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }">
-		<RouterView :router="router"/>
-	</div>
-</XWindow>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, inject, provide } from 'vue';
-import RouterView from '@/components/global/RouterView.vue';
-import XWindow from '@/components/MkWindow.vue';
-import { popout as _popout } from '@/scripts/popout';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { url } from '@/config';
-import * as os from '@/os';
-import { mainRouter, routes } from '@/router';
-import { Router } from '@/nirax';
-import { i18n } from '@/i18n';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	initialPath: string;
-}>();
-
-defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const router = new Router(routes, props.initialPath);
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-let windowEl = $ref<InstanceType<typeof XWindow>>();
-const history = $ref<{ path: string; key: any; }[]>([{
-	path: router.getCurrentPath(),
-	key: router.getCurrentKey(),
-}]);
-const buttonsLeft = $computed(() => {
-	const buttons = [];
-
-	if (history.length > 1) {
-		buttons.push({
-			icon: 'ti ti-arrow-left',
-			onClick: back,
-		});
-	}
-
-	return buttons;
-});
-const buttonsRight = $computed(() => {
-	const buttons = [{
-		icon: 'ti ti-player-eject',
-		title: i18n.ts.showInPage,
-		onClick: expand,
-	}];
-
-	return buttons;
-});
-
-router.addListener('push', ctx => {
-	history.push({ path: ctx.path, key: ctx.key });
-});
-
-provide('router', router);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-});
-provide('shouldOmitHeaderTitle', true);
-provide('shouldHeaderThin', true);
-
-const contextmenu = $computed(() => ([{
-	icon: 'ti ti-player-eject',
-	text: i18n.ts.showInPage,
-	action: expand,
-}, {
-	icon: 'ti ti-window-maximize',
-	text: i18n.ts.popout,
-	action: popout,
-}, {
-	icon: 'ti ti-external-link',
-	text: i18n.ts.openInNewTab,
-	action: () => {
-		window.open(url + router.getCurrentPath(), '_blank');
-		windowEl.close();
-	},
-}, {
-	icon: 'ti ti-link',
-	text: i18n.ts.copyLink,
-	action: () => {
-		copyToClipboard(url + router.getCurrentPath());
-	},
-}]));
-
-function back() {
-	history.pop();
-	router.replace(history[history.length - 1].path, history[history.length - 1].key);
-}
-
-function close() {
-	windowEl.close();
-}
-
-function expand() {
-	mainRouter.push(router.getCurrentPath(), 'forcePage');
-	windowEl.close();
-}
-
-function popout() {
-	_popout(router.getCurrentPath(), windowEl.$el);
-	windowEl.close();
-}
-
-defineExpose({
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.yrolvcoq {
-	min-height: 100%;
-	background: var(--bg);
-}
-</style>
diff --git a/packages/client/src/components/MkPagination.vue b/packages/client/src/components/MkPagination.vue
deleted file mode 100644
index 291409171a..0000000000
--- a/packages/client/src/components/MkPagination.vue
+++ /dev/null
@@ -1,317 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-	<MkLoading v-if="fetching"/>
-
-	<MkError v-else-if="error" @retry="init()"/>
-
-	<div v-else-if="empty" key="_empty_" class="empty">
-		<slot name="empty">
-			<div class="_fullinfo">
-				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-				<div>{{ i18n.ts.nothing }}</div>
-			</div>
-		</slot>
-	</div>
-
-	<div v-else ref="rootEl">
-		<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
-			<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
-				{{ i18n.ts.loadMore }}
-			</MkButton>
-			<MkLoading v-else class="loading"/>
-		</div>
-		<slot :items="items"></slot>
-		<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
-			<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
-				{{ i18n.ts.loadMore }}
-			</MkButton>
-			<MkLoading v-else class="loading"/>
-		</div>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import * as os from '@/os';
-import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
-import MkButton from '@/components/MkButton.vue';
-import { i18n } from '@/i18n';
-
-const SECOND_FETCH_LIMIT = 30;
-
-export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
-	endpoint: E;
-	limit: number;
-	params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>;
-
-	/**
-	 * 検索APIのような、ページング不可なエンドポイントを利用する場合
-	 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
-	 */
-	noPaging?: boolean;
-
-	/**
-	 * items 配列の中身を逆順にする(新しい方が最後)
-	 */
-	reversed?: boolean;
-
-	offsetMode?: boolean;
-};
-
-const props = withDefaults(defineProps<{
-	pagination: Paging;
-	disableAutoLoad?: boolean;
-	displayLimit?: number;
-}>(), {
-	displayLimit: 30,
-});
-
-const emit = defineEmits<{
-	(ev: 'queue', count: number): void;
-}>();
-
-type Item = { id: string; [another: string]: unknown; };
-
-const rootEl = ref<HTMLElement>();
-const items = ref<Item[]>([]);
-const queue = ref<Item[]>([]);
-const offset = ref(0);
-const fetching = ref(true);
-const moreFetching = ref(false);
-const more = ref(false);
-const backed = ref(false); // 遡り中か否か
-const isBackTop = ref(false);
-const empty = computed(() => items.value.length === 0);
-const error = ref(false);
-
-const init = async (): Promise<void> => {
-	queue.value = [];
-	fetching.value = true;
-	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
-	await os.api(props.pagination.endpoint, {
-		...params,
-		limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
-	}).then(res => {
-		for (let i = 0; i < res.length; i++) {
-			const item = res[i];
-			if (props.pagination.reversed) {
-				if (i === res.length - 2) item._shouldInsertAd_ = true;
-			} else {
-				if (i === 3) item._shouldInsertAd_ = true;
-			}
-		}
-		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
-			res.pop();
-			items.value = props.pagination.reversed ? [...res].reverse() : res;
-			more.value = true;
-		} else {
-			items.value = props.pagination.reversed ? [...res].reverse() : res;
-			more.value = false;
-		}
-		offset.value = res.length;
-		error.value = false;
-		fetching.value = false;
-	}, err => {
-		error.value = true;
-		fetching.value = false;
-	});
-};
-
-const reload = (): void => {
-	items.value = [];
-	init();
-};
-
-const fetchMore = async (): Promise<void> => {
-	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
-	moreFetching.value = true;
-	backed.value = true;
-	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
-	await os.api(props.pagination.endpoint, {
-		...params,
-		limit: SECOND_FETCH_LIMIT + 1,
-		...(props.pagination.offsetMode ? {
-			offset: offset.value,
-		} : props.pagination.reversed ? {
-			sinceId: items.value[0].id,
-		} : {
-			untilId: items.value[items.value.length - 1].id,
-		}),
-	}).then(res => {
-		for (let i = 0; i < res.length; i++) {
-			const item = res[i];
-			if (props.pagination.reversed) {
-				if (i === res.length - 9) item._shouldInsertAd_ = true;
-			} else {
-				if (i === 10) item._shouldInsertAd_ = true;
-			}
-		}
-		if (res.length > SECOND_FETCH_LIMIT) {
-			res.pop();
-			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
-			more.value = true;
-		} else {
-			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
-			more.value = false;
-		}
-		offset.value += res.length;
-		moreFetching.value = false;
-	}, err => {
-		moreFetching.value = false;
-	});
-};
-
-const fetchMoreAhead = async (): Promise<void> => {
-	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
-	moreFetching.value = true;
-	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
-	await os.api(props.pagination.endpoint, {
-		...params,
-		limit: SECOND_FETCH_LIMIT + 1,
-		...(props.pagination.offsetMode ? {
-			offset: offset.value,
-		} : props.pagination.reversed ? {
-			untilId: items.value[0].id,
-		} : {
-			sinceId: items.value[items.value.length - 1].id,
-		}),
-	}).then(res => {
-		if (res.length > SECOND_FETCH_LIMIT) {
-			res.pop();
-			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
-			more.value = true;
-		} else {
-			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
-			more.value = false;
-		}
-		offset.value += res.length;
-		moreFetching.value = false;
-	}, err => {
-		moreFetching.value = false;
-	});
-};
-
-const prepend = (item: Item): void => {
-	if (props.pagination.reversed) {
-		if (rootEl.value) {
-			const container = getScrollContainer(rootEl.value);
-			if (container == null) {
-				// TODO?
-			} else {
-				const pos = getScrollPosition(rootEl.value);
-				const viewHeight = container.clientHeight;
-				const height = container.scrollHeight;
-				const isBottom = (pos + viewHeight > height - 32);
-				if (isBottom) {
-					// オーバーフローしたら古いアイテムは捨てる
-					if (items.value.length >= props.displayLimit) {
-						// このやり方だとVue 3.2以降アニメーションが動かなくなる
-						//items.value = items.value.slice(-props.displayLimit);
-						while (items.value.length >= props.displayLimit) {
-							items.value.shift();
-						}
-						more.value = true;
-					}
-				}
-			}
-		}
-		items.value.push(item);
-		// TODO
-	} else {
-		// 初回表示時はunshiftだけでOK
-		if (!rootEl.value) {
-			items.value.unshift(item);
-			return;
-		}
-
-		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
-
-		if (isTop) {
-			// Prepend the item
-			items.value.unshift(item);
-
-			// オーバーフローしたら古いアイテムは捨てる
-			if (items.value.length >= props.displayLimit) {
-				// このやり方だとVue 3.2以降アニメーションが動かなくなる
-				//this.items = items.value.slice(0, props.displayLimit);
-				while (items.value.length >= props.displayLimit) {
-					items.value.pop();
-				}
-				more.value = true;
-			}
-		} else {
-			queue.value.push(item);
-			onScrollTop(rootEl.value, () => {
-				for (const item of queue.value) {
-					prepend(item);
-				}
-				queue.value = [];
-			});
-		}
-	}
-};
-
-const append = (item: Item): void => {
-	items.value.push(item);
-};
-
-const removeItem = (finder: (item: Item) => boolean) => {
-	const i = items.value.findIndex(finder);
-	items.value.splice(i, 1);
-};
-
-const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => {
-	const i = items.value.findIndex(item => item.id === id);
-	items.value[i] = replacer(items.value[i]);
-};
-
-if (props.pagination.params && isRef(props.pagination.params)) {
-	watch(props.pagination.params, init, { deep: true });
-}
-
-watch(queue, (a, b) => {
-	if (a.length === 0 && b.length === 0) return;
-	emit('queue', queue.value.length);
-}, { deep: true });
-
-init();
-
-onActivated(() => {
-	isBackTop.value = false;
-});
-
-onDeactivated(() => {
-	isBackTop.value = window.scrollY === 0;
-});
-
-defineExpose({
-	items,
-	queue,
-	backed,
-	reload,
-	prepend,
-	append,
-	removeItem,
-	updateItem,
-});
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.cxiknjgy {
-	> .button {
-		margin-left: auto;
-		margin-right: auto;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPoll.vue b/packages/client/src/components/MkPoll.vue
deleted file mode 100644
index a1b927e42a..0000000000
--- a/packages/client/src/components/MkPoll.vue
+++ /dev/null
@@ -1,152 +0,0 @@
-<template>
-<div class="tivcixzd" :class="{ done: closed || isVoted }">
-	<ul>
-		<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
-			<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
-			<span>
-				<template v-if="choice.isVoted"><i class="ti ti-check"></i></template>
-				<Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
-				<span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
-			</span>
-		</li>
-	</ul>
-	<p v-if="!readOnly">
-		<span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
-		<span> · </span>
-		<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
-		<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
-		<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
-		<span v-if="remaining > 0"> · {{ timer }}</span>
-	</p>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onUnmounted, ref, toRef } from 'vue';
-import * as misskey from 'misskey-js';
-import { sum } from '@/scripts/array';
-import { pleaseLogin } from '@/scripts/please-login';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { useInterval } from '@/scripts/use-interval';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-	readOnly?: boolean;
-}>();
-
-const remaining = ref(-1);
-
-const total = computed(() => sum(props.note.poll.choices.map(x => x.votes)));
-const closed = computed(() => remaining.value === 0);
-const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted));
-const timer = computed(() => i18n.t(
-	remaining.value >= 86400 ? '_poll.remainingDays' :
-	remaining.value >= 3600 ? '_poll.remainingHours' :
-	remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
-		s: Math.floor(remaining.value % 60),
-		m: Math.floor(remaining.value / 60) % 60,
-		h: Math.floor(remaining.value / 3600) % 24,
-		d: Math.floor(remaining.value / 86400),
-	}));
-
-const showResult = ref(props.readOnly || isVoted.value);
-
-// 期限付きアンケート
-if (props.note.poll.expiresAt) {
-	const tick = () => {
-		remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000);
-		if (remaining.value === 0) {
-			showResult.value = true;
-		}
-	};
-
-	useInterval(tick, 3000, {
-		immediate: true,
-		afterMounted: false,
-	});
-}
-
-const vote = async (id) => {
-	pleaseLogin();
-
-	if (props.readOnly || closed.value || isVoted.value) return;
-
-	const { canceled } = await os.confirm({
-		type: 'question',
-		text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }),
-	});
-	if (canceled) return;
-
-	await os.api('notes/polls/vote', {
-		noteId: props.note.id,
-		choice: id,
-	});
-	if (!showResult.value) showResult.value = !props.note.poll.multiple;
-};
-</script>
-
-<style lang="scss" scoped>
-.tivcixzd {
-	> ul {
-		display: block;
-		margin: 0;
-		padding: 0;
-		list-style: none;
-
-		> li {
-			display: block;
-			position: relative;
-			margin: 4px 0;
-			padding: 4px;
-			//border: solid 0.5px var(--divider);
-			background: var(--accentedBg);
-			border-radius: 4px;
-			overflow: hidden;
-			cursor: pointer;
-
-			> .backdrop {
-				position: absolute;
-				top: 0;
-				left: 0;
-				height: 100%;
-				background: var(--accent);
-				background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
-				transition: width 1s ease;
-			}
-
-			> span {
-				position: relative;
-				display: inline-block;
-				padding: 3px 5px;
-				background: var(--panel);
-				border-radius: 3px;
-
-				> i {
-					margin-right: 4px;
-					color: var(--accent);
-				}
-
-				> .votes {
-					margin-left: 4px;
-					opacity: 0.7;
-				}
-			}
-		}
-	}
-
-	> p {
-		color: var(--fg);
-
-		a {
-			color: inherit;
-		}
-	}
-
-	&.done {
-		> ul > li {
-			cursor: default;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPollEditor.vue b/packages/client/src/components/MkPollEditor.vue
deleted file mode 100644
index 556abc5fd0..0000000000
--- a/packages/client/src/components/MkPollEditor.vue
+++ /dev/null
@@ -1,219 +0,0 @@
-<template>
-<div class="zmdxowus">
-	<p v-if="choices.length < 2" class="caution">
-		<i class="ti ti-alert-triangle"></i>{{ i18n.ts._poll.noOnlyOneChoice }}
-	</p>
-	<ul>
-		<li v-for="(choice, i) in choices" :key="i">
-			<MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
-			</MkInput>
-			<button class="_button" @click="remove(i)">
-				<i class="ti ti-x"></i>
-			</button>
-		</li>
-	</ul>
-	<MkButton v-if="choices.length < 10" class="add" @click="add">{{ i18n.ts.add }}</MkButton>
-	<MkButton v-else class="add" disabled>{{ i18n.ts._poll.noMore }}</MkButton>
-	<MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch>
-	<section>
-		<div>
-			<MkSelect v-model="expiration" small>
-				<template #label>{{ i18n.ts._poll.expiration }}</template>
-				<option value="infinite">{{ i18n.ts._poll.infinite }}</option>
-				<option value="at">{{ i18n.ts._poll.at }}</option>
-				<option value="after">{{ i18n.ts._poll.after }}</option>
-			</MkSelect>
-			<section v-if="expiration === 'at'">
-				<MkInput v-model="atDate" small type="date" class="input">
-					<template #label>{{ i18n.ts._poll.deadlineDate }}</template>
-				</MkInput>
-				<MkInput v-model="atTime" small type="time" class="input">
-					<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
-				</MkInput>
-			</section>
-			<section v-else-if="expiration === 'after'">
-				<MkInput v-model="after" small type="number" class="input">
-					<template #label>{{ i18n.ts._poll.duration }}</template>
-				</MkInput>
-				<MkSelect v-model="unit" small>
-					<option value="second">{{ i18n.ts._time.second }}</option>
-					<option value="minute">{{ i18n.ts._time.minute }}</option>
-					<option value="hour">{{ i18n.ts._time.hour }}</option>
-					<option value="day">{{ i18n.ts._time.day }}</option>
-				</MkSelect>
-			</section>
-		</div>
-	</section>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import MkInput from './form/input.vue';
-import MkSelect from './form/select.vue';
-import MkSwitch from './form/switch.vue';
-import MkButton from './MkButton.vue';
-import { formatDateTimeString } from '@/scripts/format-time-string';
-import { addTime } from '@/scripts/time';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: {
-		expiresAt: string;
-		expiredAfter: number;
-		choices: string[];
-		multiple: boolean;
-	};
-}>();
-const emit = defineEmits<{
-	(ev: 'update:modelValue', v: {
-		expiresAt: string;
-		expiredAfter: number;
-		choices: string[];
-		multiple: boolean;
-	}): void;
-}>();
-
-const choices = ref(props.modelValue.choices);
-const multiple = ref(props.modelValue.multiple);
-const expiration = ref('infinite');
-const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
-const atTime = ref('00:00');
-const after = ref(0);
-const unit = ref('second');
-
-if (props.modelValue.expiresAt) {
-	expiration.value = 'at';
-	atDate.value = atTime.value = props.modelValue.expiresAt;
-} else if (typeof props.modelValue.expiredAfter === 'number') {
-	expiration.value = 'after';
-	after.value = props.modelValue.expiredAfter / 1000;
-} else {
-	expiration.value = 'infinite';
-}
-
-function onInput(i, value) {
-	choices.value[i] = value;
-}
-
-function add() {
-	choices.value.push('');
-	// TODO
-	// nextTick(() => {
-	//   (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
-	// });
-}
-
-function remove(i) {
-	choices.value = choices.value.filter((_, _i) => _i !== i);
-}
-
-function get() {
-	const calcAt = () => {
-		return new Date(`${atDate.value} ${atTime.value}`).getTime();
-	};
-
-	const calcAfter = () => {
-		let base = parseInt(after.value);
-		switch (unit.value) {
-			case 'day': base *= 24;
-				// fallthrough
-			case 'hour': base *= 60;
-				// fallthrough
-			case 'minute': base *= 60;
-				// fallthrough
-			case 'second': return base *= 1000;
-			default: return null;
-		}
-	};
-
-	return {
-		choices: choices.value,
-		multiple: multiple.value,
-		...(
-			expiration.value === 'at' ? { expiresAt: calcAt() } :
-			expiration.value === 'after' ? { expiredAfter: calcAfter() } : {}
-		),
-	};
-}
-
-watch([choices, multiple, expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), {
-	deep: true,
-});
-</script>
-
-<style lang="scss" scoped>
-.zmdxowus {
-	padding: 8px 16px;
-
-	> .caution {
-		margin: 0 0 8px 0;
-		font-size: 0.8em;
-		color: #f00;
-
-		> i {
-			margin-right: 4px;
-		}
-	}
-
-	> ul {
-		display: block;
-		margin: 0;
-		padding: 0;
-		list-style: none;
-
-		> li {
-			display: flex;
-			margin: 8px 0;
-			padding: 0;
-			width: 100%;
-
-			> .input {
-				flex: 1;
-			}
-
-			> button {
-				width: 32px;
-				padding: 4px 0;
-			}
-		}
-	}
-
-	> .add {
-		margin: 8px 0;
-		z-index: 1;
-	}
-
-	> section {
-		margin: 16px 0 0 0;
-
-		> div {
-			margin: 0 8px;
-			display: flex;
-			flex-direction: row;
-			flex-wrap: wrap;
-			gap: 12px;
-
-			&:last-child {
-				flex: 1 0 auto;
-
-				> div {
-					flex-grow: 1;
-				}
-
-				> section {
-					// MAGIC: Prevent div above from growing unless wrapped to its own line
-					flex-grow: 9999;
-					align-items: end;
-					display: flex;
-					gap: 4px;
-
-					> .input {
-						flex: 1 1 auto;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPopupMenu.vue b/packages/client/src/components/MkPopupMenu.vue
deleted file mode 100644
index f04c7f5618..0000000000
--- a/packages/client/src/components/MkPopupMenu.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')">
-	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkModal from './MkModal.vue';
-import MkMenu from './MkMenu.vue';
-import { MenuItem } from '@/types/menu';
-
-defineProps<{
-	items: MenuItem[];
-	align?: 'center' | string;
-	width?: number;
-	viaKeyboard?: boolean;
-	src?: any;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-let modal = $ref<InstanceType<typeof MkModal>>();
-</script>
-
-<style lang="scss" scoped>
-.sfhdhdhq {
-	&.drawer {
-		border-radius: 24px;
-		border-bottom-right-radius: 0;
-		border-bottom-left-radius: 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue
deleted file mode 100644
index f79e5a32cd..0000000000
--- a/packages/client/src/components/MkPostForm.vue
+++ /dev/null
@@ -1,1050 +0,0 @@
-<template>
-<div
-	v-size="{ max: [310, 500] }" class="gafaadew"
-	:class="{ modal, _popup: modal }"
-	@dragover.stop="onDragover"
-	@dragenter="onDragenter"
-	@dragleave="onDragleave"
-	@drop.stop="onDrop"
->
-	<header>
-		<button v-if="!fixed" class="cancel _button" @click="cancel"><i class="ti ti-x"></i></button>
-		<button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu">
-			<MkAvatar :user="postAccount ?? $i" class="avatar"/>
-		</button>
-		<div class="right">
-			<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
-			<span v-if="localOnly" class="local-only"><i class="ti ti-world-off"></i></span>
-			<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
-				<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
-				<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
-				<span v-if="visibility === 'followers'"><i class="ti ti-lock-open"></i></span>
-				<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
-			</button>
-			<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
-			<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button>
-		</div>
-	</header>
-	<div class="form" :class="{ fixed }">
-		<XNoteSimple v-if="reply" class="preview" :note="reply"/>
-		<XNoteSimple v-if="renote" class="preview" :note="renote"/>
-		<div v-if="quoteId" class="with-quote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
-		<div v-if="visibility === 'specified'" class="to-specified">
-			<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
-			<div class="visibleUsers">
-				<span v-for="u in visibleUsers" :key="u.id">
-					<MkAcct :user="u"/>
-					<button class="_button" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
-				</span>
-				<button class="_buttonPrimary" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
-			</div>
-		</div>
-		<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
-		<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
-		<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
-		<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
-		<XPostFormAttaches v-model="files" class="attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
-		<XPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
-		<XNotePreview v-if="showPreview" class="preview" :text="text"/>
-		<footer>
-			<button v-tooltip="i18n.ts.attachFile" class="_button" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
-			<button v-tooltip="i18n.ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
-			<button v-tooltip="i18n.ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
-			<button v-tooltip="i18n.ts.mention" class="_button" @click="insertMention"><i class="ti ti-at"></i></button>
-			<button v-tooltip="i18n.ts.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
-			<button v-tooltip="i18n.ts.emoji" class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
-			<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" @click="showActions"><i class="ti ti-plug"></i></button>
-		</footer>
-		<datalist id="hashtags">
-			<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
-		</datalist>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
-import * as mfm from 'mfm-js';
-import * as misskey from 'misskey-js';
-import insertTextAtCursor from 'insert-text-at-cursor';
-import { length } from 'stringz';
-import { toASCII } from 'punycode/';
-import * as Acct from 'misskey-js/built/acct';
-import { throttle } from 'throttle-debounce';
-import XNoteSimple from '@/components/MkNoteSimple.vue';
-import XNotePreview from '@/components/MkNotePreview.vue';
-import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
-import XPollEditor from '@/components/MkPollEditor.vue';
-import { host, url } from '@/config';
-import { erase, unique } from '@/scripts/array';
-import { extractMentions } from '@/scripts/extract-mentions';
-import { formatTimeString } from '@/scripts/format-time-string';
-import { Autocomplete } from '@/scripts/autocomplete';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { selectFiles } from '@/scripts/select-file';
-import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
-import MkInfo from '@/components/MkInfo.vue';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
-import { uploadFile } from '@/scripts/upload';
-import { deepClone } from '@/scripts/clone';
-
-const modal = inject('modal');
-
-const props = withDefaults(defineProps<{
-	reply?: misskey.entities.Note;
-	renote?: misskey.entities.Note;
-	channel?: any; // TODO
-	mention?: misskey.entities.User;
-	specified?: misskey.entities.User;
-	initialText?: string;
-	initialVisibility?: typeof misskey.noteVisibilities;
-	initialFiles?: misskey.entities.DriveFile[];
-	initialLocalOnly?: boolean;
-	initialVisibleUsers?: misskey.entities.User[];
-	initialNote?: misskey.entities.Note;
-	instant?: boolean;
-	fixed?: boolean;
-	autofocus?: boolean;
-}>(), {
-	initialVisibleUsers: () => [],
-	autofocus: true,
-});
-
-const emit = defineEmits<{
-	(ev: 'posted'): void;
-	(ev: 'cancel'): void;
-	(ev: 'esc'): void;
-}>();
-
-const textareaEl = $ref<HTMLTextAreaElement | null>(null);
-const cwInputEl = $ref<HTMLInputElement | null>(null);
-const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
-const visibilityButton = $ref<HTMLElement | null>(null);
-
-let posting = $ref(false);
-let text = $ref(props.initialText ?? '');
-let files = $ref(props.initialFiles ?? []);
-let poll = $ref<{
-	choices: string[];
-	multiple: boolean;
-	expiresAt: string | null;
-	expiredAfter: string | null;
-} | null>(null);
-let useCw = $ref(false);
-let showPreview = $ref(false);
-let cw = $ref<string | null>(null);
-let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
-let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]);
-let visibleUsers = $ref([]);
-if (props.initialVisibleUsers) {
-	props.initialVisibleUsers.forEach(pushVisibleUser);
-}
-let autocomplete = $ref(null);
-let draghover = $ref(false);
-let quoteId = $ref(null);
-let hasNotSpecifiedMentions = $ref(false);
-let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]'));
-let imeText = $ref('');
-
-const typing = throttle(3000, () => {
-	if (props.channel) {
-		stream.send('typingOnChannel', { channel: props.channel.id });
-	}
-});
-
-const draftKey = $computed((): string => {
-	let key = props.channel ? `channel:${props.channel.id}` : '';
-
-	if (props.renote) {
-		key += `renote:${props.renote.id}`;
-	} else if (props.reply) {
-		key += `reply:${props.reply.id}`;
-	} else {
-		key += 'note';
-	}
-
-	return key;
-});
-
-const placeholder = $computed((): string => {
-	if (props.renote) {
-		return i18n.ts._postForm.quotePlaceholder;
-	} else if (props.reply) {
-		return i18n.ts._postForm.replyPlaceholder;
-	} else if (props.channel) {
-		return i18n.ts._postForm.channelPlaceholder;
-	} else {
-		const xs = [
-			i18n.ts._postForm._placeholders.a,
-			i18n.ts._postForm._placeholders.b,
-			i18n.ts._postForm._placeholders.c,
-			i18n.ts._postForm._placeholders.d,
-			i18n.ts._postForm._placeholders.e,
-			i18n.ts._postForm._placeholders.f,
-		];
-		return xs[Math.floor(Math.random() * xs.length)];
-	}
-});
-
-const submitText = $computed((): string => {
-	return props.renote
-		? i18n.ts.quote
-		: props.reply
-			? i18n.ts.reply
-			: i18n.ts.note;
-});
-
-const textLength = $computed((): number => {
-	return length((text + imeText).trim());
-});
-
-const maxTextLength = $computed((): number => {
-	return instance ? instance.maxNoteTextLength : 1000;
-});
-
-const canPost = $computed((): boolean => {
-	return !posting &&
-		(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
-		(textLength <= maxTextLength) &&
-		(!poll || poll.choices.length >= 2);
-});
-
-const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
-const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
-
-watch($$(text), () => {
-	checkMissingMention();
-});
-
-watch($$(visibleUsers), () => {
-	checkMissingMention();
-}, {
-	deep: true,
-});
-
-if (props.mention) {
-	text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
-	text += ' ';
-}
-
-if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
-	text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
-}
-
-if (props.reply && props.reply.text != null) {
-	const ast = mfm.parse(props.reply.text);
-	const otherHost = props.reply.user.host;
-
-	for (const x of extractMentions(ast)) {
-		const mention = x.host ?
-			`@${x.username}@${toASCII(x.host)}` :
-			(otherHost == null || otherHost === host) ?
-				`@${x.username}` :
-				`@${x.username}@${toASCII(otherHost)}`;
-
-		// 自分は除外
-		if ($i.username === x.username && (x.host == null || x.host === host)) continue;
-
-		// 重複は除外
-		if (text.includes(`${mention} `)) continue;
-
-		text += `${mention} `;
-	}
-}
-
-if (props.channel) {
-	visibility = 'public';
-	localOnly = true; // TODO: チャンネルが連合するようになった折には消す
-}
-
-// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
-	if (props.reply.visibility === 'home' && visibility === 'followers') {
-		visibility = 'followers';
-	} else if (['home', 'followers'].includes(props.reply.visibility) && visibility === 'specified') {
-		visibility = 'specified';
-	} else {
-		visibility = props.reply.visibility;
-	}
-
-	if (visibility === 'specified') {
-		if (props.reply.visibleUserIds) {
-			os.api('users/show', {
-				userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId),
-			}).then(users => {
-				users.forEach(pushVisibleUser);
-			});
-		}
-
-		if (props.reply.userId !== $i.id) {
-			os.api('users/show', { userId: props.reply.userId }).then(user => {
-				pushVisibleUser(user);
-			});
-		}
-	}
-}
-
-if (props.specified) {
-	visibility = 'specified';
-	pushVisibleUser(props.specified);
-}
-
-// keep cw when reply
-if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
-	useCw = true;
-	cw = props.reply.cw;
-}
-
-function watchForDraft() {
-	watch($$(text), () => saveDraft());
-	watch($$(useCw), () => saveDraft());
-	watch($$(cw), () => saveDraft());
-	watch($$(poll), () => saveDraft());
-	watch($$(files), () => saveDraft(), { deep: true });
-	watch($$(visibility), () => saveDraft());
-	watch($$(localOnly), () => saveDraft());
-}
-
-function checkMissingMention() {
-	if (visibility === 'specified') {
-		const ast = mfm.parse(text);
-
-		for (const x of extractMentions(ast)) {
-			if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
-				hasNotSpecifiedMentions = true;
-				return;
-			}
-		}
-		hasNotSpecifiedMentions = false;
-	}
-}
-
-function addMissingMention() {
-	const ast = mfm.parse(text);
-
-	for (const x of extractMentions(ast)) {
-		if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
-			os.api('users/show', { username: x.username, host: x.host }).then(user => {
-				visibleUsers.push(user);
-			});
-		}
-	}
-}
-
-function togglePoll() {
-	if (poll) {
-		poll = null;
-	} else {
-		poll = {
-			choices: ['', ''],
-			multiple: false,
-			expiresAt: null,
-			expiredAfter: null,
-		};
-	}
-}
-
-function addTag(tag: string) {
-	insertTextAtCursor(textareaEl, ` #${tag} `);
-}
-
-function focus() {
-	if (textareaEl) {
-		textareaEl.focus();
-		textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length);
-	}
-}
-
-function chooseFileFrom(ev) {
-	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
-		for (const file of files_) {
-			files.push(file);
-		}
-	});
-}
-
-function detachFile(id) {
-	files = files.filter(x => x.id !== id);
-}
-
-function updateFileSensitive(file, sensitive) {
-	files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
-}
-
-function updateFileName(file, name) {
-	files[files.findIndex(x => x.id === file.id)].name = name;
-}
-
-function upload(file: File, name?: string) {
-	uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
-		files.push(res);
-	});
-}
-
-function setVisibility() {
-	if (props.channel) {
-		// TODO: information dialog
-		return;
-	}
-
-	os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
-		currentVisibility: visibility,
-		currentLocalOnly: localOnly,
-		src: visibilityButton,
-	}, {
-		changeVisibility: v => {
-			visibility = v;
-			if (defaultStore.state.rememberNoteVisibility) {
-				defaultStore.set('visibility', visibility);
-			}
-		},
-		changeLocalOnly: v => {
-			localOnly = v;
-			if (defaultStore.state.rememberNoteVisibility) {
-				defaultStore.set('localOnly', localOnly);
-			}
-		},
-	}, 'closed');
-}
-
-function pushVisibleUser(user) {
-	if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) {
-		visibleUsers.push(user);
-	}
-}
-
-function addVisibleUser() {
-	os.selectUser().then(user => {
-		pushVisibleUser(user);
-	});
-}
-
-function removeVisibleUser(user) {
-	visibleUsers = erase(user, visibleUsers);
-}
-
-function clear() {
-	text = '';
-	files = [];
-	poll = null;
-	quoteId = null;
-}
-
-function onKeydown(ev: KeyboardEvent) {
-	if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post();
-	if (ev.which === 27) emit('esc');
-	typing();
-}
-
-function onCompositionUpdate(ev: CompositionEvent) {
-	imeText = ev.data;
-	typing();
-}
-
-function onCompositionEnd(ev: CompositionEvent) {
-	imeText = '';
-}
-
-async function onPaste(ev: ClipboardEvent) {
-	for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({ item, i }))) {
-		if (item.kind === 'file') {
-			const file = item.getAsFile();
-			const lio = file.name.lastIndexOf('.');
-			const ext = lio >= 0 ? file.name.slice(lio) : '';
-			const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
-			upload(file, formatted);
-		}
-	}
-
-	const paste = ev.clipboardData.getData('text');
-
-	if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) {
-		ev.preventDefault();
-
-		os.confirm({
-			type: 'info',
-			text: i18n.ts.quoteQuestion,
-		}).then(({ canceled }) => {
-			if (canceled) {
-				insertTextAtCursor(textareaEl, paste);
-				return;
-			}
-
-			quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
-		});
-	}
-}
-
-function onDragover(ev) {
-	if (!ev.dataTransfer.items[0]) return;
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-	if (isFile || isDriveFile) {
-		ev.preventDefault();
-		draghover = true;
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	}
-}
-
-function onDragenter(ev) {
-	draghover = true;
-}
-
-function onDragleave(ev) {
-	draghover = false;
-}
-
-function onDrop(ev): void {
-	draghover = false;
-
-	// ファイルだったら
-	if (ev.dataTransfer.files.length > 0) {
-		ev.preventDefault();
-		for (const x of Array.from(ev.dataTransfer.files)) upload(x);
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		const file = JSON.parse(driveFile);
-		files.push(file);
-		ev.preventDefault();
-	}
-	//#endregion
-}
-
-function saveDraft() {
-	const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
-
-	draftData[draftKey] = {
-		updatedAt: new Date(),
-		data: {
-			text: text,
-			useCw: useCw,
-			cw: cw,
-			visibility: visibility,
-			localOnly: localOnly,
-			files: files,
-			poll: poll,
-		},
-	};
-
-	localStorage.setItem('drafts', JSON.stringify(draftData));
-}
-
-function deleteDraft() {
-	const draftData = JSON.parse(localStorage.getItem('drafts') ?? '{}');
-
-	delete draftData[draftKey];
-
-	localStorage.setItem('drafts', JSON.stringify(draftData));
-}
-
-async function post() {
-	let postData = {
-		text: text === '' ? undefined : text,
-		fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
-		replyId: props.reply ? props.reply.id : undefined,
-		renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
-		channelId: props.channel ? props.channel.id : undefined,
-		poll: poll,
-		cw: useCw ? cw ?? '' : undefined,
-		localOnly: localOnly,
-		visibility: visibility,
-		visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
-	};
-
-	if (withHashtags && hashtags && hashtags.trim() !== '') {
-		const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
-		postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_;
-	}
-
-	// plugin
-	if (notePostInterruptors.length > 0) {
-		for (const interruptor of notePostInterruptors) {
-			postData = await interruptor.handler(deepClone(postData));
-		}
-	}
-
-	let token = undefined;
-
-	if (postAccount) {
-		const storedAccounts = await getAccounts();
-		token = storedAccounts.find(x => x.id === postAccount.id)?.token;
-	}
-
-	posting = true;
-	os.api('notes/create', postData, token).then(() => {
-		clear();
-		nextTick(() => {
-			deleteDraft();
-			emit('posted');
-			if (postData.text && postData.text !== '') {
-				const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
-				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
-				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history))));
-			}
-			posting = false;
-			postAccount = null;
-		});
-	}).catch(err => {
-		posting = false;
-		os.alert({
-			type: 'error',
-			text: err.message + '\n' + (err as any).id,
-		});
-	});
-}
-
-function cancel() {
-	emit('cancel');
-}
-
-function insertMention() {
-	os.selectUser().then(user => {
-		insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' ');
-	});
-}
-
-async function insertEmoji(ev: MouseEvent) {
-	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl);
-}
-
-function showActions(ev) {
-	os.popupMenu(postFormActions.map(action => ({
-		text: action.title,
-		action: () => {
-			action.handler({
-				text: text,
-			}, (key, value) => {
-				if (key === 'text') { text = value; }
-			});
-		},
-	})), ev.currentTarget ?? ev.target);
-}
-
-let postAccount = $ref<misskey.entities.UserDetailed | null>(null);
-
-function openAccountMenu(ev: MouseEvent) {
-	openAccountMenu_({
-		withExtraOperation: false,
-		includeCurrentAccount: true,
-		active: postAccount != null ? postAccount.id : $i.id,
-		onChoose: (account) => {
-			if (account.id === $i.id) {
-				postAccount = null;
-			} else {
-				postAccount = account;
-			}
-		},
-	}, ev);
-}
-
-onMounted(() => {
-	if (props.autofocus) {
-		focus();
-
-		nextTick(() => {
-			focus();
-		});
-	}
-
-	// TODO: detach when unmount
-	new Autocomplete(textareaEl, $$(text));
-	new Autocomplete(cwInputEl, $$(cw));
-	new Autocomplete(hashtagsInputEl, $$(hashtags));
-
-	nextTick(() => {
-		// 書きかけの投稿を復元
-		if (!props.instant && !props.mention && !props.specified) {
-			const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
-			if (draft) {
-				text = draft.data.text;
-				useCw = draft.data.useCw;
-				cw = draft.data.cw;
-				visibility = draft.data.visibility;
-				localOnly = draft.data.localOnly;
-				files = (draft.data.files || []).filter(draftFile => draftFile);
-				if (draft.data.poll) {
-					poll = draft.data.poll;
-				}
-			}
-		}
-
-		// 削除して編集
-		if (props.initialNote) {
-			const init = props.initialNote;
-			text = init.text ? init.text : '';
-			files = init.files;
-			cw = init.cw;
-			useCw = init.cw != null;
-			if (init.poll) {
-				poll = {
-					choices: init.poll.choices.map(x => x.text),
-					multiple: init.poll.multiple,
-					expiresAt: init.poll.expiresAt,
-					expiredAfter: init.poll.expiredAfter,
-				};
-			}
-			visibility = init.visibility;
-			localOnly = init.localOnly;
-			quoteId = init.renote ? init.renote.id : null;
-		}
-
-		nextTick(() => watchForDraft());
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-.gafaadew {
-	position: relative;
-
-	&.modal {
-		width: 100%;
-		max-width: 520px;
-	}
-
-	> header {
-		z-index: 1000;
-		height: 66px;
-
-		> .cancel {
-			padding: 0;
-			font-size: 1em;
-			width: 64px;
-			line-height: 66px;
-		}
-
-		> .account {
-			height: 100%;
-			aspect-ratio: 1/1;
-			display: inline-flex;
-			vertical-align: bottom;
-
-			> .avatar {
-				width: 28px;
-				height: 28px;
-				margin: auto;
-			}
-		}
-
-		> .right {
-			position: absolute;
-			top: 0;
-			right: 0;
-
-			> .text-count {
-				opacity: 0.7;
-				line-height: 66px;
-			}
-
-			> .visibility {
-				height: 34px;
-				width: 34px;
-				margin: 0 0 0 8px;
-
-				& + .localOnly {
-					margin-left: 0 !important;
-				}
-			}
-			
-			> .local-only {
-				margin: 0 0 0 12px;
-				opacity: 0.7;
-			}
-
-			> .preview {
-				display: inline-block;
-				padding: 0;
-				margin: 0 8px 0 0;
-				font-size: 16px;
-				width: 34px;
-				height: 34px;
-				border-radius: 6px;
-
-				&:hover {
-					background: var(--X5);
-				}
-
-				&.active {
-					color: var(--accent);
-				}
-			}
-
-			> .submit {
-				margin: 16px 16px 16px 0;
-				padding: 0 12px;
-				line-height: 34px;
-				font-weight: bold;
-				vertical-align: bottom;
-				border-radius: 4px;
-				font-size: 0.9em;
-
-				&:disabled {
-					opacity: 0.7;
-				}
-
-				> i {
-					margin-left: 6px;
-				}
-			}
-		}
-	}
-
-	> .form {
-		> .preview {
-			padding: 16px;
-		}
-
-		> .with-quote {
-			margin: 0 0 8px 0;
-			color: var(--accent);
-
-			> button {
-				padding: 4px 8px;
-				color: var(--accentAlpha04);
-
-				&:hover {
-					color: var(--accentAlpha06);
-				}
-
-				&:active {
-					color: var(--accentDarken30);
-				}
-			}
-		}
-
-		> .to-specified {
-			padding: 6px 24px;
-			margin-bottom: 8px;
-			overflow: auto;
-			white-space: nowrap;
-
-			> .visibleUsers {
-				display: inline;
-				top: -1px;
-				font-size: 14px;
-
-				> button {
-					padding: 4px;
-					border-radius: 8px;
-				}
-
-				> span {
-					margin-right: 14px;
-					padding: 8px 0 8px 8px;
-					border-radius: 8px;
-					background: var(--X4);
-
-					> button {
-						padding: 4px 8px;
-					}
-				}
-			}
-		}
-
-		> .hasNotSpecifiedMentions {
-			margin: 0 20px 16px 20px;
-		}
-
-		> .cw,
-		> .hashtags,
-		> .text {
-			display: block;
-			box-sizing: border-box;
-			padding: 0 24px;
-			margin: 0;
-			width: 100%;
-			font-size: 16px;
-			border: none;
-			border-radius: 0;
-			background: transparent;
-			color: var(--fg);
-			font-family: inherit;
-
-			&:focus {
-				outline: none;
-			}
-
-			&:disabled {
-				opacity: 0.5;
-			}
-		}
-
-		> .cw {
-			z-index: 1;
-			padding-bottom: 8px;
-			border-bottom: solid 0.5px var(--divider);
-		}
-
-		> .hashtags {
-			z-index: 1;
-			padding-top: 8px;
-			padding-bottom: 8px;
-			border-top: solid 0.5px var(--divider);
-		}
-
-		> .text {
-			max-width: 100%;
-			min-width: 100%;
-			min-height: 90px;
-
-			&.withCw {
-				padding-top: 8px;
-			}
-		}
-
-		> footer {
-			padding: 0 16px 16px 16px;
-
-			> button {
-				display: inline-block;
-				padding: 0;
-				margin: 0;
-				font-size: 1em;
-				width: 46px;
-				height: 46px;
-				border-radius: 6px;
-
-				&:hover {
-					background: var(--X5);
-				}
-
-				&.active {
-					color: var(--accent);
-				}
-			}
-		}
-	}
-
-	&.max-width_500px {
-		> header {
-			height: 50px;
-
-			> .cancel {
-				width: 50px;
-				line-height: 50px;
-			}
-
-			> .right {
-				> .text-count {
-					line-height: 50px;
-				}
-
-				> .submit {
-					margin: 8px;
-				}
-			}
-		}
-
-		> .form {
-			> .to-specified {
-				padding: 6px 16px;
-			}
-
-			> .cw,
-			> .hashtags,
-			> .text {
-				padding: 0 16px;
-			}
-
-			> .text {
-				min-height: 80px;
-			}
-
-			> footer {
-				padding: 0 8px 8px 8px;
-			}
-		}
-	}
-
-	&.max-width_310px {
-		> .form {
-			> footer {
-				> button {
-					font-size: 14px;
-					width: 44px;
-				height: 44px;
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 500px) {
-	.gafaadew {
-		> header {
-			height: 50px;
-
-			> .cancel {
-				width: 50px;
-				line-height: 50px;
-			}
-
-			> .right {
-				> .text-count {
-					line-height: 50px;
-				}
-
-				> .submit {
-					margin: 8px;
-				}
-			}
-		}
-
-		> .form {
-			> .to-specified {
-				padding: 6px 16px;
-			}
-
-			> .cw,
-			> .hashtags,
-			> .text {
-				padding: 0 16px;
-			}
-
-			> .text {
-				min-height: 80px;
-			}
-
-			> footer {
-				padding: 0 8px 8px 8px;
-			}
-		}
-	}
-}
-
-@container (max-width: 310px) {
-	.gafaadew {
-		> .form {
-			> footer {
-				> button {
-					font-size: 14px;
-					width: 44px;
-					height: 44px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPostFormAttaches.vue b/packages/client/src/components/MkPostFormAttaches.vue
deleted file mode 100644
index 766cc9a06c..0000000000
--- a/packages/client/src/components/MkPostFormAttaches.vue
+++ /dev/null
@@ -1,168 +0,0 @@
-<template>
-<div v-show="props.modelValue.length != 0" class="skeikyzd">
-	<Sortable :model-value="props.modelValue" class="files" item-key="id" :animation="150" :delay="100" :delay-on-touch-only="true" @update:model-value="v => emit('update:modelValue', v)">
-		<template #item="{element}">
-			<div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
-				<MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
-				<div v-if="element.isSensitive" class="sensitive">
-					<i class="ti ti-alert-triangle icon"></i>
-				</div>
-			</div>
-		</template>
-	</Sortable>
-	<p class="remain">{{ 16 - props.modelValue.length }}/16</p>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, watch } from 'vue';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import * as os from '@/os';
-import { deepClone } from '@/scripts/clone';
-import { i18n } from '@/i18n';
-
-const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
-
-const props = defineProps<{
-	modelValue: any[];
-	detachMediaFn: () => void;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any[]): void;
-	(ev: 'detach'): void;
-	(ev: 'changeSensitive'): void;
-	(ev: 'changeName'): void;
-}>();
-
-let menuShowing = false;
-
-function detachMedia(id) {
-	if (props.detachMediaFn) {
-		props.detachMediaFn(id);
-	} else {
-		emit('detach', id);
-	}
-}
-
-function toggleSensitive(file) {
-	os.api('drive/files/update', {
-		fileId: file.id,
-		isSensitive: !file.isSensitive,
-	}).then(() => {
-		emit('changeSensitive', file, !file.isSensitive);
-	});
-}
-async function rename(file) {
-	const { canceled, result } = await os.inputText({
-		title: i18n.ts.enterFileName,
-		default: file.name,
-		allowEmpty: false,
-	});
-	if (canceled) return;
-	os.api('drive/files/update', {
-		fileId: file.id,
-		name: result,
-	}).then(() => {
-		emit('changeName', file, result);
-		file.name = result;
-	});
-}
-
-async function describe(file) {
-	os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
-		default: file.comment !== null ? file.comment : '',
-		file: file,
-	}, {
-		done: caption => {
-			let comment = caption.length === 0 ? null : caption;
-			os.api('drive/files/update', {
-				fileId: file.id,
-				comment: comment,
-			}).then(() => {
-				file.comment = comment;
-			});
-		},
-	}, 'closed');
-}
-
-function showFileMenu(file, ev: MouseEvent) {
-	if (menuShowing) return;
-	os.popupMenu([{
-		text: i18n.ts.renameFile,
-		icon: 'ti ti-forms',
-		action: () => { rename(file); },
-	}, {
-		text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
-		icon: file.isSensitive ? 'ti ti-eye-off' : 'ti ti-eye',
-		action: () => { toggleSensitive(file); },
-	}, {
-		text: i18n.ts.describeFile,
-		icon: 'ti ti-text-caption',
-		action: () => { describe(file); },
-	}, {
-		text: i18n.ts.attachCancel,
-		icon: 'ti ti-circle-x',
-		action: () => { detachMedia(file.id); },
-	}], ev.currentTarget ?? ev.target).then(() => menuShowing = false);
-	menuShowing = true;
-}
-</script>
-
-<style lang="scss" scoped>
-.skeikyzd {
-	padding: 8px 16px;
-	position: relative;
-
-	> .files {
-		display: flex;
-		flex-wrap: wrap;
-
-		> .file {
-			position: relative;
-			width: 64px;
-			height: 64px;
-			margin-right: 4px;
-			border-radius: 4px;
-			overflow: hidden;
-			cursor: move;
-
-			&:hover > .remove {
-				display: block;
-			}
-
-			> .thumbnail {
-				width: 100%;
-				height: 100%;
-				z-index: 1;
-				color: var(--fg);
-			}
-
-			> .sensitive {
-				display: flex;
-				position: absolute;
-				width: 64px;
-				height: 64px;
-				top: 0;
-				left: 0;
-				z-index: 2;
-				background: rgba(17, 17, 17, .7);
-				color: #fff;
-
-				> .icon {
-					margin: auto;
-				}
-			}
-		}
-	}
-
-	> .remain {
-		display: block;
-		position: absolute;
-		top: 8px;
-		right: 8px;
-		margin: 0;
-		padding: 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkPostFormDialog.vue b/packages/client/src/components/MkPostFormDialog.vue
deleted file mode 100644
index 6dabb1db14..0000000000
--- a/packages/client/src/components/MkPostFormDialog.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<template>
-<MkModal ref="modal" :prefer-type="'dialog:top'" @click="$refs.modal.close()" @closed="$emit('closed')">
-	<MkPostForm v-bind="$attrs" @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()"/>
-</MkModal>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import MkPostForm from '@/components/MkPostForm.vue';
-
-export default defineComponent({
-	components: {
-		MkModal,
-		MkPostForm,
-	},
-	emits: ['closed'],
-});
-</script>
diff --git a/packages/client/src/components/MkPushNotificationAllowButton.vue b/packages/client/src/components/MkPushNotificationAllowButton.vue
deleted file mode 100644
index b356fd3b89..0000000000
--- a/packages/client/src/components/MkPushNotificationAllowButton.vue
+++ /dev/null
@@ -1,167 +0,0 @@
-<template>
-<MkButton
-	v-if="supported && !pushRegistrationInServer"
-	type="button"
-	primary
-	:gradate="gradate"
-	:rounded="rounded"
-	:inline="inline"
-	:autofocus="autofocus"
-	:wait="wait"
-	:full="full"
-	@click="subscribe"
->
-	{{ i18n.ts.subscribePushNotification }}
-</MkButton>
-<MkButton
-	v-else-if="!showOnlyToRegister && ($i ? pushRegistrationInServer : pushSubscription)"
-	type="button"
-	:primary="false"
-	:gradate="gradate"
-	:rounded="rounded"
-	:inline="inline"
-	:autofocus="autofocus"
-	:wait="wait"
-	:full="full"
-	@click="unsubscribe"
->
-	{{ i18n.ts.unsubscribePushNotification }}
-</MkButton>
-<MkButton v-else-if="$i && pushRegistrationInServer" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full">
-	{{ i18n.ts.pushNotificationAlreadySubscribed }}
-</MkButton>
-<MkButton v-else-if="!supported" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full">
-	{{ i18n.ts.pushNotificationNotSupported }}
-</MkButton>
-</template>
-
-<script setup lang="ts">
-import { $i, getAccounts } from '@/account';
-import MkButton from '@/components/MkButton.vue';
-import { instance } from '@/instance';
-import { api, apiWithDialog, promiseDialog } from '@/os';
-import { i18n } from '@/i18n';
-
-defineProps<{
-	primary?: boolean;
-	gradate?: boolean;
-	rounded?: boolean;
-	inline?: boolean;
-	link?: boolean;
-	to?: string;
-	autofocus?: boolean;
-	wait?: boolean;
-	danger?: boolean;
-	full?: boolean;
-	showOnlyToRegister?: boolean;
-}>();
-
-// ServiceWorker registration
-let registration = $ref<ServiceWorkerRegistration | undefined>();
-// If this browser supports push notification
-let supported = $ref(false);
-// If this browser has already subscribed to push notification
-let pushSubscription = $ref<PushSubscription | null>(null);
-let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>();
-
-function subscribe() {
-	if (!registration || !supported || !instance.swPublickey) return;
-
-	// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
-	return promiseDialog(registration.pushManager.subscribe({
-		userVisibleOnly: true,
-		applicationServerKey: urlBase64ToUint8Array(instance.swPublickey),
-	})
-	.then(async subscription => {
-		pushSubscription = subscription;
-
-		// Register
-		pushRegistrationInServer = await api('sw/register', {
-			endpoint: subscription.endpoint,
-			auth: encode(subscription.getKey('auth')),
-			publickey: encode(subscription.getKey('p256dh')),
-		});
-	}, async err => { // When subscribe failed
-		// 通知が許可されていなかったとき
-		if (err?.name === 'NotAllowedError') {
-			console.info('User denied the notification permission request.');
-			return;
-		}
-
-		// 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
-		// 既に存在していることが原因でエラーになった可能性があるので、
-		// そのサブスクリプションを解除しておく
-		// （これは実行されなさそうだけど、おまじない的に古い実装から残してある）
-		await unsubscribe();
-	}), null, null);
-}
-
-async function unsubscribe() {
-	if (!pushSubscription) return;
-
-	const endpoint = pushSubscription.endpoint;
-	const accounts = await getAccounts();
-
-	pushRegistrationInServer = undefined;
-
-	if ($i && accounts.length >= 2) {
-		apiWithDialog('sw/unregister', {
-			i: $i.token,
-			endpoint,
-		});
-	} else {
-		pushSubscription.unsubscribe();
-		apiWithDialog('sw/unregister', {
-			endpoint,
-		});
-		pushSubscription = null;
-	}
-}
-
-function encode(buffer: ArrayBuffer | null) {
-	return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
-}
-
-/**
- * Convert the URL safe base64 string to a Uint8Array
- * @param base64String base64 string
- */
- function urlBase64ToUint8Array(base64String: string): Uint8Array {
-	const padding = '='.repeat((4 - base64String.length % 4) % 4);
-	const base64 = (base64String + padding)
-		.replace(/-/g, '+')
-		.replace(/_/g, '/');
-
-	const rawData = window.atob(base64);
-	const outputArray = new Uint8Array(rawData.length);
-
-	for (let i = 0; i < rawData.length; ++i) {
-		outputArray[i] = rawData.charCodeAt(i);
-	}
-	return outputArray;
-}
-
-navigator.serviceWorker.ready.then(async swr => {
-	registration = swr;
-
-	pushSubscription = await registration.pushManager.getSubscription();
-
-	if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) {
-		supported = true;
-
-		if (pushSubscription) {
-			const res = await api('sw/show-registration', {
-				endpoint: pushSubscription.endpoint,
-			});
-
-			if (res) {
-				pushRegistrationInServer = res;
-			}
-		}
-	}
-});
-
-defineExpose({
-	pushRegistrationInServer: $$(pushRegistrationInServer),
-});
-</script>
diff --git a/packages/client/src/components/MkReactionIcon.vue b/packages/client/src/components/MkReactionIcon.vue
deleted file mode 100644
index 5638c9a816..0000000000
--- a/packages/client/src/components/MkReactionIcon.vue
+++ /dev/null
@@ -1,13 +0,0 @@
-<template>
-<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	reaction: string;
-	customEmojis?: any[]; // TODO
-	noStyle?: boolean;
-}>();
-</script>
diff --git a/packages/client/src/components/MkReactionTooltip.vue b/packages/client/src/components/MkReactionTooltip.vue
deleted file mode 100644
index 310d5954fc..0000000000
--- a/packages/client/src/components/MkReactionTooltip.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
-	<div class="beeadbfb">
-		<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
-		<div class="name">{{ reaction.replace('@.', '') }}</div>
-	</div>
-</MkTooltip>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkTooltip from './MkTooltip.vue';
-import XReactionIcon from '@/components/MkReactionIcon.vue';
-
-defineProps<{
-	showing: boolean;
-	reaction: string;
-	emojis: any[]; // TODO
-	targetElement: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.beeadbfb {
-	text-align: center;
-
-	> .icon {
-		display: block;
-		width: 60px;
-		font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
-		margin: 0 auto;
-		object-fit: contain;
-	}
-
-	> .name {
-		font-size: 0.9em;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkReactionsViewer.details.vue b/packages/client/src/components/MkReactionsViewer.details.vue
deleted file mode 100644
index 29902a5075..0000000000
--- a/packages/client/src/components/MkReactionsViewer.details.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
-	<div class="bqxuuuey">
-		<div class="reaction">
-			<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
-			<div class="name">{{ getReactionName(reaction) }}</div>
-		</div>
-		<div class="users">
-			<div v-for="u in users" :key="u.id" class="user">
-				<MkAvatar class="avatar" :user="u"/>
-				<MkUserName class="name" :user="u" :nowrap="true"/>
-			</div>
-			<div v-if="users.length > 10" class="omitted">+{{ count - 10 }}</div>
-		</div>
-	</div>
-</MkTooltip>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkTooltip from './MkTooltip.vue';
-import XReactionIcon from '@/components/MkReactionIcon.vue';
-import { getEmojiName } from '@/scripts/emojilist';
-
-defineProps<{
-	showing: boolean;
-	reaction: string;
-	users: any[]; // TODO
-	count: number;
-	emojis: any[]; // TODO
-	targetElement: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-function getReactionName(reaction: string): string {
-	const trimLocal = reaction.replace('@.', '');
-	if (trimLocal.startsWith(':')) {
-		return trimLocal;
-	}
-	return getEmojiName(reaction) ?? reaction;
-}
-</script>
-
-<style lang="scss" scoped>
-.bqxuuuey {
-	display: flex;
-
-	> .reaction {
-		max-width: 100px;
-		text-align: center;
-
-		> .icon {
-			display: block;
-			width: 60px;
-			font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
-			object-fit: contain;
-			margin: 0 auto;
-		}
-
-		> .name {
-			font-size: 1em;
-		}
-	}
-
-	> .users {
-		flex: 1;
-		min-width: 0;
-		font-size: 0.95em;
-		border-left: solid 0.5px var(--divider);
-		padding-left: 10px;
-		margin-left: 10px;
-		margin-right: 14px;
-		text-align: left;
-
-		> .user {
-			line-height: 24px;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-
-			&:not(:last-child) {
-				margin-bottom: 3px;
-			}
-
-			> .avatar {
-				width: 24px;
-				height: 24px;
-				margin-right: 3px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkReactionsViewer.reaction.vue b/packages/client/src/components/MkReactionsViewer.reaction.vue
deleted file mode 100644
index 31342b0b48..0000000000
--- a/packages/client/src/components/MkReactionsViewer.reaction.vue
+++ /dev/null
@@ -1,135 +0,0 @@
-<template>
-<button
-	v-if="count > 0"
-	ref="buttonRef"
-	v-ripple="canToggle"
-	class="hkzvhatu _button"
-	:class="{ reacted: note.myReaction == reaction, canToggle }"
-	@click="toggleReaction()"
->
-	<XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/>
-	<span class="count">{{ count }}</span>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { computed, onMounted, ref, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import XDetails from '@/components/MkReactionsViewer.details.vue';
-import XReactionIcon from '@/components/MkReactionIcon.vue';
-import * as os from '@/os';
-import { useTooltip } from '@/scripts/use-tooltip';
-import { $i } from '@/account';
-
-const props = defineProps<{
-	reaction: string;
-	count: number;
-	isInitial: boolean;
-	note: misskey.entities.Note;
-}>();
-
-const buttonRef = ref<HTMLElement>();
-
-const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
-
-const toggleReaction = () => {
-	if (!canToggle.value) return;
-
-	const oldReaction = props.note.myReaction;
-	if (oldReaction) {
-		os.api('notes/reactions/delete', {
-			noteId: props.note.id,
-		}).then(() => {
-			if (oldReaction !== props.reaction) {
-				os.api('notes/reactions/create', {
-					noteId: props.note.id,
-					reaction: props.reaction,
-				});
-			}
-		});
-	} else {
-		os.api('notes/reactions/create', {
-			noteId: props.note.id,
-			reaction: props.reaction,
-		});
-	}
-};
-
-const anime = () => {
-	if (document.hidden) return;
-
-	// TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション
-};
-
-watch(() => props.count, (newCount, oldCount) => {
-	if (oldCount < newCount) anime();
-});
-
-onMounted(() => {
-	if (!props.isInitial) anime();
-});
-
-useTooltip(buttonRef, async (showing) => {
-	const reactions = await os.apiGet('notes/reactions', {
-		noteId: props.note.id,
-		type: props.reaction,
-		limit: 11,
-		_cacheKey_: props.count,
-	});
-
-	const users = reactions.map(x => x.user);
-
-	os.popup(XDetails, {
-		showing,
-		reaction: props.reaction,
-		emojis: props.note.emojis,
-		users,
-		count: props.count,
-		targetElement: buttonRef.value,
-	}, {}, 'closed');
-}, 100);
-</script>
-
-<style lang="scss" scoped>
-.hkzvhatu {
-	display: inline-block;
-	height: 32px;
-	margin: 2px;
-	padding: 0 6px;
-	border-radius: 4px;
-
-	&.canToggle {
-		background: rgba(0, 0, 0, 0.05);
-
-		&:hover {
-			background: rgba(0, 0, 0, 0.1);
-		}
-	}
-
-	&:not(.canToggle) {
-		cursor: default;
-	}
-
-	&.reacted {
-		background: var(--accent);
-
-		&:hover {
-			background: var(--accent);
-		}
-
-		> .count {
-			color: var(--fgOnAccent);
-		}
-
-		> .icon {
-			filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
-		}
-	}
-
-	> .count {
-		font-size: 0.9em;
-		line-height: 32px;
-		margin: 0 0 0 4px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkReactionsViewer.vue b/packages/client/src/components/MkReactionsViewer.vue
deleted file mode 100644
index a88311efa1..0000000000
--- a/packages/client/src/components/MkReactionsViewer.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<template>
-<div class="tdflqwzn" :class="{ isMe }">
-	<XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import { $i } from '@/account';
-import XReaction from '@/components/MkReactionsViewer.reaction.vue';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-}>();
-
-const initialReactions = new Set(Object.keys(props.note.reactions));
-
-const isMe = computed(() => $i && $i.id === props.note.userId);
-</script>
-
-<style lang="scss" scoped>
-.tdflqwzn {
-	margin: 4px -2px 0 -2px;
-
-	&:empty {
-		display: none;
-	}
-
-	&.isMe {
-		> span {
-			cursor: default !important;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkRemoteCaution.vue b/packages/client/src/components/MkRemoteCaution.vue
deleted file mode 100644
index d5dc01c1f8..0000000000
--- a/packages/client/src/components/MkRemoteCaution.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<template>
-<div class="jmgmzlwq _block"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
-</template>
-
-<script lang="ts" setup>
-import { i18n } from '@/i18n';
-
-defineProps<{
-	href: string;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.jmgmzlwq {
-	font-size: 0.8em;
-	padding: 16px;
-	background: var(--infoWarnBg);
-	color: var(--infoWarnFg);
-
-	> .link {
-		margin-left: 4px;
-		color: var(--accent);
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkRenoteButton.vue b/packages/client/src/components/MkRenoteButton.vue
deleted file mode 100644
index e0b1eaafc9..0000000000
--- a/packages/client/src/components/MkRenoteButton.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
-<button
-	v-if="canRenote"
-	ref="buttonRef"
-	class="eddddedb _button canRenote"
-	@click="renote()"
->
-	<i class="ti ti-repeat"></i>
-	<p v-if="count > 0" class="count">{{ count }}</p>
-</button>
-<button v-else class="eddddedb _button">
-	<i class="ti ti-ban"></i>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref } from 'vue';
-import * as misskey from 'misskey-js';
-import XDetails from '@/components/MkUsersTooltip.vue';
-import { pleaseLogin } from '@/scripts/please-login';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { useTooltip } from '@/scripts/use-tooltip';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-	count: number;
-}>();
-
-const buttonRef = ref<HTMLElement>();
-
-const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
-
-useTooltip(buttonRef, async (showing) => {
-	const renotes = await os.api('notes/renotes', {
-		noteId: props.note.id,
-		limit: 11,
-	});
-
-	const users = renotes.map(x => x.user);
-
-	if (users.length < 1) return;
-
-	os.popup(XDetails, {
-		showing,
-		users,
-		count: props.count,
-		targetElement: buttonRef.value,
-	}, {}, 'closed');
-});
-
-const renote = (viaKeyboard = false) => {
-	pleaseLogin();
-	os.popupMenu([{
-		text: i18n.ts.renote,
-		icon: 'ti ti-repeat',
-		action: () => {
-			os.api('notes/create', {
-				renoteId: props.note.id,
-			});
-		},
-	}, {
-		text: i18n.ts.quote,
-		icon: 'ti ti-quote',
-		action: () => {
-			os.post({
-				renote: props.note,
-			});
-		},
-	}], buttonRef.value, {
-		viaKeyboard,
-	});
-};
-</script>
-
-<style lang="scss" scoped>
-.eddddedb {
-	display: inline-block;
-	height: 32px;
-	margin: 2px;
-	padding: 0 6px;
-	border-radius: 4px;
-
-	&:not(.canRenote) {
-		cursor: default;
-	}
-
-	&.renoted {
-		background: var(--accent);
-	}
-
-	> .count {
-		display: inline;
-		margin-left: 8px;
-		opacity: 0.7;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkRipple.vue b/packages/client/src/components/MkRipple.vue
deleted file mode 100644
index 9d93211d5f..0000000000
--- a/packages/client/src/components/MkRipple.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-<template>
-<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
-	<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
-		<circle fill="none" cx="64" cy="64">
-			<animate
-				attributeName="r"
-				begin="0s" dur="0.5s"
-				values="4; 32"
-				calcMode="spline"
-				keyTimes="0; 1"
-				keySplines="0.165, 0.84, 0.44, 1"
-				repeatCount="1"
-			/>
-			<animate
-				attributeName="stroke-width"
-				begin="0s" dur="0.5s"
-				values="16; 0"
-				calcMode="spline"
-				keyTimes="0; 1"
-				keySplines="0.3, 0.61, 0.355, 1"
-				repeatCount="1"
-			/>
-		</circle>
-		<g fill="none" fill-rule="evenodd">
-			<circle v-for="(particle, i) in particles" :key="i" :fill="particle.color">
-				<animate
-					attributeName="r"
-					begin="0s" dur="0.8s"
-					:values="`${particle.size}; 0`"
-					calcMode="spline"
-					keyTimes="0; 1"
-					keySplines="0.165, 0.84, 0.44, 1"
-					repeatCount="1"
-				/>
-				<animate
-					attributeName="cx"
-					begin="0s" dur="0.8s"
-					:values="`${particle.xA}; ${particle.xB}`"
-					calcMode="spline"
-					keyTimes="0; 1"
-					keySplines="0.3, 0.61, 0.355, 1"
-					repeatCount="1"
-				/>
-				<animate
-					attributeName="cy"
-					begin="0s" dur="0.8s"
-					:values="`${particle.yA}; ${particle.yB}`"
-					calcMode="spline"
-					keyTimes="0; 1"
-					keySplines="0.3, 0.61, 0.355, 1"
-					repeatCount="1"
-				/>
-			</circle>
-		</g>
-	</svg>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as os from '@/os';
-
-const props = withDefaults(defineProps<{
-	x: number;
-	y: number;
-	particle?: boolean;
-}>(), {
-	particle: true,
-});
-
-const emit = defineEmits<{
-	(ev: 'end'): void;
-}>();
-
-const particles = [];
-const origin = 64;
-const colors = ['#FF1493', '#00FFFF', '#FFE202'];
-const zIndex = os.claimZIndex('high');
-
-if (props.particle) {
-	for (let i = 0; i < 12; i++) {
-		const angle = Math.random() * (Math.PI * 2);
-		const pos = Math.random() * 16;
-		const velocity = 16 + (Math.random() * 48);
-		particles.push({
-			size: 4 + (Math.random() * 8),
-			xA: origin + (Math.sin(angle) * pos),
-			yA: origin + (Math.cos(angle) * pos),
-			xB: origin + (Math.sin(angle) * (pos + velocity)),
-			yB: origin + (Math.cos(angle) * (pos + velocity)),
-			color: colors[Math.floor(Math.random() * colors.length)],
-		});
-	}
-}
-
-onMounted(() => {
-	window.setTimeout(() => {
-		emit('end');
-	}, 1100);
-});
-</script>
-
-<style lang="scss" scoped>
-.vswabwbm {
-	pointer-events: none;
-	position: fixed;
-	width: 128px;
-	height: 128px;
-
-	> svg {
-		> circle {
-			stroke: var(--accent);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkSample.vue b/packages/client/src/components/MkSample.vue
deleted file mode 100644
index 1d25ab54b5..0000000000
--- a/packages/client/src/components/MkSample.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-<template>
-<div class="_card">
-	<div class="_content">
-		<MkInput v-model="text">
-			<template #label>Text</template>
-		</MkInput>
-		<MkSwitch v-model="flag">
-			<span>Switch is now {{ flag ? 'on' : 'off' }}</span>
-		</MkSwitch>
-		<div style="margin: 32px 0;">
-			<MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
-			<MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
-			<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
-		</div>
-		<MkButton inline>This is</MkButton>
-		<MkButton inline primary>the button</MkButton>
-	</div>
-	<div class="_content" style="pointer-events: none;">
-		<Mfm :text="mfm"/>
-	</div>
-	<div class="_content">
-		<MkButton inline primary @click="openMenu">Open menu</MkButton>
-		<MkButton inline primary @click="openDialog">Open dialog</MkButton>
-		<MkButton inline primary @click="openForm">Open form</MkButton>
-		<MkButton inline primary @click="openDrive">Open drive</MkButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkRadio from '@/components/form/radio.vue';
-import * as os from '@/os';
-import * as config from '@/config';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		MkInput,
-		MkSwitch,
-		MkTextarea,
-		MkRadio,
-	},
-
-	data() {
-		return {
-			text: '',
-			flag: true,
-			radio: 'misskey',
-			mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`,
-		};
-	},
-
-	methods: {
-		async openDialog() {
-			os.alert({
-				type: 'warning',
-				title: 'Oh my Aichan',
-				text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
-			});
-		},
-
-		async openForm() {
-			os.form('Example form', {
-				foo: {
-					type: 'boolean',
-					default: true,
-					label: 'This is a boolean property',
-				},
-				bar: {
-					type: 'number',
-					default: 300,
-					label: 'This is a number property',
-				},
-				baz: {
-					type: 'string',
-					default: 'Misskey makes you happy.',
-					label: 'This is a string property',
-				},
-			});
-		},
-
-		async openDrive() {
-			os.selectDriveFile();
-		},
-
-		async selectUser() {
-			os.selectUser();
-		},
-
-		async openMenu(ev) {
-			os.popupMenu([{
-				type: 'label',
-				text: 'Fruits',
-			}, {
-				text: 'Create some apples',
-				action: () => {},
-			}, {
-				text: 'Read some oranges',
-				action: () => {},
-			}, {
-				text: 'Update some melons',
-				action: () => {},
-			}, null, {
-				text: 'Delete some bananas',
-				danger: true,
-				action: () => {},
-			}], ev.currentTarget ?? ev.target);
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/components/MkSignin.vue b/packages/client/src/components/MkSignin.vue
deleted file mode 100644
index 96f18f8d61..0000000000
--- a/packages/client/src/components/MkSignin.vue
+++ /dev/null
@@ -1,259 +0,0 @@
-<template>
-<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
-	<div class="auth _section _formRoot">
-		<div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
-		<MkInfo v-if="message">
-			{{ message }}
-		</MkInfo>
-		<div v-if="!totpLogin" class="normal-signin">
-			<MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:model-value="onUsernameChange">
-				<template #prefix>@</template>
-				<template #suffix>@{{ host }}</template>
-			</MkInput>
-			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
-				<template #prefix><i class="ti ti-lock"></i></template>
-				<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
-			</MkInput>
-			<MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
-		</div>
-		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
-			<div v-if="user && user.securityKeys" class="twofa-group tap-group">
-				<p>{{ i18n.ts.tapSecurityKey }}</p>
-				<MkButton v-if="!queryingKey" @click="queryKey">
-					{{ i18n.ts.retry }}
-				</MkButton>
-			</div>
-			<div v-if="user && user.securityKeys" class="or-hr">
-				<p class="or-msg">{{ i18n.ts.or }}</p>
-			</div>
-			<div class="twofa-group totp-group">
-				<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
-				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required>
-					<template #label>{{ i18n.ts.password }}</template>
-					<template #prefix><i class="ti ti-lock"></i></template>
-				</MkInput>
-				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required>
-					<template #label>{{ i18n.ts.token }}</template>
-					<template #prefix><i class="ti ti-123"></i></template>
-				</MkInput>
-				<MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
-			</div>
-		</div>
-	</div>
-	<div class="social _section">
-		<a v-if="meta && meta.enableTwitterIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/twitter`"><i class="ti ti-brand-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a>
-		<a v-if="meta && meta.enableGithubIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/github`"><i class="ti ti-brand-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a>
-		<a v-if="meta && meta.enableDiscordIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/discord`"><i class="ti ti-brand-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a>
-	</div>
-</form>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import { toUnicode } from 'punycode/';
-import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import { apiUrl, host as configHost } from '@/config';
-import { byteify, hexify } from '@/scripts/2fa';
-import * as os from '@/os';
-import { login } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-let signing = $ref(false);
-let user = $ref(null);
-let username = $ref('');
-let password = $ref('');
-let token = $ref('');
-let host = $ref(toUnicode(configHost));
-let totpLogin = $ref(false);
-let credential = $ref(null);
-let challengeData = $ref(null);
-let queryingKey = $ref(false);
-let hCaptchaResponse = $ref(null);
-let reCaptchaResponse = $ref(null);
-
-const meta = $computed(() => instance);
-
-const emit = defineEmits<{
-	(ev: 'login', v: any): void;
-}>();
-
-const props = defineProps({
-	withAvatar: {
-		type: Boolean,
-		required: false,
-		default: true,
-	},
-	autoSet: {
-		type: Boolean,
-		required: false,
-		default: false,
-	},
-	message: {
-		type: String,
-		required: false,
-		default: '',
-	},
-});
-
-function onUsernameChange() {
-	os.api('users/show', {
-		username: username,
-	}).then(userResponse => {
-		user = userResponse;
-	}, () => {
-		user = null;
-	});
-}
-
-function onLogin(res) {
-	if (props.autoSet) {
-		return login(res.i);
-	}
-}
-
-function queryKey() {
-	queryingKey = true;
-	return navigator.credentials.get({
-		publicKey: {
-			challenge: byteify(challengeData.challenge, 'base64'),
-			allowCredentials: challengeData.securityKeys.map(key => ({
-				id: byteify(key.id, 'hex'),
-				type: 'public-key',
-				transports: ['usb', 'nfc', 'ble', 'internal'],
-			})),
-			timeout: 60 * 1000,
-		},
-	}).catch(() => {
-		queryingKey = false;
-		return Promise.reject(null);
-	}).then(credential => {
-		queryingKey = false;
-		signing = true;
-		return os.api('signin', {
-			username,
-			password,
-			signature: hexify(credential.response.signature),
-			authenticatorData: hexify(credential.response.authenticatorData),
-			clientDataJSON: hexify(credential.response.clientDataJSON),
-			credentialId: credential.id,
-			challengeId: challengeData.challengeId,
-			'hcaptcha-response': hCaptchaResponse,
-			'g-recaptcha-response': reCaptchaResponse,
-		});
-	}).then(res => {
-		emit('login', res);
-		return onLogin(res);
-	}).catch(err => {
-		if (err === null) return;
-		os.alert({
-			type: 'error',
-			text: i18n.ts.signinFailed,
-		});
-		signing = false;
-	});
-}
-
-function onSubmit() {
-	signing = true;
-	console.log('submit');
-	if (!totpLogin && user && user.twoFactorEnabled) {
-		if (window.PublicKeyCredential && user.securityKeys) {
-			os.api('signin', {
-				username,
-				password,
-				'hcaptcha-response': hCaptchaResponse,
-				'g-recaptcha-response': reCaptchaResponse,
-			}).then(res => {
-				totpLogin = true;
-				signing = false;
-				challengeData = res;
-				return queryKey();
-			}).catch(loginFailed);
-		} else {
-			totpLogin = true;
-			signing = false;
-		}
-	} else {
-		os.api('signin', {
-			username,
-			password,
-			'hcaptcha-response': hCaptchaResponse,
-			'g-recaptcha-response': reCaptchaResponse,
-			token: user && user.twoFactorEnabled ? token : undefined,
-		}).then(res => {
-			emit('login', res);
-			onLogin(res);
-		}).catch(loginFailed);
-	}
-}
-
-function loginFailed(err) {
-	switch (err.id) {
-		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
-			os.alert({
-				type: 'error',
-				title: i18n.ts.loginFailed,
-				text: i18n.ts.noSuchUser,
-			});
-			break;
-		}
-		case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': {
-			os.alert({
-				type: 'error',
-				title: i18n.ts.loginFailed,
-				text: i18n.ts.incorrectPassword,
-			});
-			break;
-		}
-		case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
-			showSuspendedDialog();
-			break;
-		}
-		case '22d05606-fbcf-421a-a2db-b32610dcfd1b': {
-			os.alert({
-				type: 'error',
-				title: i18n.ts.loginFailed,
-				text: i18n.ts.rateLimitExceeded,
-			});
-			break;
-		}
-		default: {
-			console.log(err);
-			os.alert({
-				type: 'error',
-				title: i18n.ts.loginFailed,
-				text: JSON.stringify(err),
-			});
-		}
-	}
-
-	challengeData = null;
-	totpLogin = false;
-	signing = false;
-}
-
-function resetPassword() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
-	}, 'closed');
-}
-</script>
-
-<style lang="scss" scoped>
-.eppvobhk {
-	> .auth {
-		> .avatar {
-			margin: 0 auto 0 auto;
-			width: 64px;
-			height: 64px;
-			background: #ddd;
-			background-position: center;
-			background-size: cover;
-			border-radius: 100%;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkSigninDialog.vue b/packages/client/src/components/MkSigninDialog.vue
deleted file mode 100644
index fd27244516..0000000000
--- a/packages/client/src/components/MkSigninDialog.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="370"
-	:height="400"
-	@close="onClose"
-	@closed="emit('closed')"
->
-	<template #header>{{ i18n.ts.login }}</template>
-
-	<MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkSignin from '@/components/MkSignin.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	autoSet?: boolean;
-	message?: string,
-}>(), {
-	autoSet: false,
-	message: '',
-});
-
-const emit = defineEmits<{
-	(ev: 'done'): void;
-	(ev: 'closed'): void;
-	(ev: 'cancelled'): void;
-}>();
-
-const dialog = $ref<InstanceType<typeof XModalWindow>>();
-
-function onClose() {
-	emit('cancelled');
-	dialog.close();
-}
-
-function onLogin(res) {
-	emit('done', res);
-	dialog.close();
-}
-</script>
diff --git a/packages/client/src/components/MkSignup.vue b/packages/client/src/components/MkSignup.vue
deleted file mode 100644
index d987425ca3..0000000000
--- a/packages/client/src/components/MkSignup.vue
+++ /dev/null
@@ -1,246 +0,0 @@
-<template>
-<form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit">
-	<MkInput v-if="instance.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required>
-		<template #label>{{ i18n.ts.invitationCode }}</template>
-		<template #prefix><i class="ti ti-key"></i></template>
-	</MkInput>
-	<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
-		<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
-		<template #prefix>@</template>
-		<template #suffix>@{{ host }}</template>
-		<template #caption>
-			<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
-			<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
-			<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
-			<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
-			<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
-			<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
-			<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
-		</template>
-	</MkInput>
-	<MkInput v-if="instance.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
-		<template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
-		<template #prefix><i class="ti ti-mail"></i></template>
-		<template #caption>
-			<span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
-			<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
-			<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
-			<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
-			<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
-			<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
-			<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
-			<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
-			<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
-		</template>
-	</MkInput>
-	<MkInput v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
-		<template #label>{{ i18n.ts.password }}</template>
-		<template #prefix><i class="ti ti-lock"></i></template>
-		<template #caption>
-			<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
-			<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
-			<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
-		</template>
-	</MkInput>
-	<MkInput v-model="retypedPassword" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
-		<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
-		<template #prefix><i class="ti ti-lock"></i></template>
-		<template #caption>
-			<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
-			<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
-		</template>
-	</MkInput>
-	<MkSwitch v-if="instance.tosUrl" v-model="ToSAgreement" class="_formBlock tou">
-		<I18n :src="i18n.ts.agreeTo">
-			<template #0>
-				<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a>
-			</template>
-		</I18n>
-	</MkSwitch>
-	<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
-	<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
-	<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="_formBlock captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
-	<MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
-</form>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import getPasswordStrength from 'syuilo-password-strength';
-import { toUnicode } from 'punycode/';
-import MkButton from './MkButton.vue';
-import MkInput from './form/input.vue';
-import MkSwitch from './form/switch.vue';
-import MkCaptcha from '@/components/MkCaptcha.vue';
-import * as config from '@/config';
-import * as os from '@/os';
-import { login } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	autoSet?: boolean;
-}>(), {
-	autoSet: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'signup', user: Record<string, any>): void;
-	(ev: 'signupEmailPending'): void;
-}>();
-
-const host = toUnicode(config.host);
-
-let hcaptcha = $ref();
-let recaptcha = $ref();
-let turnstile = $ref();
-
-let username: string = $ref('');
-let password: string = $ref('');
-let retypedPassword: string = $ref('');
-let invitationCode: string = $ref('');
-let email = $ref('');
-let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
-let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
-let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
-let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
-let submitting: boolean = $ref(false);
-let ToSAgreement: boolean = $ref(false);
-let hCaptchaResponse = $ref(null);
-let reCaptchaResponse = $ref(null);
-let turnstileResponse = $ref(null);
-
-const shouldDisableSubmitting = $computed((): boolean => {
-	return submitting ||
-		instance.tosUrl && !ToSAgreement ||
-		instance.enableHcaptcha && !hCaptchaResponse ||
-		instance.enableRecaptcha && !reCaptchaResponse ||
-		instance.enableTurnstile && !turnstileResponse ||
-		passwordRetypeState === 'not-match';
-});
-
-function onChangeUsername(): void {
-	if (username === '') {
-		usernameState = null;
-		return;
-	}
-
-	{
-		const err =
-			!username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
-			username.length < 1 ? 'min-range' :
-			username.length > 20 ? 'max-range' :
-			null;
-
-		if (err) {
-			usernameState = err;
-			return;
-		}
-	}
-
-	usernameState = 'wait';
-
-	os.api('username/available', {
-		username,
-	}).then(result => {
-		usernameState = result.available ? 'ok' : 'unavailable';
-	}).catch(() => {
-		usernameState = 'error';
-	});
-}
-
-function onChangeEmail(): void {
-	if (email === '') {
-		emailState = null;
-		return;
-	}
-
-	emailState = 'wait';
-
-	os.api('email-address/available', {
-		emailAddress: email,
-	}).then(result => {
-		emailState = result.available ? 'ok' :
-			result.reason === 'used' ? 'unavailable:used' :
-			result.reason === 'format' ? 'unavailable:format' :
-			result.reason === 'disposable' ? 'unavailable:disposable' :
-			result.reason === 'mx' ? 'unavailable:mx' :
-			result.reason === 'smtp' ? 'unavailable:smtp' :
-			'unavailable';
-	}).catch(() => {
-		emailState = 'error';
-	});
-}
-
-function onChangePassword(): void {
-	if (password === '') {
-		passwordStrength = '';
-		return;
-	}
-
-	const strength = getPasswordStrength(password);
-	passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
-}
-
-function onChangePasswordRetype(): void {
-	if (retypedPassword === '') {
-		passwordRetypeState = null;
-		return;
-	}
-
-	passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
-}
-
-function onSubmit(): void {
-	if (submitting) return;
-	submitting = true;
-
-	os.api('signup', {
-		username,
-		password,
-		emailAddress: email,
-		invitationCode,
-		'hcaptcha-response': hCaptchaResponse,
-		'g-recaptcha-response': reCaptchaResponse,
-		'turnstile-response': turnstileResponse,
-	}).then(() => {
-		if (instance.emailRequiredForSignup) {
-			os.alert({
-				type: 'success',
-				title: i18n.ts._signup.almostThere,
-				text: i18n.t('_signup.emailSent', { email }),
-			});
-			emit('signupEmailPending');
-		} else {
-			os.api('signin', {
-				username,
-				password,
-			}).then(res => {
-				emit('signup', res);
-
-				if (props.autoSet) {
-					login(res.i);
-				}
-			});
-		}
-	}).catch(() => {
-		submitting = false;
-		hcaptcha.reset?.();
-		recaptcha.reset?.();
-		turnstile.reset?.();
-
-		os.alert({
-			type: 'error',
-			text: i18n.ts.somethingHappened,
-		});
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.qlvuhzng {
-	.captcha {
-		margin: 16px 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkSignupDialog.vue b/packages/client/src/components/MkSignupDialog.vue
deleted file mode 100644
index 77497021c3..0000000000
--- a/packages/client/src/components/MkSignupDialog.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="366"
-	:height="500"
-	@close="dialog.close()"
-	@closed="$emit('closed')"
->
-	<template #header>{{ i18n.ts.signup }}</template>
-
-	<div class="_monolithic_">
-		<div class="_section">
-			<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
-		</div>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XSignup from '@/components/MkSignup.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	autoSet?: boolean;
-}>(), {
-	autoSet: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'done'): void;
-	(ev: 'closed'): void;
-}>();
-
-const dialog = $ref<InstanceType<typeof XModalWindow>>();
-
-function onSignup(res) {
-	emit('done', res);
-	dialog.close();
-}
-
-function onSignupEmailPending() {
-	dialog.close();
-}
-</script>
diff --git a/packages/client/src/components/MkSparkle.vue b/packages/client/src/components/MkSparkle.vue
deleted file mode 100644
index cdeaf9c417..0000000000
--- a/packages/client/src/components/MkSparkle.vue
+++ /dev/null
@@ -1,130 +0,0 @@
-<template>
-<span class="mk-sparkle">
-	<span ref="el">
-		<slot></slot>
-	</span>
-	<!-- なぜか path に対する key が機能しないため
-	<svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg">
-		<path v-for="particle in particles" :key="particle.id" style="transform-origin: center; transform-box: fill-box;"
-			:transform="`translate(${particle.x} ${particle.y})`"
-			:fill="particle.color"
-			d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
-		>
-			<animateTransform
-				attributeName="transform"
-				attributeType="XML"
-				type="rotate"
-				from="0 0 0"
-				to="360 0 0"
-				:dur="`${particle.dur}ms`"
-				repeatCount="indefinite"
-				additive="sum"
-			/>
-			<animateTransform
-				attributeName="transform"
-				attributeType="XML"
-				type="scale"
-				:values="`0; ${particle.size}; 0`"
-				:dur="`${particle.dur}ms`"
-				repeatCount="indefinite"
-				additive="sum"
-			/>
-		</path>
-	</svg>
-	-->
-	<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg">
-		<path
-			style="transform-origin: center; transform-box: fill-box;"
-			:transform="`translate(${particle.x} ${particle.y})`"
-			:fill="particle.color"
-			d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
-		>
-			<animateTransform
-				attributeName="transform"
-				attributeType="XML"
-				type="rotate"
-				from="0 0 0"
-				to="360 0 0"
-				:dur="`${particle.dur}ms`"
-				repeatCount="1"
-				additive="sum"
-			/>
-			<animateTransform
-				attributeName="transform"
-				attributeType="XML"
-				type="scale"
-				:values="`0; ${particle.size}; 0`"
-				:dur="`${particle.dur}ms`"
-				repeatCount="1"
-				additive="sum"
-			/>
-		</path>
-	</svg>
-</span>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-
-const particles = ref([]);
-const el = ref<HTMLElement>();
-const width = ref(0);
-const height = ref(0);
-const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202'];
-let stop = false;
-let ro: ResizeObserver | undefined;
-
-onMounted(() => {
-	ro = new ResizeObserver((entries, observer) => {
-		width.value = el.value?.offsetWidth + 64;
-		height.value = el.value?.offsetHeight + 64;
-	});
-	ro.observe(el.value);
-	const add = () => {
-		if (stop) return;
-		const x = (Math.random() * (width.value - 64));
-		const y = (Math.random() * (height.value - 64));
-		const sizeFactor = Math.random();
-		const particle = {
-			id: Math.random().toString(),
-			x,
-			y,
-			size: 0.2 + ((sizeFactor / 10) * 3),
-			dur: 1000 + (sizeFactor * 1000),
-			color: colors[Math.floor(Math.random() * colors.length)],
-		};
-		particles.value.push(particle);
-		window.setTimeout(() => {
-			particles.value = particles.value.filter(x => x.id !== particle.id);
-		}, particle.dur - 100);
-
-		window.setTimeout(() => {
-			add();
-		}, 500 + (Math.random() * 500));
-	};
-	add();
-});
-
-onUnmounted(() => {
-	if (ro) ro.disconnect();
-	stop = true;
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-sparkle {
-	position: relative;
-	display: inline-block;
-
-	> span {
-		display: inline-block;
-	}
-
-	> svg {
-		position: absolute;
-		top: -32px;
-		left: -32px;
-		pointer-events: none;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/MkSubNoteContent.vue
deleted file mode 100644
index 210923be46..0000000000
--- a/packages/client/src/components/MkSubNoteContent.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<div class="wrmlmaau" :class="{ collapsed }">
-	<div class="body">
-		<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
-		<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
-		<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
-		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
-		<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
-	</div>
-	<details v-if="note.files.length > 0">
-		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
-		<XMediaList :media-list="note.files"/>
-	</details>
-	<details v-if="note.poll">
-		<summary>{{ i18n.ts.poll }}</summary>
-		<XPoll :note="note"/>
-	</details>
-	<button v-if="collapsed" class="fade _button" @click="collapsed = false">
-		<span>{{ i18n.ts.showMore }}</span>
-	</button>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import XMediaList from '@/components/MkMediaList.vue';
-import XPoll from '@/components/MkPoll.vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	note: misskey.entities.Note;
-}>();
-
-const collapsed = $ref(
-	props.note.cw == null && props.note.text != null && (
-		(props.note.text.split('\n').length > 9) ||
-		(props.note.text.length > 500)
-	));
-</script>
-
-<style lang="scss" scoped>
-.wrmlmaau {
-	overflow-wrap: break-word;
-
-	> .body {
-		> .reply {
-			margin-right: 6px;
-			color: var(--accent);
-		}
-
-		> .rp {
-			margin-left: 4px;
-			font-style: oblique;
-			color: var(--renote);
-		}
-	}
-
-	&.collapsed {
-		position: relative;
-		max-height: 9em;
-		overflow: hidden;
-
-		> .fade {
-			display: block;
-			position: absolute;
-			bottom: 0;
-			left: 0;
-			width: 100%;
-			height: 64px;
-			background: linear-gradient(0deg, var(--panel), var(--X15));
-
-			> span {
-				display: inline-block;
-				background: var(--panel);
-				padding: 6px 10px;
-				font-size: 0.8em;
-				border-radius: 999px;
-				box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
-			}
-
-			&:hover {
-				> span {
-					background: var(--panelHighlight);
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkSuperMenu.vue b/packages/client/src/components/MkSuperMenu.vue
deleted file mode 100644
index e79794aea4..0000000000
--- a/packages/client/src/components/MkSuperMenu.vue
+++ /dev/null
@@ -1,161 +0,0 @@
-<template>
-<div class="rrevdjwu" :class="{ grid }">
-	<div v-for="group in def" class="group">
-		<div v-if="group.title" class="title">{{ group.title }}</div>
-
-		<div class="items">
-			<template v-for="(item, i) in group.items">
-				<a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
-					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
-					<span class="text">{{ item.text }}</span>
-				</a>
-				<button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)">
-					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
-					<span class="text">{{ item.text }}</span>
-				</button>
-				<MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
-					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
-					<span class="text">{{ item.text }}</span>
-				</MkA>
-			</template>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, ref, unref } from 'vue';
-
-export default defineComponent({
-	props: {
-		def: {
-			type: Array,
-			required: true,
-		},
-		grid: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.rrevdjwu {
-	> .group {
-		& + .group {
-			margin-top: 16px;
-			padding-top: 16px;
-			border-top: solid 0.5px var(--divider);
-		}
-
-		> .title {
-			opacity: 0.7;
-			margin: 0 0 8px 0;
-			font-size: 0.9em;
-		}
-	
-		> .items {
-			> .item {
-				display: flex;
-				align-items: center;
-				width: 100%;
-				box-sizing: border-box;
-				padding: 10px 16px 10px 8px;
-				border-radius: 9px;
-				font-size: 0.9em;
-
-				&:hover {
-					text-decoration: none;
-					background: var(--panelHighlight);
-				}
-
-				&.active {
-					color: var(--accent);
-					background: var(--accentedBg);
-				}
-
-				&.danger {
-					color: var(--error);
-				}
-
-				> .icon {
-					width: 32px;
-					margin-right: 2px;
-					flex-shrink: 0;
-					text-align: center;
-					opacity: 0.8;
-				}
-
-				> .text {
-					white-space: normal;
-					padding-right: 12px;
-					flex-shrink: 1;
-				}
-
-			}
-		}
-	}
-
-	&.grid {
-		> .group {
-			& + .group {
-				padding-top: 0;
-				border-top: none;
-			}
-
-			margin-left: 0;
-			margin-right: 0;
-
-			> .title {
-				font-size: 1em;
-				opacity: 0.7;
-				margin: 0 0 8px 16px;
-			}
-
-			> .items {
-				display: grid;
-				grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
-				grid-gap: 16px;
-				padding: 0 16px;
-
-				> .item {
-					flex-direction: column;
-					text-align: center;
-					padding: 0;
-
-					&:hover {
-						text-decoration: none;
-						background: none;
-						color: var(--accent);
-
-						> .icon {
-							background: var(--accentedBg);
-						}
-					}
-
-					> .icon {
-						display: grid;
-						place-content: center;
-						margin-right: 0;
-						margin-bottom: 6px;
-						font-size: 1.5em;
-						width: 54px;
-						height: 54px;
-						aspect-ratio: 1;
-						background: var(--panel);
-						border-radius: 100%;
-					}
-
-					> .text {
-						padding-right: 0;
-						width: 100%;
-						font-size: 0.8em;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkTab.vue b/packages/client/src/components/MkTab.vue
deleted file mode 100644
index 669e9e2e11..0000000000
--- a/packages/client/src/components/MkTab.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<script lang="ts">
-import { defineComponent, h, resolveDirective, withDirectives } from 'vue';
-
-export default defineComponent({
-	props: {
-		modelValue: {
-			required: true,
-		},
-	},
-	render() {
-		const options = this.$slots.default();
-
-		return withDirectives(h('div', {
-			class: 'pxhvhrfw',
-		}, options.map(option => withDirectives(h('button', {
-			class: ['_button', { active: this.modelValue === option.props.value }],
-			key: option.key,
-			disabled: this.modelValue === option.props.value,
-			onClick: () => {
-				this.$emit('update:modelValue', option.props.value);
-			},
-		}, option.children), [
-			[resolveDirective('click-anime')],
-		]))), [
-			[resolveDirective('size'), { max: [500] }],
-		]);
-	},
-});
-</script>
-
-<style lang="scss">
-.pxhvhrfw {
-	display: flex;
-	font-size: 90%;
-
-	> button {
-		flex: 1;
-		padding: 10px 8px;
-		border-radius: var(--radius);
-
-		&:disabled {
-			opacity: 1 !important;
-			cursor: default;
-		}
-
-		&.active {
-			color: var(--accent);
-			background: var(--accentedBg);
-		}
-
-		&:not(.active):hover {
-			color: var(--fgHighlighted);
-			background: var(--panelHighlight);
-		}
-
-		&:not(:first-child) {
-			margin-left: 8px;
-		}
-
-		> .icon {
-			margin-right: 6px;
-		}
-	}
-
-	&.max-width_500px {
-		font-size: 80%;
-
-		> button {
-			padding: 11px 8px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkTagCloud.vue b/packages/client/src/components/MkTagCloud.vue
deleted file mode 100644
index 2dfd26edb0..0000000000
--- a/packages/client/src/components/MkTagCloud.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<div ref="rootEl" class="meijqfqm">
-	<canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
-	<div :id="idForTags" ref="tagsEl" class="tags">
-		<ul>
-			<slot></slot>
-		</ul>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref, watch, PropType, onBeforeUnmount } from 'vue';
-import tinycolor from 'tinycolor2';
-
-const loaded = !!window.TagCanvas;
-const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
-const computedStyle = getComputedStyle(document.documentElement);
-const idForCanvas = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
-const idForTags = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
-let available = $ref(false);
-let rootEl = $ref<HTMLElement | null>(null);
-let canvasEl = $ref<HTMLCanvasElement | null>(null);
-let tagsEl = $ref<HTMLElement | null>(null);
-let width = $ref(300);
-
-watch($$(available), () => {
-	try {
-		window.TagCanvas.Start(idForCanvas, idForTags, {
-			textColour: '#ffffff',
-			outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(),
-			outlineRadius: 10,
-			initial: [-0.030, -0.010],
-			frontSelect: true,
-			imageRadius: 8,
-			//dragControl: true,
-			dragThreshold: 3,
-			wheelZoom: false,
-			reverse: true,
-			depth: 0.5,
-			maxSpeed: 0.2,
-			minSpeed: 0.003,
-			stretchX: 0.8,
-			stretchY: 0.8,
-		});
-	} catch (err) {}
-});
-
-onMounted(() => {
-	width = rootEl.offsetWidth;
-
-	if (loaded) {
-		available = true;
-	} else {
-		document.head.appendChild(Object.assign(document.createElement('script'), {
-			async: true,
-			src: '/client-assets/tagcanvas.min.js',
-		})).addEventListener('load', () => available = true);
-	}
-});
-
-onBeforeUnmount(() => {
-	if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
-});
-
-defineExpose({
-	update: () => {
-		window.TagCanvas.Update(idForCanvas);
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.meijqfqm {
-	position: relative;
-	overflow: clip;
-	display: grid;
-	place-items: center;
-
-	> .canvas {
-		display: block;
-	}
-
-	> .tags {
-		position: absolute;
-		top: 999px;
-		left: 999px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkTimeline.vue b/packages/client/src/components/MkTimeline.vue
deleted file mode 100644
index 831a194ce3..0000000000
--- a/packages/client/src/components/MkTimeline.vue
+++ /dev/null
@@ -1,143 +0,0 @@
-<template>
-<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed, provide, onUnmounted } from 'vue';
-import XNotes from '@/components/MkNotes.vue';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import * as sound from '@/scripts/sound';
-import { $i } from '@/account';
-
-const props = defineProps<{
-	src: string;
-	list?: string;
-	antenna?: string;
-	channel?: string;
-	sound?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'note'): void;
-	(ev: 'queue', count: number): void;
-}>();
-
-provide('inChannel', computed(() => props.src === 'channel'));
-
-const tlComponent: InstanceType<typeof XNotes> = $ref();
-
-const prepend = note => {
-	tlComponent.pagingComponent?.prepend(note);
-
-	emit('note');
-
-	if (props.sound) {
-		sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
-	}
-};
-
-const onUserAdded = () => {
-	tlComponent.pagingComponent?.reload();
-};
-
-const onUserRemoved = () => {
-	tlComponent.pagingComponent?.reload();
-};
-
-const onChangeFollowing = () => {
-	if (!tlComponent.pagingComponent?.backed) {
-		tlComponent.pagingComponent?.reload();
-	}
-};
-
-let endpoint;
-let query;
-let connection;
-let connection2;
-
-if (props.src === 'antenna') {
-	endpoint = 'antennas/notes';
-	query = {
-		antennaId: props.antenna,
-	};
-	connection = stream.useChannel('antenna', {
-		antennaId: props.antenna,
-	});
-	connection.on('note', prepend);
-} else if (props.src === 'home') {
-	endpoint = 'notes/timeline';
-	connection = stream.useChannel('homeTimeline');
-	connection.on('note', prepend);
-
-	connection2 = stream.useChannel('main');
-	connection2.on('follow', onChangeFollowing);
-	connection2.on('unfollow', onChangeFollowing);
-} else if (props.src === 'local') {
-	endpoint = 'notes/local-timeline';
-	connection = stream.useChannel('localTimeline');
-	connection.on('note', prepend);
-} else if (props.src === 'social') {
-	endpoint = 'notes/hybrid-timeline';
-	connection = stream.useChannel('hybridTimeline');
-	connection.on('note', prepend);
-} else if (props.src === 'global') {
-	endpoint = 'notes/global-timeline';
-	connection = stream.useChannel('globalTimeline');
-	connection.on('note', prepend);
-} else if (props.src === 'mentions') {
-	endpoint = 'notes/mentions';
-	connection = stream.useChannel('main');
-	connection.on('mention', prepend);
-} else if (props.src === 'directs') {
-	endpoint = 'notes/mentions';
-	query = {
-		visibility: 'specified',
-	};
-	const onNote = note => {
-		if (note.visibility === 'specified') {
-			prepend(note);
-		}
-	};
-	connection = stream.useChannel('main');
-	connection.on('mention', onNote);
-} else if (props.src === 'list') {
-	endpoint = 'notes/user-list-timeline';
-	query = {
-		listId: props.list,
-	};
-	connection = stream.useChannel('userList', {
-		listId: props.list,
-	});
-	connection.on('note', prepend);
-	connection.on('userAdded', onUserAdded);
-	connection.on('userRemoved', onUserRemoved);
-} else if (props.src === 'channel') {
-	endpoint = 'channels/timeline';
-	query = {
-		channelId: props.channel,
-	};
-	connection = stream.useChannel('channel', {
-		channelId: props.channel,
-	});
-	connection.on('note', prepend);
-}
-
-const pagination = {
-	endpoint: endpoint,
-	limit: 10,
-	params: query,
-};
-
-onUnmounted(() => {
-	connection.dispose();
-	if (connection2) connection2.dispose();
-});
-
-/* TODO
-const timetravel = (date?: Date) => {
-	this.date = date;
-	this.$refs.tl.reload();
-};
-*/
-</script>
diff --git a/packages/client/src/components/MkToast.vue b/packages/client/src/components/MkToast.vue
deleted file mode 100644
index c9fad64eb6..0000000000
--- a/packages/client/src/components/MkToast.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<div class="mk-toast">
-	<transition :name="$store.state.animation ? 'toast' : ''" appear @after-leave="emit('closed')">
-		<div v-if="showing" class="body _acrylic" :style="{ zIndex }">
-			<div class="message">
-				{{ message }}
-			</div>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
-import * as os from '@/os';
-
-defineProps<{
-	message: string;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const zIndex = os.claimZIndex('high');
-let showing = $ref(true);
-
-onMounted(() => {
-	window.setTimeout(() => {
-		showing = false;
-	}, 4000);
-});
-</script>
-
-<style lang="scss" scoped>
-.toast-enter-active, .toast-leave-active {
-	transition: opacity 0.3s, transform 0.3s !important;
-}
-.toast-enter-from, .toast-leave-to {
-	opacity: 0;
-	transform: translateY(-100%);
-}
-
-.mk-toast {
-	> .body {
-		position: fixed;
-		left: 0;
-		right: 0;
-		top: 0;
-		margin: 0 auto;
-		margin-top: 16px;
-		min-width: 300px;
-		max-width: calc(100% - 32px);
-		width: min-content;
-		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
-		border-radius: 8px;
-		overflow: clip;
-		text-align: center;
-		pointer-events: none;
-
-		> .message {
-			padding: 16px 24px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkTokenGenerateWindow.vue b/packages/client/src/components/MkTokenGenerateWindow.vue
deleted file mode 100644
index b846034a24..0000000000
--- a/packages/client/src/components/MkTokenGenerateWindow.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="400"
-	:height="450"
-	:with-ok-button="true"
-	:ok-button-disabled="false"
-	:can-close="false"
-	@close="dialog.close()"
-	@closed="$emit('closed')"
-	@ok="ok()"
->
-	<template #header>{{ title || $ts.generateAccessToken }}</template>
-	<div v-if="information" class="_section">
-		<MkInfo warn>{{ information }}</MkInfo>
-	</div>
-	<div class="_section">
-		<MkInput v-model="name">
-			<template #label>{{ $ts.name }}</template>
-		</MkInput>
-	</div>
-	<div class="_section">
-		<div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div>
-		<MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
-		<MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
-		<MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { permissions as kinds } from 'misskey-js';
-import MkInput from './form/input.vue';
-import MkSwitch from './form/switch.vue';
-import MkButton from './MkButton.vue';
-import MkInfo from './MkInfo.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-
-const props = withDefaults(defineProps<{
-	title?: string | null;
-	information?: string | null;
-	initialName?: string | null;
-	initialPermissions?: string[] | null;
-}>(), {
-	title: null,
-	information: null,
-	initialName: null,
-	initialPermissions: null,
-});
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-	(ev: 'done', result: { name: string | null, permissions: string[] }): void;
-}>();
-
-const dialog = $ref<InstanceType<typeof XModalWindow>>();
-let name = $ref(props.initialName);
-let permissions = $ref({});
-
-if (props.initialPermissions) {
-	for (const kind of props.initialPermissions) {
-		permissions[kind] = true;
-	}
-} else {
-	for (const kind of kinds) {
-		permissions[kind] = false;
-	}
-}
-
-function ok(): void {
-	emit('done', {
-		name: name,
-		permissions: Object.keys(permissions).filter(p => permissions[p]),
-	});
-	dialog.close();
-}
-
-function disableAll(): void {
-	for (const p in permissions) {
-		permissions[p] = false;
-	}
-}
-
-function enableAll(): void {
-	for (const p in permissions) {
-		permissions[p] = true;
-	}
-}
-</script>
diff --git a/packages/client/src/components/MkTooltip.vue b/packages/client/src/components/MkTooltip.vue
deleted file mode 100644
index 4c6258d245..0000000000
--- a/packages/client/src/components/MkTooltip.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')">
-	<div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }">
-		<slot>
-			<Mfm v-if="asMfm" :text="text"/>
-			<span v-else>{{ text }}</span>
-		</slot>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import { calcPopupPosition } from '@/scripts/popup-position';
-
-const props = withDefaults(defineProps<{
-	showing: boolean;
-	targetElement?: HTMLElement;
-	x?: number;
-	y?: number;
-	text?: string;
-	asMfm?: boolean;
-	maxWidth?: number;
-	direction?: 'top' | 'bottom' | 'right' | 'left';
-	innerMargin?: number;
-}>(), {
-	maxWidth: 250,
-	direction: 'top',
-	innerMargin: 0,
-});
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const el = ref<HTMLElement>();
-const zIndex = os.claimZIndex('high');
-
-function setPosition() {
-	const data = calcPopupPosition(el.value, {
-		anchorElement: props.targetElement,
-		direction: props.direction,
-		align: 'center',
-		innerMargin: props.innerMargin,
-		x: props.x,
-		y: props.y,
-	});
-
-	el.value.style.transformOrigin = data.transformOrigin;
-	el.value.style.left = data.left + 'px';
-	el.value.style.top = data.top + 'px';
-}
-
-let loopHandler;
-
-onMounted(() => {
-	nextTick(() => {
-		setPosition();
-
-		const loop = () => {
-			loopHandler = window.requestAnimationFrame(() => {
-				setPosition();
-				loop();
-			});
-		};
-
-		loop();
-	});
-});
-
-onUnmounted(() => {
-	window.cancelAnimationFrame(loopHandler);
-});
-</script>
-
-<style lang="scss" scoped>
-.tooltip-enter-active,
-.tooltip-leave-active {
-	opacity: 1;
-	transform: scale(1);
-	transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tooltip-enter-from,
-.tooltip-leave-active {
-	opacity: 0;
-	transform: scale(0.75);
-}
-
-.buebdbiu {
-	position: absolute;
-	font-size: 0.8em;
-	padding: 8px 12px;
-	box-sizing: border-box;
-	text-align: center;
-	border-radius: 4px;
-	border: solid 0.5px var(--divider);
-	pointer-events: none;
-	transform-origin: center center;
-}
-</style>
diff --git a/packages/client/src/components/MkUpdated.vue b/packages/client/src/components/MkUpdated.vue
deleted file mode 100644
index 48aeb30224..0000000000
--- a/packages/client/src/components/MkUpdated.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<template>
-<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
-	<div class="ewlycnyt">
-		<div class="title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div>
-		<div class="version">✨{{ version }}🚀</div>
-		<MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton>
-		<MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ i18n.ts.gotIt }}</MkButton>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkSparkle from '@/components/MkSparkle.vue';
-import { version } from '@/config';
-import { i18n } from '@/i18n';
-
-const modal = ref<InstanceType<typeof MkModal>>();
-
-const whatIsNew = () => {
-	modal.value.close();
-	window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank');
-};
-</script>
-
-<style lang="scss" scoped>
-.ewlycnyt {
-	position: relative;
-	padding: 32px;
-	min-width: 320px;
-	max-width: 480px;
-	box-sizing: border-box;
-	text-align: center;
-	background: var(--panel);
-	border-radius: var(--radius);
-
-	> .title {
-		font-weight: bold;
-	}
-
-	> .version {
-		margin: 1em 0;
-	}
-
-	> .gotIt {
-		margin: 8px 0 0 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUrlPreview.vue b/packages/client/src/components/MkUrlPreview.vue
deleted file mode 100644
index b2d16ddb01..0000000000
--- a/packages/client/src/components/MkUrlPreview.vue
+++ /dev/null
@@ -1,383 +0,0 @@
-<template>
-<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
-	<button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
-	<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
-</div>
-<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
-	<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
-</div>
-<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
-			<div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`">
-				<button v-if="!playerEnabled && player.url" class="_button" :title="i18n.ts.enablePlayer" @click.prevent="isMobile? playerEnabled = true : openPlayer()"><i class="ti ti-player-play"></i></button>
-			</div>
-			<article>
-				<header>
-					<h1 :title="title">{{ title }}</h1>
-				</header>
-				<p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
-				<footer>
-					<img v-if="icon" class="icon" :src="icon"/>
-					<p :title="sitename">{{ sitename }}</p>
-				</footer>
-			</article>
-		</component>
-	</transition>
-	<div v-if="tweetId" class="expandTweet">
-		<a @click="tweetExpanded = true">
-			<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
-		</a>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, onMounted, onUnmounted } from 'vue';
-import { url as local, lang } from '@/config';
-import { i18n } from '@/i18n';
-import * as os from '@/os';
-import { deviceKind } from '@/scripts/device-kind';
-
-const props = withDefaults(defineProps<{
-	url: string;
-	detail?: boolean;
-	compact?: boolean;
-}>(), {
-	detail: false,
-	compact: false,
-});
-
-const MOBILE_THRESHOLD = 500;
-const isMobile = $ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
-
-const self = props.url.startsWith(local);
-const attr = self ? 'to' : 'href';
-const target = self ? null : '_blank';
-let fetching = $ref(true);
-let title = $ref<string | null>(null);
-let description = $ref<string | null>(null);
-let thumbnail = $ref<string | null>(null);
-let icon = $ref<string | null>(null);
-let sitename = $ref<string | null>(null);
-let player = $ref({
-	url: null,
-	width: null,
-	height: null,
-});
-let playerEnabled = $ref(false);
-let tweetId = $ref<string | null>(null);
-let tweetExpanded = $ref(props.detail);
-const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
-let tweetHeight = $ref(150);
-
-const requestUrl = new URL(props.url);
-
-if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') {
-	const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
-	if (m) tweetId = m[1];
-}
-
-if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
-	requestUrl.hostname = 'www.youtube.com';
-}
-
-const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
-
-requestUrl.hash = '';
-
-window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
-	res.json().then(info => {
-		if (info.url == null) return;
-		title = info.title;
-		description = info.description;
-		thumbnail = info.thumbnail;
-		icon = info.icon;
-		sitename = info.sitename;
-		fetching = false;
-		player = info.player;
-	});
-});
-
-function adjustTweetHeight(message: any) {
-	if (message.origin !== 'https://platform.twitter.com') return;
-	const embed = message.data?.['twttr.embed'];
-	if (embed?.method !== 'twttr.private.resize') return;
-	if (embed?.id !== embedId) return;
-	const height = embed?.params[0]?.height;
-	if (height) tweetHeight = height;
-}
-
-const openPlayer = (): void => {
-	os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
-		url: requestUrl.href,
-	});
-};
-
-(window as any).addEventListener('message', adjustTweetHeight);
-
-onUnmounted(() => {
-	(window as any).removeEventListener('message', adjustTweetHeight);
-});
-</script>
-
-<style lang="scss" scoped>
-.player {
-	position: relative;
-	width: 100%;
-
-	> button {
-		position: absolute;
-		top: -1.5em;
-		right: 0;
-		font-size: 1em;
-		width: 1.5em;
-		height: 1.5em;
-		padding: 0;
-		margin: 0;
-		color: var(--fg);
-		background: rgba(128, 128, 128, 0.2);
-		opacity: 0.7;
-
-		&:hover {
-			opacity: 0.9;
-		}
-	}
-
-	> iframe {
-		height: 100%;
-		left: 0;
-		position: absolute;
-		top: 0;
-		width: 100%;
-	}
-}
-
-.mk-url-preview {
-	&.max-width_400px {
-		> .link {
-			font-size: 12px;
-
-			> .thumbnail {
-				height: 80px;
-			}
-
-			> article {
-				padding: 12px;
-			}
-		}
-	}
-
-	&.max-width_350px {
-		> .link {
-			font-size: 10px;
-
-			> .thumbnail {
-				height: 70px;
-			}
-
-			> article {
-				padding: 8px;
-
-				> header {
-					margin-bottom: 4px;
-				}
-
-				> footer {
-					margin-top: 4px;
-
-					> img {
-						width: 12px;
-						height: 12px;
-					}
-				}
-			}
-
-			&.compact {
-				> .thumbnail {
-					position: absolute;
-					width: 56px;
-					height: 100%;
-				}
-
-				> article {
-					left: 56px;
-					width: calc(100% - 56px);
-					padding: 4px;
-
-					> header {
-						margin-bottom: 2px;
-					}
-
-					> footer {
-						margin-top: 2px;
-					}
-				}
-			}
-		}
-	}
-
-	> .link {
-		position: relative;
-		display: block;
-		font-size: 14px;
-		box-shadow: 0 0 0 1px var(--divider);
-		border-radius: 8px;
-		overflow: hidden;
-
-		&:hover {
-			text-decoration: none;
-			border-color: rgba(0, 0, 0, 0.2);
-
-			> article > header > h1 {
-				text-decoration: underline;
-			}
-		}
-
-		> .thumbnail {
-			position: absolute;
-			width: 100px;
-			height: 100%;
-			background-position: center;
-			background-size: cover;
-			display: flex;
-			justify-content: center;
-			align-items: center;
-
-			> button {
-				font-size: 3.5em;
-				opacity: 0.7;
-
-				&:hover {
-					font-size: 4em;
-					opacity: 0.9;
-				}
-			}
-
-			& + article {
-				left: 100px;
-				width: calc(100% - 100px);
-			}
-		}
-
-		> article {
-			position: relative;
-			box-sizing: border-box;
-			padding: 16px;
-
-			> header {
-				margin-bottom: 8px;
-
-				> h1 {
-					margin: 0;
-					font-size: 1em;
-				}
-			}
-
-			> p {
-				margin: 0;
-				font-size: 0.8em;
-			}
-
-			> footer {
-				margin-top: 8px;
-				height: 16px;
-
-				> img {
-					display: inline-block;
-					width: 16px;
-					height: 16px;
-					margin-right: 4px;
-					vertical-align: top;
-				}
-
-				> p {
-					display: inline-block;
-					margin: 0;
-					color: var(--urlPreviewInfo);
-					font-size: 0.8em;
-					line-height: 16px;
-					vertical-align: top;
-				}
-			}
-		}
-
-		&.compact {
-			> article {
-				> header h1, p, footer {
-					overflow: hidden;
-					white-space: nowrap;
-					text-overflow: ellipsis;
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 400px) {
-	.mk-url-preview {
-		> .link {
-			font-size: 12px;
-
-			> .thumbnail {
-				height: 80px;
-			}
-
-			> article {
-				padding: 12px;
-			}
-		}
-	}
-}
-
-@container (max-width: 350px) {
-	.mk-url-preview {
-		> .link {
-			font-size: 10px;
-
-			> .thumbnail {
-				height: 70px;
-			}
-
-			> article {
-				padding: 8px;
-
-				> header {
-					margin-bottom: 4px;
-				}
-
-				> footer {
-					margin-top: 4px;
-
-					> img {
-						width: 12px;
-						height: 12px;
-					}
-				}
-			}
-
-			&.compact {
-				> .thumbnail {
-					position: absolute;
-					width: 56px;
-					height: 100%;
-				}
-
-				> article {
-					left: 56px;
-					width: calc(100% - 56px);
-					padding: 4px;
-
-					> header {
-						margin-bottom: 2px;
-					}
-
-					> footer {
-						margin-top: 2px;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUrlPreviewPopup.vue b/packages/client/src/components/MkUrlPreviewPopup.vue
deleted file mode 100644
index f343c6d8a6..0000000000
--- a/packages/client/src/components/MkUrlPreviewPopup.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
-	<transition :name="$store.state.animation ? 'zoom' : ''" @after-leave="emit('closed')">
-		<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import MkUrlPreview from '@/components/MkUrlPreview.vue';
-import * as os from '@/os';
-
-const props = defineProps<{
-	showing: boolean;
-	url: string;
-	source: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const zIndex = os.claimZIndex('middle');
-let top = $ref(0);
-let left = $ref(0);
-
-onMounted(() => {
-	const rect = props.source.getBoundingClientRect();
-	const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
-	const y = rect.top + props.source.offsetHeight + window.pageYOffset;
-
-	top = y;
-	left = x;
-});
-</script>
-
-<style lang="scss" scoped>
-.fgmtyycl {
-	position: absolute;
-	width: 500px;
-	max-width: calc(90vw - 12px);
-	pointer-events: none;
-}
-</style>
diff --git a/packages/client/src/components/MkUserCardMini.vue b/packages/client/src/components/MkUserCardMini.vue
deleted file mode 100644
index 1a4c494987..0000000000
--- a/packages/client/src/components/MkUserCardMini.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
-<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]">
-	<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
-	<div class="body">
-		<span class="name"><MkUserName class="name" :user="user"/></span>
-		<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
-	</div>
-	<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import * as misskey from 'misskey-js';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import { acct } from '@/filters/user';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-}>();
-
-let chartValues = $ref<number[] | null>(null);
-
-os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => {
-	// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
-	res.inc.splice(0, 1);
-	chartValues = res.inc;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	$bodyTitleHieght: 18px;
-	$bodyInfoHieght: 16px;
-
-	display: flex;
-	align-items: center;
-	padding: 16px;
-	background: var(--panel);
-	border-radius: 8px;
-
-	> :global(.avatar) {
-		display: block;
-		width: ($bodyTitleHieght + $bodyInfoHieght);
-		height: ($bodyTitleHieght + $bodyInfoHieght);
-		margin-right: 12px;
-	}
-
-	> :global(.body) {
-		flex: 1;
-		overflow: hidden;
-		font-size: 0.9em;
-		color: var(--fg);
-		padding-right: 8px;
-
-		> :global(.name) {
-			display: block;
-			width: 100%;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			line-height: $bodyTitleHieght;
-		}
-
-		> :global(.sub) {
-			display: block;
-			width: 100%;
-			font-size: 95%;
-			opacity: 0.7;
-			line-height: $bodyInfoHieght;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-	}
-
-	> :global(.chart) {
-		height: 30px;
-	}
-
-	&:global(.yellow) {
-		--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;
-	}
-
-	&:global(.red) {
-		--c: rgb(255 0 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;
-	}
-
-	&:global(.gray) {
-		--c: var(--bg);
-		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/client/src/components/MkUserInfo.vue b/packages/client/src/components/MkUserInfo.vue
deleted file mode 100644
index 036cbea304..0000000000
--- a/packages/client/src/components/MkUserInfo.vue
+++ /dev/null
@@ -1,137 +0,0 @@
-<template>
-<div class="_panel vjnjpkug">
-	<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
-	<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
-	<div class="title">
-		<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
-		<p class="username"><MkAcct :user="user"/></p>
-	</div>
-	<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
-	<div class="description">
-		<div v-if="user.description" class="mfm">
-			<Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
-		</div>
-		<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
-	</div>
-	<div class="status">
-		<div>
-			<p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span>
-		</div>
-		<div>
-			<p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span>
-		</div>
-		<div>
-			<p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span>
-		</div>
-	</div>
-	<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import * as misskey from 'misskey-js';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import { userPage } from '@/filters/user';
-import { i18n } from '@/i18n';
-
-defineProps<{
-	user: misskey.entities.UserDetailed;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.vjnjpkug {
-	position: relative;
-
-	> .banner {
-		height: 84px;
-		background-color: rgba(0, 0, 0, 0.1);
-		background-size: cover;
-		background-position: center;
-	}
-
-	> .avatar {
-		display: block;
-		position: absolute;
-		top: 62px;
-		left: 13px;
-		z-index: 2;
-		width: 58px;
-		height: 58px;
-		border: solid 4px var(--panel);
-	}
-
-	> .title {
-		display: block;
-		padding: 10px 0 10px 88px;
-
-		> .name {
-			display: inline-block;
-			margin: 0;
-			font-weight: bold;
-			line-height: 16px;
-			word-break: break-all;
-		}
-
-		> .username {
-			display: block;
-			margin: 0;
-			line-height: 16px;
-			font-size: 0.8em;
-			color: var(--fg);
-			opacity: 0.7;
-		}
-	}
-	
-	> .followed {
-		position: absolute;
-		top: 12px;
-		left: 12px;
-		padding: 4px 8px;
-		color: #fff;
-		background: rgba(0, 0, 0, 0.7);
-		font-size: 0.7em;
-		border-radius: 6px;
-	}
-	
-	> .description {
-		padding: 16px;
-		font-size: 0.8em;
-		border-top: solid 0.5px var(--divider);
-
-		> .mfm {
-			display: -webkit-box;
-			-webkit-line-clamp: 3;
-			-webkit-box-orient: vertical;  
-			overflow: hidden;
-		}
-	}
-
-	> .status {
-		padding: 10px 16px;
-		border-top: solid 0.5px var(--divider);
-
-		> div {
-			display: inline-block;
-			width: 33%;
-
-			> p {
-				margin: 0;
-				font-size: 0.7em;
-				color: var(--fg);
-			}
-
-			> span {
-				font-size: 1em;
-				color: var(--accent);
-			}
-		}
-	}
-
-	> .koudoku-button {
-		position: absolute;
-		top: 8px;
-		right: 8px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUserList.vue b/packages/client/src/components/MkUserList.vue
deleted file mode 100644
index e1f47c7673..0000000000
--- a/packages/client/src/components/MkUserList.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<template>
-<MkPagination ref="pagingComponent" :pagination="pagination">
-	<template #empty>
-		<div class="_fullinfo">
-			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-			<div>{{ i18n.ts.noUsers }}</div>
-		</div>
-	</template>
-
-	<template #default="{ items: users }">
-		<div class="efvhhmdq">
-			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
-		</div>
-	</template>
-</MkPagination>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import MkUserInfo from '@/components/MkUserInfo.vue';
-import MkPagination, { Paging } from '@/components/MkPagination.vue';
-import { userPage } from '@/filters/user';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	pagination: Paging;
-	noGap?: boolean;
-}>();
-
-const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-</script>
-
-<style lang="scss" scoped>
-.efvhhmdq {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-	grid-gap: var(--margin);
-}
-</style>
diff --git a/packages/client/src/components/MkUserOnlineIndicator.vue b/packages/client/src/components/MkUserOnlineIndicator.vue
deleted file mode 100644
index a4f6f80383..0000000000
--- a/packages/client/src/components/MkUserOnlineIndicator.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-}>();
-
-const text = $computed(() => {
-	switch (props.user.onlineStatus) {
-		case 'online': return i18n.ts.online;
-		case 'active': return i18n.ts.active;
-		case 'offline': return i18n.ts.offline;
-		case 'unknown': return i18n.ts.unknown;
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.fzgwjkgc {
-	box-shadow: 0 0 0 3px var(--panel);
-	border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる
-
-	&.online {
-		background: #58d4c9;
-	}
-
-	&.active {
-		background: #e4bc48;
-	}
-
-	&.offline {
-		background: #ea5353;
-	}
-
-	&.unknown {
-		background: #888;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUserPreview.vue b/packages/client/src/components/MkUserPreview.vue
deleted file mode 100644
index 4de2e8baa2..0000000000
--- a/packages/client/src/components/MkUserPreview.vue
+++ /dev/null
@@ -1,184 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="emit('closed')">
-	<div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
-		<div v-if="user != null" class="info">
-			<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
-				<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
-			</div>
-			<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
-			<div class="title">
-				<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
-				<p class="username"><MkAcct :user="user"/></p>
-			</div>
-			<div class="description">
-				<Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
-			</div>
-			<div class="status">
-				<div>
-					<p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
-				</div>
-				<div>
-					<p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
-				</div>
-				<div>
-					<p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
-				</div>
-			</div>
-			<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
-		</div>
-		<div v-else>
-			<MkLoading/>
-		</div>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import * as misskey from 'misskey-js';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import { userPage } from '@/filters/user';
-import * as os from '@/os';
-
-const props = defineProps<{
-	showing: boolean;
-	q: string;
-	source: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-	(ev: 'mouseover'): void;
-	(ev: 'mouseleave'): void;
-}>();
-
-const zIndex = os.claimZIndex('middle');
-let user = $ref<misskey.entities.UserDetailed | null>(null);
-let top = $ref(0);
-let left = $ref(0);
-
-onMounted(() => {
-	if (typeof props.q === 'object') {
-		user = props.q;
-	} else {
-		const query = props.q.startsWith('@') ?
-			Acct.parse(props.q.substr(1)) :
-			{ userId: props.q };
-
-		os.api('users/show', query).then(res => {
-			if (!props.showing) return;
-			user = res;
-		});
-	}
-
-	const rect = props.source.getBoundingClientRect();
-	const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
-	const y = rect.top + props.source.offsetHeight + window.pageYOffset;
-
-	top = y;
-	left = x;
-});
-</script>
-
-<style lang="scss" scoped>
-.popup-enter-active, .popup-leave-active {
-	transition: opacity 0.3s, transform 0.3s !important;
-}
-.popup-enter-from, .popup-leave-to {
-	opacity: 0;
-	transform: scale(0.9);
-}
-
-.fxxzrfni {
-	position: absolute;
-	width: 300px;
-	overflow: hidden;
-	transform-origin: center top;
-
-	> .info {
-		> .banner {
-			height: 84px;
-			background-color: rgba(0, 0, 0, 0.1);
-			background-size: cover;
-			background-position: center;
-			> .followed {
-					position: absolute;
-					top: 12px;
-					left: 12px;
-					padding: 4px 8px;
-					color: #fff;
-					background: rgba(0, 0, 0, 0.7);
-					font-size: 0.7em;
-					border-radius: 6px;
-			}
-		}
-
-		> .avatar {
-			display: block;
-			position: absolute;
-			top: 62px;
-			left: 13px;
-			z-index: 2;
-			width: 58px;
-			height: 58px;
-			border: solid 3px var(--face);
-			border-radius: 8px;
-		}
-
-		> .title {
-			display: block;
-			padding: 8px 0 8px 82px;
-
-			> .name {
-				display: inline-block;
-				margin: 0;
-				font-weight: bold;
-				line-height: 16px;
-				word-break: break-all;
-			}
-
-			> .username {
-				display: block;
-				margin: 0;
-				line-height: 16px;
-				font-size: 0.8em;
-				color: var(--fg);
-				opacity: 0.7;
-			}
-		}
-
-		> .description {
-			padding: 0 16px;
-			font-size: 0.8em;
-			color: var(--fg);
-		}
-
-		> .status {
-			padding: 8px 16px;
-
-			> div {
-				display: inline-block;
-				width: 33%;
-
-				> p {
-					margin: 0;
-					font-size: 0.7em;
-					color: var(--fg);
-				}
-
-				> span {
-					font-size: 1em;
-					color: var(--accent);
-				}
-			}
-		}
-
-		> .koudoku-button {
-			position: absolute;
-			top: 8px;
-			right: 8px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUserSelectDialog.vue b/packages/client/src/components/MkUserSelectDialog.vue
deleted file mode 100644
index 1d31769c30..0000000000
--- a/packages/client/src/components/MkUserSelectDialog.vue
+++ /dev/null
@@ -1,190 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialogEl"
-	:with-ok-button="true"
-	:ok-button-disabled="selected == null"
-	@click="cancel()"
-	@close="cancel()"
-	@ok="ok()"
-	@closed="$emit('closed')"
->
-	<template #header>{{ i18n.ts.selectUser }}</template>
-	<div class="tbhwbxda">
-		<div class="form">
-			<FormSplit :min-width="170">
-				<MkInput v-model="username" :autofocus="true" @update:model-value="search">
-					<template #label>{{ i18n.ts.username }}</template>
-					<template #prefix>@</template>
-				</MkInput>
-				<MkInput v-model="host" @update:model-value="search">
-					<template #label>{{ i18n.ts.host }}</template>
-					<template #prefix>@</template>
-				</MkInput>
-			</FormSplit>
-		</div>
-		<div v-if="username != '' || host != ''" class="result" :class="{ hit: users.length > 0 }">
-			<div v-if="users.length > 0" class="users">
-				<div v-for="user in users" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
-					<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-					<div class="body">
-						<MkUserName :user="user" class="name"/>
-						<MkAcct :user="user" class="acct"/>
-					</div>
-				</div>
-			</div>
-			<div v-else class="empty">
-				<span>{{ i18n.ts.noUsers }}</span>
-			</div>
-		</div>
-		<div v-if="username == '' && host == ''" class="recent">
-			<div class="users">
-				<div v-for="user in recentUsers" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
-					<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-					<div class="body">
-						<MkUserName :user="user" class="name"/>
-						<MkAcct :user="user" class="acct"/>
-					</div>
-				</div>
-			</div>
-		</div>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted } from 'vue';
-import * as misskey from 'misskey-js';
-import MkInput from '@/components/form/input.vue';
-import FormSplit from '@/components/form/split.vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-
-const emit = defineEmits<{
-	(ev: 'ok', selected: misskey.entities.UserDetailed): void;
-	(ev: 'cancel'): void;
-	(ev: 'closed'): void;
-}>();
-
-let username = $ref('');
-let host = $ref('');
-let users: misskey.entities.UserDetailed[] = $ref([]);
-let recentUsers: misskey.entities.UserDetailed[] = $ref([]);
-let selected: misskey.entities.UserDetailed | null = $ref(null);
-let dialogEl = $ref();
-
-const search = () => {
-	if (username === '' && host === '') {
-		users = [];
-		return;
-	}
-	os.api('users/search-by-username-and-host', {
-		username: username,
-		host: host,
-		limit: 10,
-		detail: false,
-	}).then(_users => {
-		users = _users;
-	});
-};
-
-const ok = () => {
-	if (selected == null) return;
-	emit('ok', selected);
-	dialogEl.close();
-
-	// 最近使ったユーザー更新
-	let recents = defaultStore.state.recentlyUsedUsers;
-	recents = recents.filter(x => x !== selected.id);
-	recents.unshift(selected.id);
-	defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
-};
-
-const cancel = () => {
-	emit('cancel');
-	dialogEl.close();
-};
-
-onMounted(() => {
-	os.api('users/show', {
-		userIds: defaultStore.state.recentlyUsedUsers,
-	}).then(users => {
-		recentUsers = users;
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-.tbhwbxda {
-	> .form {
-		padding: 0 var(--root-margin);
-	}
-
-	> .result, > .recent {
-		display: flex;
-		flex-direction: column;
-		overflow: auto;
-		height: 100%;
-
-		&.result.hit {
-			padding: 0;
-		}
-
-		&.recent {
-			padding: 0;
-		}
-
-		> .users {
-			flex: 1;
-			overflow: auto;
-			padding: 8px 0;
-
-			> .user {
-				display: flex;
-				align-items: center;
-				padding: 8px var(--root-margin);
-				font-size: 14px;
-
-				&:hover {
-					background: var(--X7);
-				}
-
-				&.selected {
-					background: var(--accent);
-					color: #fff;
-				}
-
-				> * {
-					pointer-events: none;
-					user-select: none;
-				}
-
-				> .avatar {
-					width: 45px;
-					height: 45px;
-				}
-
-				> .body {
-					padding: 0 8px;
-					min-width: 0;
-
-					> .name {
-						display: block;
-						font-weight: bold;
-					}
-
-					> .acct {
-						opacity: 0.5;
-					}
-				}
-			}
-		}
-
-		> .empty {
-			opacity: 0.7;
-			text-align: center;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkUsersTooltip.vue b/packages/client/src/components/MkUsersTooltip.vue
deleted file mode 100644
index f0384e2d65..0000000000
--- a/packages/client/src/components/MkUsersTooltip.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="250" @closed="emit('closed')">
-	<div class="beaffaef">
-		<div v-for="u in users" :key="u.id" class="user">
-			<MkAvatar class="avatar" :user="u"/>
-			<MkUserName class="name" :user="u" :nowrap="true"/>
-		</div>
-		<div v-if="users.length < count" class="omitted">+{{ count - users.length }}</div>
-	</div>
-</MkTooltip>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkTooltip from './MkTooltip.vue';
-
-defineProps<{
-	showing: boolean;
-	users: any[]; // TODO
-	count: number;
-	targetElement: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.beaffaef {
-	font-size: 0.9em;
-	text-align: left;
-
-	> .user {
-		line-height: 24px;
-		white-space: nowrap;
-		overflow: hidden;
-		text-overflow: ellipsis;
-
-		&:not(:last-child) {
-			margin-bottom: 3px;
-		}
-
-		> .avatar {
-			width: 24px;
-			height: 24px;
-			margin-right: 3px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkVisibility.vue b/packages/client/src/components/MkVisibility.vue
deleted file mode 100644
index 229907fbb8..0000000000
--- a/packages/client/src/components/MkVisibility.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<template>
-<span v-if="note.visibility !== 'public'" :class="$style.visibility" :title="i18n.ts._visibility[note.visibility]">
-	<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
-	<i v-else-if="note.visibility === 'followers'" class="ti ti-lock-open"></i>
-	<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
-</span>
-<span v-if="note.localOnly" :class="$style.localOnly" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import XDetails from '@/components/MkUsersTooltip.vue';
-import * as os from '@/os';
-import { useTooltip } from '@/scripts/use-tooltip';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	note: {
-		visibility: string;
-		localOnly?: boolean;
-		visibleUserIds?: string[];
-	},
-}>();
-
-const specified = $ref<HTMLElement>();
-
-if (props.note.visibility === 'specified') {
-	useTooltip($$(specified), async (showing) => {
-		const users = await os.api('users/show', {
-			userIds: props.note.visibleUserIds,
-			limit: 10,
-		});
-
-		os.popup(XDetails, {
-			showing,
-			users,
-			count: props.note.visibleUserIds.length,
-			targetElement: specified,
-		}, {}, 'closed');
-	});
-}
-</script>
-
-<style lang="scss" module>
-.visibility, .localOnly {
-	margin-left: 0.5em;
-}
-</style>
diff --git a/packages/client/src/components/MkVisibilityPicker.vue b/packages/client/src/components/MkVisibilityPicker.vue
deleted file mode 100644
index 8f0bcdeae8..0000000000
--- a/packages/client/src/components/MkVisibilityPicker.vue
+++ /dev/null
@@ -1,159 +0,0 @@
-<template>
-<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
-	<div class="gqyayizv _popup">
-		<button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')">
-			<div><i class="ti ti-world"></i></div>
-			<div>
-				<span>{{ i18n.ts._visibility.public }}</span>
-				<span>{{ i18n.ts._visibility.publicDescription }}</span>
-			</div>
-		</button>
-		<button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')">
-			<div><i class="ti ti-home"></i></div>
-			<div>
-				<span>{{ i18n.ts._visibility.home }}</span>
-				<span>{{ i18n.ts._visibility.homeDescription }}</span>
-			</div>
-		</button>
-		<button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
-			<div><i class="ti ti-lock-open"></i></div>
-			<div>
-				<span>{{ i18n.ts._visibility.followers }}</span>
-				<span>{{ i18n.ts._visibility.followersDescription }}</span>
-			</div>
-		</button>
-		<button key="specified" :disabled="localOnly" class="_button" :class="{ active: v === 'specified' }" data-index="4" @click="choose('specified')">
-			<div><i class="ti ti-mail"></i></div>
-			<div>
-				<span>{{ i18n.ts._visibility.specified }}</span>
-				<span>{{ i18n.ts._visibility.specifiedDescription }}</span>
-			</div>
-		</button>
-		<div class="divider"></div>
-		<button key="localOnly" class="_button localOnly" :class="{ active: localOnly }" data-index="5" @click="localOnly = !localOnly">
-			<div><i class="ti ti-world-off"></i></div>
-			<div>
-				<span>{{ i18n.ts._visibility.localOnly }}</span>
-				<span>{{ i18n.ts._visibility.localOnlyDescription }}</span>
-			</div>
-			<div><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div>
-		</button>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import MkModal from '@/components/MkModal.vue';
-import { i18n } from '@/i18n';
-
-const modal = $ref<InstanceType<typeof MkModal>>();
-
-const props = withDefaults(defineProps<{
-	currentVisibility: typeof misskey.noteVisibilities[number];
-	currentLocalOnly: boolean;
-	src?: HTMLElement;
-}>(), {
-});
-
-const emit = defineEmits<{
-	(ev: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void;
-	(ev: 'changeLocalOnly', v: boolean): void;
-	(ev: 'closed'): void;
-}>();
-
-let v = $ref(props.currentVisibility);
-let localOnly = $ref(props.currentLocalOnly);
-
-watch($$(localOnly), () => {
-	emit('changeLocalOnly', localOnly);
-});
-
-function choose(visibility: typeof misskey.noteVisibilities[number]): void {
-	v = visibility;
-	emit('changeVisibility', visibility);
-	nextTick(() => {
-		modal.close();
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.gqyayizv {
-	width: 240px;
-	padding: 8px 0;
-
-	> .divider {
-		margin: 8px 0;
-		border-top: solid 0.5px var(--divider);
-	}
-
-	> button {
-		display: flex;
-		padding: 8px 14px;
-		font-size: 12px;
-		text-align: left;
-		width: 100%;
-		box-sizing: border-box;
-
-		&:hover {
-			background: rgba(0, 0, 0, 0.05);
-		}
-
-		&:active {
-			background: rgba(0, 0, 0, 0.1);
-		}
-
-		&.active {
-			color: var(--fgOnAccent);
-			background: var(--accent);
-		}
-
-		&.localOnly.active {
-			color: var(--accent);
-			background: inherit;
-		}
-
-		> *:nth-child(1) {
-			display: flex;
-			justify-content: center;
-			align-items: center;
-			margin-right: 10px;
-			width: 16px;
-			top: 0;
-			bottom: 0;
-			margin-top: auto;
-			margin-bottom: auto;
-		}
-
-		> *:nth-child(2) {
-			flex: 1 1 auto;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-
-			> span:first-child {
-				display: block;
-				font-weight: bold;
-			}
-
-			> span:last-child:not(:first-child) {
-				opacity: 0.6;
-			}
-		}
-
-		> *:nth-child(3) {
-			display: flex;
-			justify-content: center;
-			align-items: center;
-			margin-left: 10px;
-			width: 16px;
-			top: 0;
-			bottom: 0;
-			margin-top: auto;
-			margin-bottom: auto;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkWaitingDialog.vue b/packages/client/src/components/MkWaitingDialog.vue
deleted file mode 100644
index f4a9f4f22c..0000000000
--- a/packages/client/src/components/MkWaitingDialog.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<template>
-<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
-	<div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
-		<i v-if="success" class="ti ti-check icon success"></i>
-		<MkLoading v-else class="icon waiting" :em="true"/>
-		<div v-if="text && !success" class="text">{{ text }}<MkEllipsis/></div>
-	</div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { watch, ref } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-
-const modal = ref<InstanceType<typeof MkModal>>();
-
-const props = defineProps<{
-	success: boolean;
-	showing: boolean;
-	text?: string;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'done');
-	(ev: 'closed');
-}>();
-
-function done() {
-	emit('done');
-	modal.value.close();
-}
-
-watch(() => props.showing, () => {
-	if (!props.showing) done();
-});
-</script>
-
-<style lang="scss" scoped>
-.iuyakobc {
-	position: relative;
-	padding: 32px;
-	box-sizing: border-box;
-	text-align: center;
-	background: var(--panel);
-	border-radius: var(--radius);
-	width: 250px;
-
-	&.iconOnly {
-		padding: 0;
-		width: 96px;
-		height: 96px;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-	}
-
-	> .icon {
-		font-size: 32px;
-
-		&.success {
-			color: var(--accent);
-		}
-
-		&.waiting {
-			opacity: 0.7;
-		}
-	}
-
-	> .text {
-		margin-top: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkWidgets.vue b/packages/client/src/components/MkWidgets.vue
deleted file mode 100644
index fff89117ce..0000000000
--- a/packages/client/src/components/MkWidgets.vue
+++ /dev/null
@@ -1,165 +0,0 @@
-<template>
-<div class="vjoppmmu">
-	<template v-if="edit">
-		<header>
-			<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select">
-				<template #label>{{ i18n.ts.selectWidget }}</template>
-				<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option>
-			</MkSelect>
-			<MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
-			<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
-		</header>
-		<Sortable
-			:model-value="props.widgets"
-			item-key="id"
-			handle=".handle"
-			:animation="150"
-			@update:model-value="v => emit('updateWidgets', v)"
-		>
-			<template #item="{element}">
-				<div class="customize-container">
-					<button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
-					<button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
-					<div class="handle">
-						<component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @update-props="updateWidget(element.id, $event)"/>
-					</div>
-				</div>
-			</template>
-		</Sortable>
-	</template>
-	<component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" class="widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, reactive, ref, computed } from 'vue';
-import { v4 as uuid } from 'uuid';
-import MkSelect from '@/components/form/select.vue';
-import MkButton from '@/components/MkButton.vue';
-import { widgets as widgetDefs } from '@/widgets';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { deepClone } from '@/scripts/clone';
-
-const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
-
-type Widget = {
-	name: string;
-	id: string;
-	data: Record<string, any>;
-};
-
-const props = defineProps<{
-	widgets: Widget[];
-	edit: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'updateWidgets', widgets: Widget[]): void;
-	(ev: 'addWidget', widget: Widget): void;
-	(ev: 'removeWidget', widget: Widget): void;
-	(ev: 'updateWidget', widget: Partial<Widget>): void;
-	(ev: 'exit'): void;
-}>();
-
-const widgetRefs = {};
-const configWidget = (id: string) => {
-	widgetRefs[id].configure();
-};
-const widgetAdderSelected = ref(null);
-const addWidget = () => {
-	if (widgetAdderSelected.value == null) return;
-
-	emit('addWidget', {
-		name: widgetAdderSelected.value,
-		id: uuid(),
-		data: {},
-	});
-
-	widgetAdderSelected.value = null;
-};
-const removeWidget = (widget) => {
-	emit('removeWidget', widget);
-};
-const updateWidget = (id, data) => {
-	emit('updateWidget', { id, data });
-};
-
-function onContextmenu(widget: Widget, ev: MouseEvent) {
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target)) return;
-	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
-	if (window.getSelection()?.toString() !== '') return;
-
-	os.contextMenu([{
-		type: 'label',
-		text: i18n.t(`_widgets.${widget.name}`),
-	}, {
-		icon: 'ti ti-settings',
-		text: i18n.ts.settings,
-		action: () => {
-			configWidget(widget.id);
-		},
-	}], ev);
-}
-</script>
-
-<style lang="scss" scoped>
-.vjoppmmu {
-	container-type: inline-size;
-
-	> header {
-		margin: 16px 0;
-
-		> * {
-			width: 100%;
-			padding: 4px;
-		}
-	}
-
-	> .widget, .customize-container {
-		contain: content;
-		margin: var(--margin) 0;
-
-		&:first-of-type {
-			margin-top: 0;
-		}
-	}
-
-	.customize-container {
-		position: relative;
-		cursor: move;
-
-		> .config,
-		> .remove {
-			position: absolute;
-			z-index: 10000;
-			top: 8px;
-			width: 32px;
-			height: 32px;
-			color: #fff;
-			background: rgba(#000, 0.7);
-			border-radius: 4px;
-		}
-
-		> .config {
-			right: 8px + 8px + 32px;
-		}
-
-		> .remove {
-			right: 8px;
-		}
-
-		> .handle {
-			> .widget {
-				pointer-events: none;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkWindow.vue b/packages/client/src/components/MkWindow.vue
deleted file mode 100644
index 629c105810..0000000000
--- a/packages/client/src/components/MkWindow.vue
+++ /dev/null
@@ -1,571 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
-	<div v-if="showing" ref="rootEl" class="ebkgocck" :class="{ maximized }">
-		<div class="body _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
-			<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
-				<span class="left">
-					<button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
-				</span>
-				<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
-					<slot name="header"></slot>
-				</span>
-				<span class="right">
-					<button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
-					<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="button _button" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
-					<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="button _button" @click="maximize()"><i class="ti ti-rectangle"></i></button>
-					<button v-if="closeButton" v-tooltip="i18n.ts.close" class="button _button" @click="close()"><i class="ti ti-x"></i></button>
-				</span>
-			</div>
-			<div class="body">
-				<slot></slot>
-			</div>
-		</div>
-		<template v-if="canResize">
-			<div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
-			<div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
-			<div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
-			<div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
-			<div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
-			<div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
-			<div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
-			<div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
-		</template>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide } from 'vue';
-import contains from '@/scripts/contains';
-import * as os from '@/os';
-import { MenuItem } from '@/types/menu';
-import { i18n } from '@/i18n';
-
-const minHeight = 50;
-const minWidth = 250;
-
-function dragListen(fn: (ev: MouseEvent) => void) {
-	window.addEventListener('mousemove', fn);
-	window.addEventListener('touchmove', fn);
-	window.addEventListener('mouseleave', dragClear.bind(null, fn));
-	window.addEventListener('mouseup', dragClear.bind(null, fn));
-	window.addEventListener('touchend', dragClear.bind(null, fn));
-}
-
-function dragClear(fn) {
-	window.removeEventListener('mousemove', fn);
-	window.removeEventListener('touchmove', fn);
-	window.removeEventListener('mouseleave', dragClear);
-	window.removeEventListener('mouseup', dragClear);
-	window.removeEventListener('touchend', dragClear);
-}
-
-const props = withDefaults(defineProps<{
-	initialWidth?: number;
-	initialHeight?: number | null;
-	canResize?: boolean;
-	closeButton?: boolean;
-	mini?: boolean;
-	front?: boolean;
-	contextmenu?: MenuItem[] | null;
-	buttonsLeft?: any[];
-	buttonsRight?: any[];
-}>(), {
-	initialWidth: 400,
-	initialHeight: null,
-	canResize: false,
-	closeButton: true,
-	mini: false,
-	front: false,
-	contextmenu: null,
-	buttonsLeft: () => [],
-	buttonsRight: () => [],
-});
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-provide('inWindow', true);
-
-let rootEl = $ref<HTMLElement | null>();
-let showing = $ref(true);
-let beforeClickedAt = 0;
-let maximized = $ref(false);
-let unMaximizedTop = '';
-let unMaximizedLeft = '';
-let unMaximizedWidth = '';
-let unMaximizedHeight = '';
-
-function close() {
-	showing = false;
-}
-
-function onKeydown(evt) {
-	if (evt.which === 27) { // Esc
-		evt.preventDefault();
-		evt.stopPropagation();
-		close();
-	}
-}
-
-function onContextmenu(ev: MouseEvent) {
-	if (props.contextmenu) {
-		os.contextMenu(props.contextmenu, ev);
-	}
-}
-
-// 最前面へ移動
-function top() {
-	if (rootEl) {
-		rootEl.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low');
-	}
-}
-
-function maximize() {
-	maximized = true;
-	unMaximizedTop = rootEl.style.top;
-	unMaximizedLeft = rootEl.style.left;
-	unMaximizedWidth = rootEl.style.width;
-	unMaximizedHeight = rootEl.style.height;
-	rootEl.style.top = '0';
-	rootEl.style.left = '0';
-	rootEl.style.width = '100%';
-	rootEl.style.height = '100%';
-}
-
-function unMaximize() {
-	maximized = false;
-	rootEl.style.top = unMaximizedTop;
-	rootEl.style.left = unMaximizedLeft;
-	rootEl.style.width = unMaximizedWidth;
-	rootEl.style.height = unMaximizedHeight;
-}
-
-function onBodyMousedown() {
-	top();
-}
-
-function onDblClick() {
-	maximize();
-}
-
-function onHeaderMousedown(evt: MouseEvent) {
-	// 右クリックはコンテキストメニューを開こうとした可能性が高いため無視
-	if (evt.button === 2) return;
-
-	let beforeMaximized = false;
-
-	if (maximized) {
-		beforeMaximized = true;
-		unMaximize();
-	}
-
-	// ダブルクリック判定
-	if (Date.now() - beforeClickedAt < 300) {
-		beforeClickedAt = Date.now();
-		onDblClick();
-		return;
-	}
-
-	beforeClickedAt = Date.now();
-
-	const main = rootEl;
-	if (main == null) return;
-
-	if (!contains(main, document.activeElement)) main.focus();
-
-	const position = main.getBoundingClientRect();
-
-	const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
-	const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
-	const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
-	const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
-	const browserWidth = window.innerWidth;
-	const browserHeight = window.innerHeight;
-	const windowWidth = main.offsetWidth;
-	const windowHeight = main.offsetHeight;
-
-	function move(x: number, y: number) {
-		let moveLeft = x - moveBaseX;
-		let moveTop = y - moveBaseY;
-
-		// 下はみ出し
-		if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
-
-		// 左はみ出し
-		if (moveLeft < 0) moveLeft = 0;
-
-		// 上はみ出し
-		if (moveTop < 0) moveTop = 0;
-
-		// 右はみ出し
-		if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
-
-		rootEl.style.left = moveLeft + 'px';
-		rootEl.style.top = moveTop + 'px';
-	}
-
-	if (beforeMaximized) {
-		move(clickX, clickY);
-	}
-
-	// 動かした時
-	dragListen(me => {
-		const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX;
-		const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY;
-
-		move(x, y);
-	});
-}
-
-// 上ハンドル掴み時
-function onTopHandleMousedown(evt) {
-	const main = rootEl;
-	// どういうわけかnullになることがある
-	if (main == null) return;
-
-	const base = evt.clientY;
-	const height = parseInt(getComputedStyle(main, '').height, 10);
-	const top = parseInt(getComputedStyle(main, '').top, 10);
-
-	// 動かした時
-	dragListen(me => {
-		const move = me.clientY - base;
-		if (top + move > 0) {
-			if (height + -move > minHeight) {
-				applyTransformHeight(height + -move);
-				applyTransformTop(top + move);
-			} else { // 最小の高さより小さくなろうとした時
-				applyTransformHeight(minHeight);
-				applyTransformTop(top + (height - minHeight));
-			}
-		} else { // 上のはみ出し時
-			applyTransformHeight(top + height);
-			applyTransformTop(0);
-		}
-	});
-}
-
-// 右ハンドル掴み時
-function onRightHandleMousedown(evt) {
-	const main = rootEl;
-	if (main == null) return;
-
-	const base = evt.clientX;
-	const width = parseInt(getComputedStyle(main, '').width, 10);
-	const left = parseInt(getComputedStyle(main, '').left, 10);
-	const browserWidth = window.innerWidth;
-
-	// 動かした時
-	dragListen(me => {
-		const move = me.clientX - base;
-		if (left + width + move < browserWidth) {
-			if (width + move > minWidth) {
-				applyTransformWidth(width + move);
-			} else { // 最小の幅より小さくなろうとした時
-				applyTransformWidth(minWidth);
-			}
-		} else { // 右のはみ出し時
-			applyTransformWidth(browserWidth - left);
-		}
-	});
-}
-
-// 下ハンドル掴み時
-function onBottomHandleMousedown(evt) {
-	const main = rootEl;
-	if (main == null) return;
-
-	const base = evt.clientY;
-	const height = parseInt(getComputedStyle(main, '').height, 10);
-	const top = parseInt(getComputedStyle(main, '').top, 10);
-	const browserHeight = window.innerHeight;
-
-	// 動かした時
-	dragListen(me => {
-		const move = me.clientY - base;
-		if (top + height + move < browserHeight) {
-			if (height + move > minHeight) {
-				applyTransformHeight(height + move);
-			} else { // 最小の高さより小さくなろうとした時
-				applyTransformHeight(minHeight);
-			}
-		} else { // 下のはみ出し時
-			applyTransformHeight(browserHeight - top);
-		}
-	});
-}
-
-// 左ハンドル掴み時
-function onLeftHandleMousedown(evt) {
-	const main = rootEl;
-	if (main == null) return;
-
-	const base = evt.clientX;
-	const width = parseInt(getComputedStyle(main, '').width, 10);
-	const left = parseInt(getComputedStyle(main, '').left, 10);
-
-	// 動かした時
-	dragListen(me => {
-		const move = me.clientX - base;
-		if (left + move > 0) {
-			if (width + -move > minWidth) {
-				applyTransformWidth(width + -move);
-				applyTransformLeft(left + move);
-			} else { // 最小の幅より小さくなろうとした時
-				applyTransformWidth(minWidth);
-				applyTransformLeft(left + (width - minWidth));
-			}
-		} else { // 左のはみ出し時
-			applyTransformWidth(left + width);
-			applyTransformLeft(0);
-		}
-	});
-}
-
-// 左上ハンドル掴み時
-function onTopLeftHandleMousedown(evt) {
-	onTopHandleMousedown(evt);
-	onLeftHandleMousedown(evt);
-}
-
-// 右上ハンドル掴み時
-function onTopRightHandleMousedown(evt) {
-	onTopHandleMousedown(evt);
-	onRightHandleMousedown(evt);
-}
-
-// 右下ハンドル掴み時
-function onBottomRightHandleMousedown(evt) {
-	onBottomHandleMousedown(evt);
-	onRightHandleMousedown(evt);
-}
-
-// 左下ハンドル掴み時
-function onBottomLeftHandleMousedown(evt) {
-	onBottomHandleMousedown(evt);
-	onLeftHandleMousedown(evt);
-}
-
-// 高さを適用
-function applyTransformHeight(height) {
-	if (height > window.innerHeight) height = window.innerHeight;
-	rootEl.style.height = height + 'px';
-}
-
-// 幅を適用
-function applyTransformWidth(width) {
-	if (width > window.innerWidth) width = window.innerWidth;
-	rootEl.style.width = width + 'px';
-}
-
-// Y座標を適用
-function applyTransformTop(top) {
-	rootEl.style.top = top + 'px';
-}
-
-// X座標を適用
-function applyTransformLeft(left) {
-	rootEl.style.left = left + 'px';
-}
-
-function onBrowserResize() {
-	const main = rootEl;
-	if (main == null) return;
-
-	const position = main.getBoundingClientRect();
-	const browserWidth = window.innerWidth;
-	const browserHeight = window.innerHeight;
-	const windowWidth = main.offsetWidth;
-	const windowHeight = main.offsetHeight;
-	if (position.left < 0) main.style.left = '0'; // 左はみ出し
-	if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
-	if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
-	if (position.top < 0) main.style.top = '0'; // 上はみ出し
-}
-
-onMounted(() => {
-	if (props.initialWidth) applyTransformWidth(props.initialWidth);
-	if (props.initialHeight) applyTransformHeight(props.initialHeight);
-
-	applyTransformTop((window.innerHeight / 2) - (rootEl.offsetHeight / 2));
-	applyTransformLeft((window.innerWidth / 2) - (rootEl.offsetWidth / 2));
-
-	// 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする
-	top();
-
-	window.addEventListener('resize', onBrowserResize);
-});
-
-onBeforeUnmount(() => {
-	window.removeEventListener('resize', onBrowserResize);
-});
-
-defineExpose({
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.window-enter-active, .window-leave-active {
-	transition: opacity 0.2s, transform 0.2s !important;
-}
-.window-enter-from, .window-leave-to {
-	pointer-events: none;
-	opacity: 0;
-	transform: scale(0.9);
-}
-
-.ebkgocck {
-	position: fixed;
-	top: 0;
-	left: 0;
-
-	> .body {
-		overflow: clip;
-		display: flex;
-		flex-direction: column;
-		contain: content;
-		width: 100%;
-		height: 100%;
-		border-radius: var(--radius);
-
-		> .header {
-			--height: 39px;
-
-			&.mini {
-				--height: 32px;
-			}
-
-			display: flex;
-			position: relative;
-			z-index: 1;
-			flex-shrink: 0;
-			user-select: none;
-			height: var(--height);
-			background: var(--windowHeader);
-			-webkit-backdrop-filter: var(--blur, blur(15px));
-			backdrop-filter: var(--blur, blur(15px));
-			//border-bottom: solid 1px var(--divider);
-			font-size: 95%;
-			font-weight: bold;
-
-			> .left, > .right {
-				> .button {
-					height: var(--height);
-					width: var(--height);
-
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-
-					&.highlighted {
-						color: var(--accent);
-					}
-				}
-			}
-
-			> .left {
-				margin-right: 16px;
-			}
-
-			> .right {
-				min-width: 16px;
-			}
-
-			> .title {
-				flex: 1;
-				position: relative;
-				line-height: var(--height);
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				cursor: move;
-			}
-		}
-
-		> .body {
-			flex: 1;
-			overflow: auto;
-			background: var(--panel);
-		}
-	}
-
-	> .handle {
-		$size: 8px;
-
-		position: absolute;
-
-		&.top {
-			top: -($size);
-			left: 0;
-			width: 100%;
-			height: $size;
-			cursor: ns-resize;
-		}
-
-		&.right {
-			top: 0;
-			right: -($size);
-			width: $size;
-			height: 100%;
-			cursor: ew-resize;
-		}
-
-		&.bottom {
-			bottom: -($size);
-			left: 0;
-			width: 100%;
-			height: $size;
-			cursor: ns-resize;
-		}
-
-		&.left {
-			top: 0;
-			left: -($size);
-			width: $size;
-			height: 100%;
-			cursor: ew-resize;
-		}
-
-		&.top-left {
-			top: -($size);
-			left: -($size);
-			width: $size * 2;
-			height: $size * 2;
-			cursor: nwse-resize;
-		}
-
-		&.top-right {
-			top: -($size);
-			right: -($size);
-			width: $size * 2;
-			height: $size * 2;
-			cursor: nesw-resize;
-		}
-
-		&.bottom-right {
-			bottom: -($size);
-			right: -($size);
-			width: $size * 2;
-			height: $size * 2;
-			cursor: nwse-resize;
-		}
-
-		&.bottom-left {
-			bottom: -($size);
-			left: -($size);
-			width: $size * 2;
-			height: $size * 2;
-			cursor: nesw-resize;
-		}
-	}
-
-	&.maximized {
-		> .body {
-			border-radius: 0;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/MkYoutubePlayer.vue b/packages/client/src/components/MkYoutubePlayer.vue
deleted file mode 100644
index 8cd481a39c..0000000000
--- a/packages/client/src/components/MkYoutubePlayer.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-<template>
-<XWindow :initial-width="640" :initial-height="402" :can-resize="true" :close-button="true">
-	<template #header>
-		<i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i>
-		<span>{{ title ?? 'YouTube' }}</span>
-	</template>
-
-	<div class="poamfof">
-		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-			<div v-if="player.url" class="player">
-				<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
-			</div>
-		</transition>
-		<MkLoading v-if="fetching"/>
-		<MkError v-else-if="!player.url" @retry="ytFetch()"/>
-	</div>
-</XWindow>
-</template>
-
-<script lang="ts" setup>
-import XWindow from '@/components/MkWindow.vue';
-import { lang } from '@/config';
-
-const props = defineProps<{
-	url: string;
-}>();
-
-const requestUrl = new URL(props.url);
-
-let fetching = $ref(true);
-let title = $ref<string | null>(null);
-let player = $ref({
-	url: null,
-	width: null,
-	height: null,
-});
-
-const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
-
-const ytFetch = (): void => {
-	fetching = true;
-	window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
-		res.json().then(info => {
-			if (info.url == null) return;
-			title = info.title;
-			fetching = false;
-			player = info.player;
-		});
-	});
-};
-
-ytFetch();
-
-</script>
-
-<style lang="scss">
-.poamfof {
-	position: relative;
-	overflow: hidden;
-	height: 100%;
-
-	.player {
-		position: absolute;
-		inset: 0;
-
-		iframe {
-			width: 100%;
-			height: 100%;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/checkbox.vue b/packages/client/src/components/form/checkbox.vue
deleted file mode 100644
index ba3b2dc146..0000000000
--- a/packages/client/src/components/form/checkbox.vue
+++ /dev/null
@@ -1,144 +0,0 @@
-<template>
-<div
-	class="ziffeoms"
-	:class="{ disabled, checked }"
->
-	<input
-		ref="input"
-		type="checkbox"
-		:disabled="disabled"
-		@keydown.enter="toggle"
-	>
-	<span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
-		<i class="check ti ti-check"></i>
-	</span>
-	<span class="label">
-		<!-- TODO: 無名slotの方は廃止 -->
-		<span @click="toggle"><slot name="label"></slot><slot></slot></span>
-		<p class="caption"><slot name="caption"></slot></p>
-	</span>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { toRefs, Ref } from 'vue';
-import * as os from '@/os';
-import Ripple from '@/components/MkRipple.vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: boolean | Ref<boolean>;
-	disabled?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', v: boolean): void;
-}>();
-
-let button = $ref<HTMLElement>();
-const checked = toRefs(props).modelValue;
-const toggle = () => {
-	if (props.disabled) return;
-	emit('update:modelValue', !checked.value);
-
-	if (!checked.value) {
-		const rect = button.getBoundingClientRect();
-		const x = rect.left + (button.offsetWidth / 2);
-		const y = rect.top + (button.offsetHeight / 2);
-		os.popup(Ripple, { x, y, particle: false }, {}, 'end');
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.ziffeoms {
-	position: relative;
-	display: flex;
-	transition: all 0.2s ease;
-
-	> * {
-		user-select: none;
-	}
-
-	> input {
-		position: absolute;
-		width: 0;
-		height: 0;
-		opacity: 0;
-		margin: 0;
-	}
-
-	> .button {
-		position: relative;
-		display: inline-flex;
-		flex-shrink: 0;
-		margin: 0;
-		box-sizing: border-box;
-		width: 23px;
-		height: 23px;
-		outline: none;
-		background: var(--panel);
-		border: solid 1px var(--panel);
-		border-radius: 4px;
-		cursor: pointer;
-		transition: inherit;
-
-		> .check {
-			margin: auto;
-			opacity: 0;
-			color: var(--fgOnAccent);
-			font-size: 13px;
-			transform: scale(0.5);
-			transition: all 0.2s ease;
-		}
-	}
-
-	&:hover {
-		> .button {
-			border-color: var(--inputBorderHover) !important;
-		}
-	}
-
-	> .label {
-		margin-left: 12px;
-		margin-top: 2px;
-		display: block;
-		transition: inherit;
-		color: var(--fg);
-
-		> span {
-			display: block;
-			line-height: 20px;
-			cursor: pointer;
-			transition: inherit;
-		}
-
-		> .caption {
-			margin: 8px 0 0 0;
-			color: var(--fgTransparentWeak);
-			font-size: 0.85em;
-
-			&:empty {
-				display: none;
-			}
-		}
-	}
-
-	&.disabled {
-		opacity: 0.6;
-		cursor: not-allowed;
-	}
-
-	&.checked {
-		> .button {
-			background-color: var(--accent) !important;
-			border-color: var(--accent) !important;
-
-			> .check {
-				opacity: 1;
-				transform: scale(1);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue
deleted file mode 100644
index 1256dfcbb4..0000000000
--- a/packages/client/src/components/form/folder.vue
+++ /dev/null
@@ -1,107 +0,0 @@
-<template>
-<div class="dwzlatin" :class="{ opened }">
-	<div class="header _button" @click="toggle">
-		<span class="icon"><slot name="icon"></slot></span>
-		<span class="text"><slot name="label"></slot></span>
-		<span class="right">
-			<span class="text"><slot name="suffix"></slot></span>
-			<i v-if="opened" class="ti ti-chevron-up icon"></i>
-			<i v-else class="ti ti-chevron-down icon"></i>
-		</span>
-	</div>
-	<KeepAlive>
-		<div v-if="openedAtLeastOnce" v-show="opened" class="body">
-			<MkSpacer :margin-min="14" :margin-max="22">
-				<slot></slot>
-			</MkSpacer>
-		</div>
-	</KeepAlive>
-</div>
-</template>
-
-<script lang="ts" setup>
-const props = withDefaults(defineProps<{
-	defaultOpen: boolean;
-}>(), {
-	defaultOpen: false,
-});
-
-let opened = $ref(props.defaultOpen);
-let openedAtLeastOnce = $ref(props.defaultOpen);
-
-const toggle = () => {
-	opened = !opened;
-	if (opened) {
-		openedAtLeastOnce = true;
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.dwzlatin {
-	display: block;
-
-	> .header {
-		display: flex;
-		align-items: center;
-		width: 100%;
-		box-sizing: border-box;
-		padding: 10px 14px 10px 14px;
-		background: var(--buttonBg);
-		border-radius: 6px;
-
-		&:hover {
-			text-decoration: none;
-			background: var(--buttonHoverBg);
-		}
-
-		&.active {
-			color: var(--accent);
-			background: var(--buttonHoverBg);
-		}
-
-		> .icon {
-			margin-right: 0.75em;
-			flex-shrink: 0;
-			text-align: center;
-			opacity: 0.8;
-
-			&:empty {
-				display: none;
-
-				& + .text {
-					padding-left: 4px;
-				}
-			}
-		}
-
-		> .text {
-			white-space: nowrap;
-			text-overflow: ellipsis;
-			overflow: hidden;
-			padding-right: 12px;
-		}
-
-		> .right {
-			margin-left: auto;
-			opacity: 0.7;
-			white-space: nowrap;
-
-			> .text:not(:empty) {
-				margin-right: 0.75em;
-			}
-		}
-	}
-
-	> .body {
-		background: var(--panel);
-		border-radius: 0 0 6px 6px;
-	}
-
-	&.opened {
-		> .header {
-			border-radius: 6px 6px 0 0;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue
deleted file mode 100644
index 939e9691a6..0000000000
--- a/packages/client/src/components/form/input.vue
+++ /dev/null
@@ -1,263 +0,0 @@
-<template>
-<div class="matxzzsk">
-	<div class="label" @click="focus"><slot name="label"></slot></div>
-	<div class="input" :class="{ inline, disabled, focused }">
-		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
-		<input
-			ref="inputEl"
-			v-model="v"
-			v-adaptive-border
-			:type="type"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			:placeholder="placeholder"
-			:pattern="pattern"
-			:autocomplete="autocomplete"
-			:spellcheck="spellcheck"
-			:step="step"
-			:list="id"
-			@focus="focused = true"
-			@blur="focused = false"
-			@keydown="onKeydown($event)"
-			@input="onInput"
-		>
-		<datalist v-if="datalist" :id="id">
-			<option v-for="data in datalist" :value="data"/>
-		</datalist>
-		<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
-	</div>
-	<div class="caption"><slot name="caption"></slot></div>
-
-	<MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import { debounce } from 'throttle-debounce';
-import MkButton from '@/components/MkButton.vue';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: string | number;
-	type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search';
-	required?: boolean;
-	readonly?: boolean;
-	disabled?: boolean;
-	pattern?: string;
-	placeholder?: string;
-	autofocus?: boolean;
-	autocomplete?: boolean;
-	spellcheck?: boolean;
-	step?: any;
-	datalist?: string[];
-	inline?: boolean;
-	debounce?: boolean;
-	manualSave?: boolean;
-	small?: boolean;
-	large?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'change', _ev: KeyboardEvent): void;
-	(ev: 'keydown', _ev: KeyboardEvent): void;
-	(ev: 'enter'): void;
-	(ev: 'update:modelValue', value: string | number): void;
-}>();
-
-const { modelValue, type, autofocus } = toRefs(props);
-const v = ref(modelValue.value);
-const id = Math.random().toString(); // TODO: uuid?
-const focused = ref(false);
-const changed = ref(false);
-const invalid = ref(false);
-const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = ref<HTMLElement>();
-const prefixEl = ref<HTMLElement>();
-const suffixEl = ref<HTMLElement>();
-const height =
-	props.small ? 36 :
-	props.large ? 40 :
-	38;
-
-const focus = () => inputEl.value.focus();
-const onInput = (ev: KeyboardEvent) => {
-	changed.value = true;
-	emit('change', ev);
-};
-const onKeydown = (ev: KeyboardEvent) => {
-	emit('keydown', ev);
-
-	if (ev.code === 'Enter') {
-		emit('enter');
-	}
-};
-
-const updated = () => {
-	changed.value = false;
-	if (type.value === 'number') {
-		emit('update:modelValue', parseFloat(v.value));
-	} else {
-		emit('update:modelValue', v.value);
-	}
-};
-
-const debouncedUpdated = debounce(1000, updated);
-
-watch(modelValue, newValue => {
-	v.value = newValue;
-});
-
-watch(v, newValue => {
-	if (!props.manualSave) {
-		if (props.debounce) {
-			debouncedUpdated();
-		} else {
-			updated();
-		}
-	}
-
-	invalid.value = inputEl.value.validity.badInput;
-});
-
-// このコンポーネントが作成された時、非表示状態である場合がある
-// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
-useInterval(() => {
-	if (prefixEl.value) {
-		if (prefixEl.value.offsetWidth) {
-			inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
-		}
-	}
-	if (suffixEl.value) {
-		if (suffixEl.value.offsetWidth) {
-			inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
-		}
-	}
-}, 100, {
-	immediate: true,
-	afterMounted: true,
-});
-
-onMounted(() => {
-	nextTick(() => {
-		if (autofocus.value) {
-			focus();
-		}
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-.matxzzsk {
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .input {
-		position: relative;
-
-		> input {
-			appearance: none;
-			-webkit-appearance: none;
-			display: block;
-			height: v-bind("height + 'px'");
-			width: 100%;
-			margin: 0;
-			padding: 0 12px;
-			font: inherit;
-			font-weight: normal;
-			font-size: 1em;
-			color: var(--fg);
-			background: var(--panel);
-			border: solid 1px var(--panel);
-			border-radius: 6px;
-			outline: none;
-			box-shadow: none;
-			box-sizing: border-box;
-			transition: border-color 0.1s ease-out;
-
-			&:hover {
-				border-color: var(--inputBorderHover) !important;
-			}
-		}
-
-		> .prefix,
-		> .suffix {
-			display: flex;
-			align-items: center;
-			position: absolute;
-			z-index: 1;
-			top: 0;
-			padding: 0 12px;
-			font-size: 1em;
-			height: v-bind("height + 'px'");
-			pointer-events: none;
-
-			&:empty {
-				display: none;
-			}
-
-			> * {
-				display: inline-block;
-				min-width: 16px;
-				max-width: 150px;
-				overflow: hidden;
-				white-space: nowrap;
-				text-overflow: ellipsis;
-			}
-		}
-
-		> .prefix {
-			left: 0;
-			padding-right: 6px;
-		}
-
-		> .suffix {
-			right: 0;
-			padding-left: 6px;
-		}
-
-		&.inline {
-			display: inline-block;
-			margin: 0;
-		}
-
-		&.focused {
-			> input {
-				border-color: var(--accent) !important;
-				//box-shadow: 0 0 0 4px var(--focus);
-			}
-		}
-
-		&.disabled {
-			opacity: 0.7;
-
-			&, * {
-				cursor: not-allowed !important;
-			}
-		}
-	}
-
-	> .save {
-		margin: 8px 0 0 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/link.vue b/packages/client/src/components/form/link.vue
deleted file mode 100644
index a1775c0bdb..0000000000
--- a/packages/client/src/components/form/link.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<template>
-<div class="ffcbddfc" :class="{ inline }">
-	<a v-if="external" class="main _button" :href="to" target="_blank">
-		<span class="icon"><slot name="icon"></slot></span>
-		<span class="text"><slot></slot></span>
-		<span class="right">
-			<span class="text"><slot name="suffix"></slot></span>
-			<i class="ti ti-external-link icon"></i>
-		</span>
-	</a>
-	<MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior">
-		<span class="icon"><slot name="icon"></slot></span>
-		<span class="text"><slot></slot></span>
-		<span class="right">
-			<span class="text"><slot name="suffix"></slot></span>
-			<i class="ti ti-chevron-right icon"></i>
-		</span>
-	</MkA>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	to: string;
-	active?: boolean;
-	external?: boolean;
-	behavior?: null | 'window' | 'browser' | 'modalWindow';
-	inline?: boolean;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.ffcbddfc {
-	display: block;
-
-	&.inline {
-		display: inline-block;
-	}
-
-	> .main {
-		display: flex;
-		align-items: center;
-		width: 100%;
-		box-sizing: border-box;
-		padding: 10px 14px;
-		background: var(--buttonBg);
-		border-radius: 6px;
-		font-size: 0.9em;
-
-		&:hover {
-			text-decoration: none;
-			background: var(--buttonHoverBg);
-		}
-
-		&.active {
-			color: var(--accent);
-			background: var(--buttonHoverBg);
-		}
-
-		> .icon {
-			margin-right: 0.75em;
-			flex-shrink: 0;
-			text-align: center;
-			color: var(--fgTransparentWeak);
-
-			&:empty {
-				display: none;
-
-				& + .text {
-					padding-left: 4px;
-				}
-			}
-		}
-
-		> .text {
-			flex-shrink: 1;
-			white-space: normal;
-			padding-right: 12px;
-			text-align: center;
-		}
-
-		> .right {
-			margin-left: auto;
-			opacity: 0.7;
-			white-space: nowrap;
-
-			> .text:not(:empty) {
-				margin-right: 0.75em;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue
deleted file mode 100644
index fcf454c77a..0000000000
--- a/packages/client/src/components/form/radio.vue
+++ /dev/null
@@ -1,132 +0,0 @@
-<template>
-<div
-	v-adaptive-border
-	class="novjtctn"
-	:class="{ disabled, checked }"
-	:aria-checked="checked"
-	:aria-disabled="disabled"
-	@click="toggle"
->
-	<input
-		type="radio"
-		:disabled="disabled"
-	>
-	<span class="button">
-		<span></span>
-	</span>
-	<span class="label"><slot></slot></span>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	modelValue: any;
-	value: any;
-	disabled: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any): void;
-}>();
-
-let checked = $computed(() => props.modelValue === props.value);
-
-function toggle(): void {
-	if (props.disabled) return;
-	emit('update:modelValue', props.value);
-}
-</script>
-
-<style lang="scss" scoped>
-.novjtctn {
-	position: relative;
-	display: inline-block;
-	text-align: left;
-	cursor: pointer;
-	padding: 7px 10px;
-	min-width: 60px;
-	background-color: var(--panel);
-	background-clip: padding-box !important;
-	border: solid 1px var(--panel);
-	border-radius: 6px;
-	font-size: 90%;
-	transition: all 0.2s;
-
-	> * {
-		user-select: none;
-	}
-
-	&.disabled {
-		opacity: 0.6;
-
-		&, * {
-			cursor: not-allowed !important;
-		}
-	}
-
-	&:hover {
-		border-color: var(--inputBorderHover) !important;
-	}
-
-	&.checked {
-		background-color: var(--accentedBg) !important;
-		border-color: var(--accentedBg) !important;
-		color: var(--accent);
-
-		&, * {
-			cursor: default !important;
-		}
-
-		> .button {
-			border-color: var(--accent);
-
-			&:after {
-				background-color: var(--accent);
-				transform: scale(1);
-				opacity: 1;
-			}
-		}
-	}
-
-	> input {
-		position: absolute;
-		width: 0;
-		height: 0;
-		opacity: 0;
-		margin: 0;
-	}
-
-	> .button {
-		position: absolute;
-		width: 14px;
-		height: 14px;
-		background: none;
-		border: solid 2px var(--inputBorder);
-		border-radius: 100%;
-		transition: inherit;
-
-		&:after {
-			content: '';
-			display: block;
-			position: absolute;
-			top: 3px;
-			right: 3px;
-			bottom: 3px;
-			left: 3px;
-			border-radius: 100%;
-			opacity: 0;
-			transform: scale(0);
-			transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
-		}
-	}
-
-	> .label {
-		margin-left: 28px;
-		display: block;
-		line-height: 20px;
-		cursor: pointer;
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue
deleted file mode 100644
index bde4a8fb00..0000000000
--- a/packages/client/src/components/form/radios.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-<script lang="ts">
-import { defineComponent, h } from 'vue';
-import MkRadio from './radio.vue';
-
-export default defineComponent({
-	components: {
-		MkRadio,
-	},
-	props: {
-		modelValue: {
-			required: false,
-		},
-	},
-	data() {
-		return {
-			value: this.modelValue,
-		};
-	},
-	watch: {
-		value() {
-			this.$emit('update:modelValue', this.value);
-		},
-	},
-	render() {
-		let options = this.$slots.default();
-		const label = this.$slots.label && this.$slots.label();
-		const caption = this.$slots.caption && this.$slots.caption();
-
-		// なぜかFragmentになることがあるため
-		if (options.length === 1 && options[0].props == null) options = options[0].children;
-
-		return h('div', {
-			class: 'novjtcto',
-		}, [
-			...(label ? [h('div', {
-				class: 'label',
-			}, [label])] : []),
-			h('div', {
-				class: 'body',
-			}, options.map(option => h(MkRadio, {
-				key: option.key,
-				value: option.props.value,
-				modelValue: this.value,
-				'onUpdate:modelValue': value => this.value = value,
-			}, option.children)),
-			),
-			...(caption ? [h('div', {
-				class: 'caption',
-			}, [caption])] : []),
-		]);
-	},
-});
-</script>
-
-<style lang="scss">
-.novjtcto {
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .body {
-		display: flex;
-    gap: 12px;
-    flex-wrap: wrap;
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue
deleted file mode 100644
index db21c35717..0000000000
--- a/packages/client/src/components/form/range.vue
+++ /dev/null
@@ -1,259 +0,0 @@
-<template>
-<div class="timctyfi" :class="{ disabled, easing }">
-	<div class="label"><slot name="label"></slot></div>
-	<div v-adaptive-border class="body">
-		<div ref="containerEl" class="container">
-			<div class="track">
-				<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
-			</div>
-			<div v-if="steps && showTicks" class="ticks">
-				<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
-			</div>
-			<div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div>
-		</div>
-	</div>
-	<div class="caption"><slot name="caption"></slot></div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
-import * as os from '@/os';
-
-const props = withDefaults(defineProps<{
-	modelValue: number;
-	disabled?: boolean;
-	min: number;
-	max: number;
-	step?: number;
-	textConverter?: (value: number) => string,
-	showTicks?: boolean;
-	easing?: boolean;
-}>(), {
-	step: 1,
-	textConverter: (v) => v.toString(),
-	easing: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: number): void;
-}>();
-
-const containerEl = ref<HTMLElement>();
-const thumbEl = ref<HTMLElement>();
-
-const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
-const steppedRawValue = computed(() => {
-	if (props.step) {
-		const step = props.step / (props.max - props.min);
-		return (step * Math.round(rawValue.value / step));
-	} else {
-		return rawValue.value;
-	}
-});
-const finalValue = computed(() => {
-	if (Number.isInteger(props.step)) {
-		return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min);
-	} else {
-		return (steppedRawValue.value * (props.max - props.min)) + props.min;
-	}
-});
-
-const thumbWidth = computed(() => {
-	if (thumbEl.value == null) return 0;
-	return thumbEl.value!.offsetWidth;
-});
-const thumbPosition = ref(0);
-const calcThumbPosition = () => {
-	if (containerEl.value == null) {
-		thumbPosition.value = 0;
-	} else {
-		thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value;
-	}
-};
-watch([steppedRawValue, containerEl], calcThumbPosition);
-
-let ro: ResizeObserver | undefined;
-
-onMounted(() => {
-	ro = new ResizeObserver((entries, observer) => {
-		calcThumbPosition();
-	});
-	ro.observe(containerEl.value);
-});
-
-onUnmounted(() => {
-	if (ro) ro.disconnect();
-});
-
-const steps = computed(() => {
-	if (props.step) {
-		return (props.max - props.min) / props.step;
-	} else {
-		return 0;
-	}
-});
-
-const onMousedown = (ev: MouseEvent | TouchEvent) => {
-	ev.preventDefault();
-
-	const tooltipShowing = ref(true);
-	os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
-		showing: tooltipShowing,
-		text: computed(() => {
-			return props.textConverter(finalValue.value);
-		}),
-		targetElement: thumbEl,
-	}, {}, 'closed');
-
-	const style = document.createElement('style');
-	style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
-	document.head.appendChild(style);
-
-	const onDrag = (ev: MouseEvent | TouchEvent) => {
-		ev.preventDefault();
-		const containerRect = containerEl.value!.getBoundingClientRect();
-		const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
-		const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2));
-		rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value)));
-	};
-
-	let beforeValue = finalValue.value;
-
-	const onMouseup = () => {
-		document.head.removeChild(style);
-		tooltipShowing.value = false;
-		window.removeEventListener('mousemove', onDrag);
-		window.removeEventListener('touchmove', onDrag);
-		window.removeEventListener('mouseup', onMouseup);
-		window.removeEventListener('touchend', onMouseup);
-
-		// 値が変わってたら通知
-		if (beforeValue !== finalValue.value) {
-			emit('update:modelValue', finalValue.value);
-		}
-	};
-
-	window.addEventListener('mousemove', onDrag);
-	window.addEventListener('touchmove', onDrag);
-	window.addEventListener('mouseup', onMouseup, { once: true });
-	window.addEventListener('touchend', onMouseup, { once: true });
-};
-</script>
-
-<style lang="scss" scoped>
-@use "sass:math";
-
-.timctyfi {
-	position: relative;
-
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	$thumbHeight: 20px;
-	$thumbWidth: 20px;
-
-	> .body {
-		padding: 10px 12px;
-		background: var(--panel);
-		border: solid 1px var(--panel);
-		border-radius: 6px;
-
-		> .container {
-			position: relative;
-			height: $thumbHeight;
-
-			> .track {
-				position: absolute;
-				top: 0;
-				bottom: 0;
-				left: 0;
-				right: 0;
-				margin: auto;
-				width: calc(100% - #{$thumbWidth});
-				height: 3px;
-				background: rgba(0, 0, 0, 0.1);
-				border-radius: 999px;
-				overflow: clip;
-
-				> .highlight {
-					position: absolute;
-					top: 0;
-					left: 0;
-					height: 100%;
-					background: var(--accent);
-					opacity: 0.5;
-				}
-			}
-
-			> .ticks {
-				$tickWidth: 3px;
-
-				position: absolute;
-				top: 0;
-				bottom: 0;
-				left: 0;
-				right: 0;
-				margin: auto;
-				width: calc(100% - #{$thumbWidth});
-
-				> .tick {
-					position: absolute;
-					bottom: 0;
-					width: $tickWidth;
-					height: 3px;
-					margin-left: - math.div($tickWidth, 2);
-					background: var(--divider);
-					border-radius: 999px;
-				}
-			}
-
-			> .thumb {
-				position: absolute;
-				width: $thumbWidth;
-				height: $thumbHeight;
-				cursor: grab;
-				background: var(--accent);
-				border-radius: 999px;
-
-				&:hover {
-					background: var(--accentLighten);
-				}
-			}
-		}
-	}
-
-	&.easing {
-		> .body {
-			> .container {
-				> .track {
-					> .highlight {
-						transition: width 0.2s cubic-bezier(0,0,0,1);
-					}
-				}
-
-				> .thumb {
-					transition: left 0.2s cubic-bezier(0,0,0,1);
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue
deleted file mode 100644
index c6e34ef1cc..0000000000
--- a/packages/client/src/components/form/section.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<template>
-<div class="vrtktovh _formBlock">
-	<div class="label"><slot name="label"></slot></div>
-	<div class="main _formRoot">
-		<slot></slot>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-</script>
-
-<style lang="scss" scoped>
-.vrtktovh {
-	border-top: solid 0.5px var(--divider);
-	border-bottom: solid 0.5px var(--divider);
-
-	& + .vrtktovh {
-		border-top: none;
-	}
-
-	&:first-child {
-		border-top: none;
-	}
-
-	&:last-child {
-		border-bottom: none;
-	}
-
-	> .label {
-		font-weight: bold;
-		margin: 1.5em 0 16px 0;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .main {
-		margin: 1.5em 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue
deleted file mode 100644
index eaf4b131cd..0000000000
--- a/packages/client/src/components/form/select.vue
+++ /dev/null
@@ -1,279 +0,0 @@
-<template>
-<div class="vblkjoeq">
-	<div class="label" @click="focus"><slot name="label"></slot></div>
-	<div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
-		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
-		<select
-			ref="inputEl"
-			v-model="v"
-			v-adaptive-border
-			class="select"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			:placeholder="placeholder"
-			@focus="focused = true"
-			@blur="focused = false"
-			@input="onInput"
-		>
-			<slot></slot>
-		</select>
-		<div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down"></i></div>
-	</div>
-	<div class="caption"><slot name="caption"></slot></div>
-
-	<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: string;
-	required?: boolean;
-	readonly?: boolean;
-	disabled?: boolean;
-	placeholder?: string;
-	autofocus?: boolean;
-	inline?: boolean;
-	manualSave?: boolean;
-	small?: boolean;
-	large?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'change', _ev: KeyboardEvent): void;
-	(ev: 'update:modelValue', value: string): void;
-}>();
-
-const slots = useSlots();
-
-const { modelValue, autofocus } = toRefs(props);
-const v = ref(modelValue.value);
-const focused = ref(false);
-const changed = ref(false);
-const invalid = ref(false);
-const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = ref(null);
-const prefixEl = ref(null);
-const suffixEl = ref(null);
-const container = ref(null);
-const height =
-	props.small ? 36 :
-	props.large ? 40 :
-	38;
-
-const focus = () => inputEl.value.focus();
-const onInput = (ev) => {
-	changed.value = true;
-	emit('change', ev);
-};
-
-const updated = () => {
-	changed.value = false;
-	emit('update:modelValue', v.value);
-};
-
-watch(modelValue, newValue => {
-	v.value = newValue;
-});
-
-watch(v, newValue => {
-	if (!props.manualSave) {
-		updated();
-	}
-
-	invalid.value = inputEl.value.validity.badInput;
-});
-
-// このコンポーネントが作成された時、非表示状態である場合がある
-// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
-useInterval(() => {
-	if (prefixEl.value) {
-		if (prefixEl.value.offsetWidth) {
-			inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
-		}
-	}
-	if (suffixEl.value) {
-		if (suffixEl.value.offsetWidth) {
-			inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
-		}
-	}
-}, 100, {
-	immediate: true,
-	afterMounted: true,
-});
-
-onMounted(() => {
-	nextTick(() => {
-		if (autofocus.value) {
-			focus();
-		}
-	});
-});
-
-const onClick = (ev: MouseEvent) => {
-	focused.value = true;
-
-	const menu = [];
-	let options = slots.default!();
-
-	const pushOption = (option: VNode) => {
-		menu.push({
-			text: option.children,
-			active: v.value === option.props.value,
-			action: () => {
-				v.value = option.props.value;
-			},
-		});
-	};
-
-	const scanOptions = (options: VNode[]) => {
-		for (const vnode of options) {
-			if (vnode.type === 'optgroup') {
-				const optgroup = vnode;
-				menu.push({
-					type: 'label',
-					text: optgroup.props.label,
-				});
-				scanOptions(optgroup.children);
-			} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
-				const fragment = vnode;
-				scanOptions(fragment.children);
-			} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
-				// nop?
-			} else {
-				const option = vnode;
-				pushOption(option);
-			}
-		}
-	};
-
-	scanOptions(options);
-
-	os.popupMenu(menu, container.value, {
-		width: container.value.offsetWidth,
-	}).then(() => {
-		focused.value = false;
-	});
-};
-</script>
-
-<style lang="scss" scoped>
-.vblkjoeq {
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .input {
-		position: relative;
-		cursor: pointer;
-
-		&:hover {
-			> .select {
-				border-color: var(--inputBorderHover) !important;
-			}
-		}
-
-		> .select {
-			appearance: none;
-			-webkit-appearance: none;
-			display: block;
-			height: v-bind("height + 'px'");
-			width: 100%;
-			margin: 0;
-			padding: 0 12px;
-			font: inherit;
-			font-weight: normal;
-			font-size: 1em;
-			color: var(--fg);
-			background: var(--panel);
-			border: solid 1px var(--panel);
-			border-radius: 6px;
-			outline: none;
-			box-shadow: none;
-			box-sizing: border-box;
-			cursor: pointer;
-			transition: border-color 0.1s ease-out;
-			pointer-events: none;
-			user-select: none;
-		}
-
-		> .prefix,
-		> .suffix {
-			display: flex;
-			align-items: center;
-			position: absolute;
-			z-index: 1;
-			top: 0;
-			padding: 0 12px;
-			font-size: 1em;
-			height: v-bind("height + 'px'");
-			pointer-events: none;
-
-			&:empty {
-				display: none;
-			}
-
-			> * {
-				display: inline-block;
-				min-width: 16px;
-				max-width: 150px;
-				overflow: hidden;
-				white-space: nowrap;
-				text-overflow: ellipsis;
-			}
-		}
-
-		> .prefix {
-			left: 0;
-			padding-right: 6px;
-		}
-
-		> .suffix {
-			right: 0;
-			padding-left: 6px;
-		}
-
-		&.inline {
-			display: inline-block;
-			margin: 0;
-		}
-
-		&.focused {
-			> select {
-				border-color: var(--accent) !important;
-			}
-		}
-
-		&.disabled {
-			opacity: 0.7;
-
-			&, * {
-				cursor: not-allowed !important;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue
deleted file mode 100644
index 79ce8fe51f..0000000000
--- a/packages/client/src/components/form/slot.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-<template>
-<div class="adhpbeou">
-	<div class="label" @click="focus"><slot name="label"></slot></div>
-	<div class="content">
-		<slot></slot>
-	</div>
-	<div class="caption"><slot name="caption"></slot></div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-function focus() {
-	// TODO
-}
-</script>
-
-<style lang="scss" scoped>
-.adhpbeou {
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/split.vue b/packages/client/src/components/form/split.vue
deleted file mode 100644
index 301a8a84e5..0000000000
--- a/packages/client/src/components/form/split.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-<div class="terlnhxf _formBlock">
-	<slot></slot>
-</div>
-</template>
-
-<script lang="ts" setup>
-const props = withDefaults(defineProps<{
-	minWidth?: number;
-}>(), {
-	minWidth: 210,
-});
-
-const minWidth = props.minWidth + 'px';
-</script>
-
-<style lang="scss" scoped>
-.terlnhxf {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(v-bind('minWidth'), 1fr));
-	grid-gap: 12px;
-
-	> ::v-deep(*) {
-		margin: 0 !important;
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/suspense.vue b/packages/client/src/components/form/suspense.vue
deleted file mode 100644
index 7efa501f27..0000000000
--- a/packages/client/src/components/form/suspense.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-	<div v-if="pending">
-		<MkLoading/>
-	</div>
-	<div v-else-if="resolved">
-		<slot :result="result"></slot>
-	</div>
-	<div v-else>
-		<div class="wszdbhzo">
-			<div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div>
-			<MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton>
-		</div>
-	</div>
-</transition>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType, ref, watch } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-
-	props: {
-		p: {
-			type: Function as PropType<() => Promise<any>>,
-			required: true,
-		},
-	},
-
-	setup(props, context) {
-		const pending = ref(true);
-		const resolved = ref(false);
-		const rejected = ref(false);
-		const result = ref(null);
-
-		const process = () => {
-			if (props.p == null) {
-				return;
-			}
-			const promise = props.p();
-			pending.value = true;
-			resolved.value = false;
-			rejected.value = false;
-			promise.then((_result) => {
-				pending.value = false;
-				resolved.value = true;
-				result.value = _result;
-			});
-			promise.catch(() => {
-				pending.value = false;
-				rejected.value = true;
-			});
-		};
-
-		watch(() => props.p, () => {
-			process();
-		}, {
-			immediate: true,
-		});
-
-		const retry = () => {
-			process();
-		};
-
-		return {
-			pending,
-			resolved,
-			rejected,
-			result,
-			retry,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.wszdbhzo {
-	padding: 16px;
-	text-align: center;
-
-	> .retry {
-		margin-top: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue
deleted file mode 100644
index 1ed00ae655..0000000000
--- a/packages/client/src/components/form/switch.vue
+++ /dev/null
@@ -1,144 +0,0 @@
-<template>
-<div
-	class="ziffeomt"
-	:class="{ disabled, checked }"
->
-	<input
-		ref="input"
-		type="checkbox"
-		:disabled="disabled"
-		@keydown.enter="toggle"
-	>
-	<span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
-		<div class="knob"></div>
-	</span>
-	<span class="label">
-		<!-- TODO: 無名slotの方は廃止 -->
-		<span @click="toggle"><slot name="label"></slot><slot></slot></span>
-		<p class="caption"><slot name="caption"></slot></p>
-	</span>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { toRefs, Ref } from 'vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	modelValue: boolean | Ref<boolean>;
-	disabled?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', v: boolean): void;
-}>();
-
-let button = $ref<HTMLElement>();
-const checked = toRefs(props).modelValue;
-const toggle = () => {
-	if (props.disabled) return;
-	emit('update:modelValue', !checked.value);
-
-	if (!checked.value) {
-
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.ziffeomt {
-	position: relative;
-	display: flex;
-	transition: all 0.2s ease;
-
-	> * {
-		user-select: none;
-	}
-
-	> input {
-		position: absolute;
-		width: 0;
-		height: 0;
-		opacity: 0;
-		margin: 0;
-	}
-
-	> .button {
-		position: relative;
-		display: inline-flex;
-		flex-shrink: 0;
-		margin: 0;
-		box-sizing: border-box;
-		width: 32px;
-		height: 23px;
-		outline: none;
-		background: var(--swutchOffBg);
-		background-clip: content-box;
-		border: solid 1px var(--swutchOffBg);
-		border-radius: 999px;
-		cursor: pointer;
-		transition: inherit;
-		user-select: none;
-
-		> .knob {
-			position: absolute;
-			top: 3px;
-			left: 3px;
-			width: 15px;
-			height: 15px;
-			background: var(--swutchOffFg);
-			border-radius: 999px;
-			transition: all 0.2s ease;
-		}
-	}
-
-	&:hover {
-		> .button {
-			border-color: var(--inputBorderHover) !important;
-		}
-	}
-
-	> .label {
-		margin-left: 12px;
-		margin-top: 2px;
-		display: block;
-		transition: inherit;
-		color: var(--fg);
-
-		> span {
-			display: block;
-			line-height: 20px;
-			cursor: pointer;
-			transition: inherit;
-		}
-
-		> .caption {
-			margin: 8px 0 0 0;
-			color: var(--fgTransparentWeak);
-			font-size: 0.85em;
-
-			&:empty {
-				display: none;
-			}
-		}
-	}
-
-	&.disabled {
-		opacity: 0.6;
-		cursor: not-allowed;
-	}
-
-	&.checked {
-		> .button {
-			background-color: var(--swutchOnBg) !important;
-			border-color: var(--swutchOnBg) !important;
-
-			> .knob {
-				left: 12px;
-				background: var(--swutchOnFg);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue
deleted file mode 100644
index d34d7b1775..0000000000
--- a/packages/client/src/components/form/textarea.vue
+++ /dev/null
@@ -1,260 +0,0 @@
-<template>
-<div class="adhpbeos">
-	<div class="label" @click="focus"><slot name="label"></slot></div>
-	<div class="input" :class="{ disabled, focused, tall, pre }">
-		<textarea
-			ref="inputEl"
-			v-model="v"
-			v-adaptive-border
-			:class="{ code, _monospace: code }"
-			:disabled="disabled"
-			:required="required"
-			:readonly="readonly"
-			:placeholder="placeholder"
-			:pattern="pattern"
-			:autocomplete="autocomplete"
-			:spellcheck="spellcheck"
-			@focus="focused = true"
-			@blur="focused = false"
-			@keydown="onKeydown($event)"
-			@input="onInput"
-		></textarea>
-	</div>
-	<div class="caption"><slot name="caption"></slot></div>
-
-	<MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
-import { debounce } from 'throttle-debounce';
-import MkButton from '@/components/MkButton.vue';
-import { i18n } from '@/i18n';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-
-	props: {
-		modelValue: {
-			required: true,
-		},
-		type: {
-			type: String,
-			required: false,
-		},
-		required: {
-			type: Boolean,
-			required: false,
-		},
-		readonly: {
-			type: Boolean,
-			required: false,
-		},
-		disabled: {
-			type: Boolean,
-			required: false,
-		},
-		pattern: {
-			type: String,
-			required: false,
-		},
-		placeholder: {
-			type: String,
-			required: false,
-		},
-		autofocus: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		autocomplete: {
-			required: false,
-		},
-		spellcheck: {
-			required: false,
-		},
-		code: {
-			type: Boolean,
-			required: false,
-		},
-		tall: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		pre: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		debounce: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		manualSave: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-
-	emits: ['change', 'keydown', 'enter', 'update:modelValue'],
-
-	setup(props, context) {
-		const { modelValue, autofocus } = toRefs(props);
-		const v = ref(modelValue.value);
-		const focused = ref(false);
-		const changed = ref(false);
-		const invalid = ref(false);
-		const filled = computed(() => v.value !== '' && v.value != null);
-		const inputEl = ref(null);
-
-		const focus = () => inputEl.value.focus();
-		const onInput = (ev) => {
-			changed.value = true;
-			context.emit('change', ev);
-		};
-		const onKeydown = (ev: KeyboardEvent) => {
-			context.emit('keydown', ev);
-
-			if (ev.code === 'Enter') {
-				context.emit('enter');
-			}
-		};
-
-		const updated = () => {
-			changed.value = false;
-			context.emit('update:modelValue', v.value);
-		};
-
-		const debouncedUpdated = debounce(1000, updated);
-
-		watch(modelValue, newValue => {
-			v.value = newValue;
-		});
-
-		watch(v, newValue => {
-			if (!props.manualSave) {
-				if (props.debounce) {
-					debouncedUpdated();
-				} else {
-					updated();
-				}
-			}
-
-			invalid.value = inputEl.value.validity.badInput;
-		});
-
-		onMounted(() => {
-			nextTick(() => {
-				if (autofocus.value) {
-					focus();
-				}
-			});
-		});
-
-		return {
-			v,
-			focused,
-			invalid,
-			changed,
-			filled,
-			inputEl,
-			focus,
-			onInput,
-			onKeydown,
-			updated,
-			i18n,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.adhpbeos {
-	> .label {
-		font-size: 0.85em;
-		padding: 0 0 8px 0;
-		user-select: none;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .caption {
-		font-size: 0.85em;
-		padding: 8px 0 0 0;
-		color: var(--fgTransparentWeak);
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .input {
-		position: relative;
-
-		> textarea {
-			appearance: none;
-			-webkit-appearance: none;
-			display: block;
-			width: 100%;
-			min-width: 100%;
-			max-width: 100%;
-			min-height: 130px;
-			margin: 0;
-			padding: 12px;
-			font: inherit;
-			font-weight: normal;
-			font-size: 1em;
-			color: var(--fg);
-			background: var(--panel);
-			border: solid 1px var(--panel);
-			border-radius: 6px;
-			outline: none;
-			box-shadow: none;
-			box-sizing: border-box;
-			transition: border-color 0.1s ease-out;
-
-			&:hover {
-				border-color: var(--inputBorderHover) !important;
-			}
-		}
-
-		&.focused {
-			> textarea {
-				border-color: var(--accent) !important;
-			}
-		}
-
-		&.disabled {
-			opacity: 0.7;
-
-			&, * {
-				cursor: not-allowed !important;
-			}
-		}
-
-		&.tall {
-			> textarea {
-				min-height: 200px;
-			}
-		}
-
-		&.pre {
-			> textarea {
-				white-space: pre;
-			}
-		}
-	}
-
-	> .save {
-		margin: 8px 0 0 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkA.vue b/packages/client/src/components/global/MkA.vue
deleted file mode 100644
index 5a0ba0d8d3..0000000000
--- a/packages/client/src/components/global/MkA.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<template>
-<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
-	<slot></slot>
-</a>
-</template>
-
-<script lang="ts" setup>
-import { inject } from 'vue';
-import * as os from '@/os';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { url } from '@/config';
-import { popout as popout_ } from '@/scripts/popout';
-import { i18n } from '@/i18n';
-import { useRouter } from '@/router';
-
-const props = withDefaults(defineProps<{
-	to: string;
-	activeClass?: null | string;
-	behavior?: null | 'window' | 'browser' | 'modalWindow';
-}>(), {
-	activeClass: null,
-	behavior: null,
-});
-
-const router = useRouter();
-
-const active = $computed(() => {
-	if (props.activeClass == null) return false;
-	const resolved = router.resolve(props.to);
-	if (resolved == null) return false;
-	if (resolved.route.path === router.currentRoute.value.path) return true;
-	if (resolved.route.name == null) return false;
-	if (router.currentRoute.value.name == null) return false;
-	return resolved.route.name === router.currentRoute.value.name;
-});
-
-function onContextmenu(ev) {
-	const selection = window.getSelection();
-	if (selection && selection.toString() !== '') return;
-	os.contextMenu([{
-		type: 'label',
-		text: props.to,
-	}, {
-		icon: 'ti ti-app-window',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			os.pageWindow(props.to);
-		},
-	}, {
-		icon: 'ti ti-player-eject',
-		text: i18n.ts.showInPage,
-		action: () => {
-			router.push(props.to, 'forcePage');
-		},
-	}, null, {
-		icon: 'ti ti-external-link',
-		text: i18n.ts.openInNewTab,
-		action: () => {
-			window.open(props.to, '_blank');
-		},
-	}, {
-		icon: 'ti ti-link',
-		text: i18n.ts.copyLink,
-		action: () => {
-			copyToClipboard(`${url}${props.to}`);
-		},
-	}], ev);
-}
-
-function openWindow() {
-	os.pageWindow(props.to);
-}
-
-function modalWindow() {
-	os.modalPageWindow(props.to);
-}
-
-function popout() {
-	popout_(props.to);
-}
-
-function nav(ev: MouseEvent) {
-	if (props.behavior === 'browser') {
-		location.href = props.to;
-		return;
-	}
-
-	if (props.behavior) {
-		if (props.behavior === 'window') {
-			return openWindow();
-		} else if (props.behavior === 'modalWindow') {
-			return modalWindow();
-		}
-	}
-
-	if (ev.shiftKey) {
-		return openWindow();
-	}
-
-	router.push(props.to, ev.ctrlKey ? 'forcePage' : null);
-}
-</script>
diff --git a/packages/client/src/components/global/MkAcct.vue b/packages/client/src/components/global/MkAcct.vue
deleted file mode 100644
index c3e806b5fb..0000000000
--- a/packages/client/src/components/global/MkAcct.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-<span class="mk-acct">
-	<span class="name">@{{ user.username }}</span>
-	<span v-if="user.host || detail || $store.state.showFullAcct" class="host">@{{ user.host || host }}</span>
-</span>
-</template>
-
-<script lang="ts" setup>
-import * as misskey from 'misskey-js';
-import { toUnicode } from 'punycode/';
-import { host as hostRaw } from '@/config';
-
-defineProps<{
-	user: misskey.entities.UserDetailed;
-	detail?: boolean;
-}>();
-
-const host = toUnicode(hostRaw);
-</script>
-
-<style lang="scss" scoped>
-.mk-acct {
-	> .host {
-		opacity: 0.5;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkAd.vue b/packages/client/src/components/global/MkAd.vue
deleted file mode 100644
index a80efb142c..0000000000
--- a/packages/client/src/components/global/MkAd.vue
+++ /dev/null
@@ -1,186 +0,0 @@
-<template>
-<div v-if="chosen" class="qiivuoyo">
-	<div v-if="!showMenu" class="main" :class="chosen.place">
-		<a :href="chosen.url" target="_blank">
-			<img :src="chosen.imageUrl">
-			<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="ti ti-info-circle info-circle"></span></button>
-		</a>
-	</div>
-	<div v-else class="menu">
-		<div class="body">
-			<div>Ads by {{ host }}</div>
-			<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
-			<MkButton v-if="chosen.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
-			<button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
-		</div>
-	</div>
-</div>
-<div v-else></div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import { instance } from '@/instance';
-import { host } from '@/config';
-import MkButton from '@/components/MkButton.vue';
-import { defaultStore } from '@/store';
-import * as os from '@/os';
-
-type Ad = (typeof instance)['ads'][number];
-
-const props = defineProps<{
-	prefer: string[];
-	specify?: Ad;
-}>();
-
-const showMenu = ref(false);
-const toggleMenu = (): void => {
-	showMenu.value = !showMenu.value;
-};
-
-const choseAd = (): Ad | null => {
-	if (props.specify) {
-		return props.specify;
-	}
-
-	const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
-		...ad,
-		ratio: 0,
-	} : ad);
-
-	let ads = allAds.filter(ad => props.prefer.includes(ad.place));
-
-	if (ads.length === 0) {
-		ads = allAds.filter(ad => ad.place === 'square');
-	}
-
-	const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
-	ads = ads.filter(ad => ad.ratio !== 0);
-
-	if (ads.length === 0) {
-		if (lowPriorityAds.length !== 0) {
-			return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
-		} else {
-			return null;
-		}
-	}
-
-	const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
-	const r = Math.random() * totalFactor;
-
-	let stackedFactor = 0;
-	for (const ad of ads) {
-		if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
-			return ad;
-		} else {
-			stackedFactor += ad.ratio;
-		}
-	}
-
-	return null;
-};
-
-const chosen = ref(choseAd());
-
-function reduceFrequency(): void {
-	if (chosen.value == null) return;
-	if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
-	defaultStore.push('mutedAds', chosen.value.id);
-	os.success();
-	chosen.value = choseAd();
-	showMenu.value = false;
-}
-</script>
-
-<style lang="scss" scoped>
-.qiivuoyo {
-	background-size: auto auto;
-	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
-
-	> .main {
-		text-align: center;
-
-		> a {
-			display: inline-block;
-			position: relative;
-			vertical-align: bottom;
-
-			&:hover {
-				> img {
-					filter: contrast(120%);
-				}
-			}
-
-			> img {
-				display: block;
-				object-fit: contain;
-				margin: auto;
-				border-radius: 5px;
-			}
-
-			> .menu {
-				position: absolute;
-				top: 1px;
-				right: 1px;
-
-				> .info-circle {
-					border: 3px solid var(--panel);
-					border-radius: 50%;
-					background: var(--panel);
-				}
-			}
-		}
-
-		&.square {
-			> a ,
-			> a > img {
-				max-width: min(300px, 100%);
-				max-height: 300px;
-			}
-		}
-
-		&.horizontal {
-			padding: 8px;
-
-			> a ,
-			> a > img {
-				max-width: min(600px, 100%);
-				max-height: 80px;
-			}
-		}
-
-		&.horizontal-big {
-			padding: 8px;
-
-			> a ,
-			> a > img {
-				max-width: min(600px, 100%);
-				max-height: 250px;
-			}
-		}
-
-		&.vertical {
-			> a ,
-			> a > img {
-				max-width: min(100px, 100%);
-			}
-		}
-	}
-
-	> .menu {
-		padding: 8px;
-		text-align: center;
-
-		> .body {
-			padding: 8px;
-			margin: 0 auto;
-			max-width: 400px;
-			border: solid 1px var(--divider);
-
-			> .button {
-				margin: 8px auto;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkAvatar.vue b/packages/client/src/components/global/MkAvatar.vue
deleted file mode 100644
index 5f3e3c176d..0000000000
--- a/packages/client/src/components/global/MkAvatar.vue
+++ /dev/null
@@ -1,143 +0,0 @@
-<template>
-<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
-	<img class="inner" :src="url" decoding="async"/>
-	<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
-</span>
-<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
-	<img class="inner" :src="url" decoding="async"/>
-	<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
-</MkA>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
-import { acct, userPage } from '@/filters/user';
-import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
-import { defaultStore } from '@/store';
-
-const props = withDefaults(defineProps<{
-	user: misskey.entities.User;
-	target?: string | null;
-	disableLink?: boolean;
-	disablePreview?: boolean;
-	showIndicator?: boolean;
-}>(), {
-	target: null,
-	disableLink: false,
-	disablePreview: false,
-	showIndicator: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'click', v: MouseEvent): void;
-}>();
-
-const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
-	? getStaticImageUrl(props.user.avatarUrl)
-	: props.user.avatarUrl);
-
-function onClick(ev: MouseEvent) {
-	emit('click', ev);
-}
-
-let color = $ref();
-
-watch(() => props.user.avatarBlurhash, () => {
-	color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
-}, {
-	immediate: true,
-});
-</script>
-
-<style lang="scss" scoped>
-@keyframes earwiggleleft {
-	from { transform: rotate(37.6deg) skew(30deg); }
-	25% { transform: rotate(10deg) skew(30deg); }
-	50% { transform: rotate(20deg) skew(30deg); }
-	75% { transform: rotate(0deg) skew(30deg); }
-	to { transform: rotate(37.6deg) skew(30deg); }
-}
-
-@keyframes earwiggleright {
-	from { transform: rotate(-37.6deg) skew(-30deg); }
-	30% { transform: rotate(-10deg) skew(-30deg); }
-	55% { transform: rotate(-20deg) skew(-30deg); }
-	75% { transform: rotate(0deg) skew(-30deg); }
-	to { transform: rotate(-37.6deg) skew(-30deg); }
-}
-
-.eiwwqkts {
-	position: relative;
-	display: inline-block;
-	vertical-align: bottom;
-	flex-shrink: 0;
-	border-radius: 100%;
-	line-height: 16px;
-
-	> .inner {
-		position: absolute;
-		bottom: 0;
-		left: 0;
-		right: 0;
-		top: 0;
-		border-radius: 100%;
-		z-index: 1;
-		overflow: hidden;
-		object-fit: cover;
-		width: 100%;
-		height: 100%;
-	}
-
-	> .indicator {
-		position: absolute;
-		z-index: 1;
-		bottom: 0;
-		left: 0;
-		width: 20%;
-		height: 20%;
-	}
-
-	&.square {
-		border-radius: 20%;
-
-		> .inner {
-			border-radius: 20%;
-		}
-	}
-
-	&.cat {
-		&:before, &:after {
-			background: #df548f;
-			border: solid 4px currentColor;
-			box-sizing: border-box;
-			content: '';
-			display: inline-block;
-			height: 50%;
-			width: 50%;
-		}
-
-		&:before {
-			border-radius: 0 75% 75%;
-			transform: rotate(37.5deg) skew(30deg);
-		}
-
-		&:after {
-			border-radius: 75% 0 75% 75%;
-			transform: rotate(-37.5deg) skew(-30deg);
-		}
-
-		&:hover {
-			&:before {
-				animation: earwiggleleft 1s infinite;
-			}
-
-			&:after {
-				animation: earwiggleright 1s infinite;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkEllipsis.vue b/packages/client/src/components/global/MkEllipsis.vue
deleted file mode 100644
index 0a46f486d6..0000000000
--- a/packages/client/src/components/global/MkEllipsis.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<template>
-	<span class="mk-ellipsis">
-		<span>.</span><span>.</span><span>.</span>
-	</span>
-</template>
-
-<style lang="scss" scoped>
-.mk-ellipsis {
-	> span {
-		animation: ellipsis 1.4s infinite ease-in-out both;
-
-		&:nth-child(1) {
-			animation-delay: 0s;
-		}
-
-		&:nth-child(2) {
-			animation-delay: 0.16s;
-		}
-
-		&:nth-child(3) {
-			animation-delay: 0.32s;
-		}
-	}
-}
-
-@keyframes ellipsis {
-	0%, 80%, 100% {
-		opacity: 1;
-	}
-	40% {
-		opacity: 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkEmoji.vue b/packages/client/src/components/global/MkEmoji.vue
deleted file mode 100644
index ce1299a39f..0000000000
--- a/packages/client/src/components/global/MkEmoji.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<template>
-<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
-<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/>
-<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span>
-<span v-else>{{ emoji }}</span>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import { CustomEmoji } from 'misskey-js/built/entities';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
-import { defaultStore } from '@/store';
-import { instance } from '@/instance';
-import { getEmojiName } from '@/scripts/emojilist';
-
-const props = defineProps<{
-	emoji: string;
-	normal?: boolean;
-	noStyle?: boolean;
-	customEmojis?: CustomEmoji[];
-	isReaction?: boolean;
-}>();
-
-const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
-
-const isCustom = computed(() => props.emoji.startsWith(':'));
-const char = computed(() => isCustom.value ? undefined : props.emoji);
-const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction);
-const ce = computed(() => props.customEmojis ?? instance.emojis ?? []);
-const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : undefined);
-const url = computed(() => {
-	if (char.value) {
-		return char2path(char.value);
-	} else {
-		const rawUrl = (customEmoji.value as CustomEmoji).url;
-		return defaultStore.state.disableShowingAnimatedImages
-			? getStaticImageUrl(rawUrl)
-			: rawUrl;
-	}
-});
-const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value);
-
-// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
-function computeTitle(event: PointerEvent): void {
-	const title = customEmoji.value
-		? `:${customEmoji.value.name}:`
-		: (getEmojiName(char.value as string) ?? char.value as string);
-	(event.target as HTMLElement).title = title;
-}
-</script>
-
-<style lang="scss" scoped>
-.mk-emoji {
-	height: 1.25em;
-	vertical-align: -0.25em;
-
-	&.custom {
-		height: 2.5em;
-		vertical-align: middle;
-		transition: transform 0.2s ease;
-
-		&:hover {
-			transform: scale(1.2);
-		}
-
-		&.normal {
-			height: 1.25em;
-			vertical-align: -0.25em;
-
-			&:hover {
-				transform: none;
-			}
-		}
-	}
-
-	&.noStyle {
-		height: auto !important;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkError.vue b/packages/client/src/components/global/MkError.vue
deleted file mode 100644
index e135d4184b..0000000000
--- a/packages/client/src/components/global/MkError.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<template>
-<transition :name="$store.state.animation ? 'zoom' : ''" appear>
-	<div class="mjndxjcg">
-		<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
-		<p><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
-		<MkButton class="button" @click="() => $emit('retry')">{{ i18n.ts.retry }}</MkButton>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import MkButton from '@/components/MkButton.vue';
-import { i18n } from '@/i18n';
-</script>
-
-<style lang="scss" scoped>
-.mjndxjcg {
-	padding: 32px;
-	text-align: center;
-
-	> p {
-		margin: 0 0 8px 0;
-	}
-
-	> .button {
-		margin: 0 auto;
-	}
-
-	> img {
-		vertical-align: bottom;
-		height: 128px;
-		margin-bottom: 16px;
-		border-radius: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkLoading.vue b/packages/client/src/components/global/MkLoading.vue
deleted file mode 100644
index 64e12e3b44..0000000000
--- a/packages/client/src/components/global/MkLoading.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<template>
-<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]">
-	<div :class="$style.container">
-		<svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
-			<g transform="matrix(1.125,0,0,1.125,12,12)">
-				<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
-			</g>
-		</svg>
-		<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
-			<g transform="matrix(1.125,0,0,1.125,12,12)">
-				<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
-			</g>
-		</svg>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = withDefaults(defineProps<{
-	inline?: boolean;
-	colored?: boolean;
-	mini?: boolean;
-	em?: boolean;
-}>(), {
-	inline: false,
-	colored: true,
-	mini: false,
-	em: false,
-});
-</script>
-
-<style lang="scss" module>
-@keyframes spinner {
-	0% {
-		transform: rotate(0deg);
-	}
-	100% {
-		transform: rotate(360deg);
-	}
-}
-
-.root {
-	padding: 32px;
-	text-align: center;
-	cursor: wait;
-
-	--size: 38px;
-
-	&.colored {
-		color: var(--accent);
-	}
-
-	&.inline {
-		display: inline;
-		padding: 0;
-		--size: 32px;
-	}
-
-	&.mini {
-		padding: 16px;
-		--size: 32px;
-	}
-
-	&.em {
-		display: inline-block;
-		vertical-align: middle;
-		padding: 0;
-		--size: 1em;
-	}
-}
-
-.container {
-	position: relative;
-	width: var(--size);
-	height: var(--size);
-	margin: 0 auto;
-}
-
-.spinner {
-	position: absolute;
-	top: 0;
-	left: 0;
-	width: var(--size);
-	height: var(--size);
-	fill-rule: evenodd;
-	clip-rule: evenodd;
-	stroke-linecap: round;
-	stroke-linejoin: round;
-	stroke-miterlimit: 1.5;
-}
-
-.bg {
-	opacity: 0.275;
-}
-
-.fg {
-	animation: spinner 0.5s linear infinite;
-}
-</style>
diff --git a/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue
deleted file mode 100644
index 70d0108e9f..0000000000
--- a/packages/client/src/components/global/MkMisskeyFlavoredMarkdown.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<template>
-<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MfmCore from '@/components/mfm';
-
-const props = withDefaults(defineProps<{
-	text: string;
-	plain?: boolean;
-	nowrap?: boolean;
-	author?: any;
-	customEmojis?: any;
-	isNote?: boolean;
-}>(), {
-	plain: false,
-	nowrap: false,
-	author: null,
-	isNote: true,
-});
-</script>
-
-<style lang="scss">
-._mfm_blur_ {
-	filter: blur(6px);
-	transition: filter 0.3s;
-
-	&:hover {
-		filter: blur(0px);
-	}
-}
-
-.mfm-x2 {
-	--mfm-zoom-size: 200%;
-}
-
-.mfm-x3 {
-	--mfm-zoom-size: 400%;
-}
-
-.mfm-x4 {
-	--mfm-zoom-size: 600%;
-}
-
-.mfm-x2, .mfm-x3, .mfm-x4 {
-	font-size: var(--mfm-zoom-size);
-
-	.mfm-x2, .mfm-x3, .mfm-x4 {
-		/* only half effective */
-		font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
-
-		.mfm-x2, .mfm-x3, .mfm-x4 {
-			/* disabled */
-			font-size: 100%;
-		}
-	}
-}
-
-@keyframes mfm-spin {
-	0% { transform: rotate(0deg); }
-	100% { transform: rotate(360deg); }
-}
-
-@keyframes mfm-spinX {
-	0% { transform: perspective(128px) rotateX(0deg); }
-	100% { transform: perspective(128px) rotateX(360deg); }
-}
-
-@keyframes mfm-spinY {
-	0% { transform: perspective(128px) rotateY(0deg); }
-	100% { transform: perspective(128px) rotateY(360deg); }
-}
-
-@keyframes mfm-jump {
-	0% { transform: translateY(0); }
-	25% { transform: translateY(-16px); }
-	50% { transform: translateY(0); }
-	75% { transform: translateY(-8px); }
-	100% { transform: translateY(0); }
-}
-
-@keyframes mfm-bounce {
-	0% { transform: translateY(0) scale(1, 1); }
-	25% { transform: translateY(-16px) scale(1, 1); }
-	50% { transform: translateY(0) scale(1, 1); }
-	75% { transform: translateY(0) scale(1.5, 0.75); }
-	100% { transform: translateY(0) scale(1, 1); }
-}
-
-// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
-// let css = '';
-// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
-@keyframes mfm-twitch {
-	0% { transform: translate(7px, -2px) }
-	5% { transform: translate(-3px, 1px) }
-	10% { transform: translate(-7px, -1px) }
-	15% { transform: translate(0px, -1px) }
-	20% { transform: translate(-8px, 6px) }
-	25% { transform: translate(-4px, -3px) }
-	30% { transform: translate(-4px, -6px) }
-	35% { transform: translate(-8px, -8px) }
-	40% { transform: translate(4px, 6px) }
-	45% { transform: translate(-3px, 1px) }
-	50% { transform: translate(2px, -10px) }
-	55% { transform: translate(-7px, 0px) }
-	60% { transform: translate(-2px, 4px) }
-	65% { transform: translate(3px, -8px) }
-	70% { transform: translate(6px, 7px) }
-	75% { transform: translate(-7px, -2px) }
-	80% { transform: translate(-7px, -8px) }
-	85% { transform: translate(9px, 3px) }
-	90% { transform: translate(-3px, -2px) }
-	95% { transform: translate(-10px, 2px) }
-	100% { transform: translate(-2px, -6px) }
-}
-
-// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
-// let css = '';
-// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
-@keyframes mfm-shake {
-	0% { transform: translate(-3px, -1px) rotate(-8deg) }
-	5% { transform: translate(0px, -1px) rotate(-10deg) }
-	10% { transform: translate(1px, -3px) rotate(0deg) }
-	15% { transform: translate(1px, 1px) rotate(11deg) }
-	20% { transform: translate(-2px, 1px) rotate(1deg) }
-	25% { transform: translate(-1px, -2px) rotate(-2deg) }
-	30% { transform: translate(-1px, 2px) rotate(-3deg) }
-	35% { transform: translate(2px, 1px) rotate(6deg) }
-	40% { transform: translate(-2px, -3px) rotate(-9deg) }
-	45% { transform: translate(0px, -1px) rotate(-12deg) }
-	50% { transform: translate(1px, 2px) rotate(10deg) }
-	55% { transform: translate(0px, -3px) rotate(8deg) }
-	60% { transform: translate(1px, -1px) rotate(8deg) }
-	65% { transform: translate(0px, -1px) rotate(-7deg) }
-	70% { transform: translate(-1px, -3px) rotate(6deg) }
-	75% { transform: translate(0px, -2px) rotate(4deg) }
-	80% { transform: translate(-2px, -1px) rotate(3deg) }
-	85% { transform: translate(1px, -3px) rotate(-10deg) }
-	90% { transform: translate(1px, 0px) rotate(3deg) }
-	95% { transform: translate(-2px, 0px) rotate(-3deg) }
-	100% { transform: translate(2px, 1px) rotate(2deg) }
-}
-
-@keyframes mfm-rubberBand {
-	from { transform: scale3d(1, 1, 1); }
-	30% { transform: scale3d(1.25, 0.75, 1); }
-	40% { transform: scale3d(0.75, 1.25, 1); }
-	50% { transform: scale3d(1.15, 0.85, 1); }
-	65% { transform: scale3d(0.95, 1.05, 1); }
-	75% { transform: scale3d(1.05, 0.95, 1); }
-	to { transform: scale3d(1, 1, 1); }
-}
-
-@keyframes mfm-rainbow {
-	0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
-	100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
-}
-</style>
-
-<style lang="scss" scoped>
-.havbbuyv {
-	white-space: pre-wrap;
-
-	&.nowrap {
-		white-space: pre;
-		word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
-		overflow: hidden;
-		text-overflow: ellipsis;
-	}
-
-	::v-deep(.quote) {
-		display: block;
-		margin: 8px;
-		padding: 6px 0 6px 12px;
-		color: var(--fg);
-		border-left: solid 3px var(--fg);
-		opacity: 0.7;
-	}
-
-	::v-deep(pre) {
-		font-size: 0.8em;
-	}
-
-	> ::v-deep(code) {
-		font-size: 0.8em;
-		word-break: break-all;
-		padding: 4px 6px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkPageHeader.vue b/packages/client/src/components/global/MkPageHeader.vue
deleted file mode 100644
index a228dfe883..0000000000
--- a/packages/client/src/components/global/MkPageHeader.vue
+++ /dev/null
@@ -1,368 +0,0 @@
-<template>
-<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
-	<div v-if="narrow" class="buttons left">
-		<MkAvatar v-if="props.displayMyAvatar && $i" class="avatar" :user="$i" :disable-preview="true"/>
-	</div>
-	<template v-if="metadata">
-		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
-			<MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
-			<i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i>
-
-			<div class="title">
-				<MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/>
-				<div v-else-if="metadata.title" class="title">{{ metadata.title }}</div>
-				<div v-if="!narrow && metadata.subtitle" class="subtitle">
-					{{ metadata.subtitle }}
-				</div>
-				<div v-if="narrow && hasTabs" class="subtitle activeTab">
-					{{ tabs.find(tab => tab.key === props.tab)?.title }}
-					<i class="chevron ti ti-chevron-down"></i>
-				</div>
-			</div>
-		</div>
-		<div v-if="!narrow || hideTitle" class="tabs">
-			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
-				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
-				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
-			</button>
-			<div ref="tabHighlightEl" class="highlight"></div>
-		</div>
-	</template>
-	<div class="buttons right">
-		<template v-for="action in actions">
-			<button v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
-		</template>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
-import tinycolor from 'tinycolor2';
-import { popupMenu } from '@/os';
-import { scrollToTop } from '@/scripts/scroll';
-import { globalEvents } from '@/events';
-import { injectPageMetadata } from '@/scripts/page-metadata';
-import { $i } from '@/account';
-
-type Tab = {
-	key: string;
-	title: string;
-	icon?: string;
-	iconOnly?: boolean;
-	onClick?: (ev: MouseEvent) => void;
-};
-
-const props = withDefaults(defineProps<{
-	tabs?: Tab[];
-	tab?: string;
-	actions?: {
-		text: string;
-		icon: string;
-		highlighted?: boolean;
-		handler: (ev: MouseEvent) => void;
-	}[];
-	thin?: boolean;
-	displayMyAvatar?: boolean;
-}>(), {
-	tabs: () => ([] as Tab[])
-});
-
-const emit = defineEmits<{
-	(ev: 'update:tab', key: string);
-}>();
-
-const metadata = injectPageMetadata();
-
-const hideTitle = inject('shouldOmitHeaderTitle', false);
-const thin_ = props.thin || inject('shouldHeaderThin', false);
-
-const el = $ref<HTMLElement | undefined>(undefined);
-const tabRefs: Record<string, HTMLElement | null> = {};
-const tabHighlightEl = $ref<HTMLElement | null>(null);
-const bg = ref<string | undefined>(undefined);
-let 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 || hasActions;
-});
-
-const showTabsPopup = (ev: MouseEvent) => {
-	if (!hasTabs) return;
-	if (!narrow) return;
-	ev.preventDefault();
-	ev.stopPropagation();
-	const menu = props.tabs.map(tab => ({
-		text: tab.title,
-		icon: tab.icon,
-		active: tab.key != null && tab.key === props.tab,
-		action: (ev) => {
-			onTabClick(tab, ev);
-		},
-	}));
-	popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement);
-};
-
-const preventDrag = (ev: TouchEvent) => {
-	ev.stopPropagation();
-};
-
-const onClick = () => {
-	if (el) {
-		scrollToTop(el as HTMLElement, { behavior: 'smooth' });
-	}
-};
-
-function onTabMousedown(tab: Tab, ev: MouseEvent): void {
-	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
-	if (tab.key) {
-		emit('update:tab', tab.key);
-	}
-}
-
-function onTabClick(tab: Tab, ev: MouseEvent): void {
-	if (tab.onClick) {
-		ev.preventDefault();
-		ev.stopPropagation();
-		tab.onClick(ev);
-	}
-	if (tab.key) {
-		emit('update:tab', tab.key);
-	}
-}
-
-const calcBg = () => {
-	const rawBg = metadata?.bg || 'var(--bg)';
-	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
-	tinyBg.setAlpha(0.85);
-	bg.value = tinyBg.toRgbString();
-};
-
-let ro: ResizeObserver | null;
-
-onMounted(() => {
-	calcBg();
-	globalEvents.on('themeChanged', calcBg);
-
-	watch(() => [props.tab, props.tabs], () => {
-		nextTick(() => {
-			const tabEl = props.tab ? tabRefs[props.tab] : undefined;
-			if (tabEl && tabHighlightEl && tabEl.parentElement) {
-				// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
-				// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
-				const parentRect = tabEl.parentElement.getBoundingClientRect();
-				const rect = tabEl.getBoundingClientRect();
-				tabHighlightEl.style.width = rect.width + 'px';
-				tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
-			}
-		});
-	}, {
-		immediate: true,
-	});
-
-	if (el && el.parentElement) {
-		narrow = el.parentElement.offsetWidth < 500;
-		ro = new ResizeObserver((entries, observer) => {
-			if (el.parentElement && document.body.contains(el as HTMLElement)) {
-				narrow = el.parentElement.offsetWidth < 500;
-			}
-		});
-		ro.observe(el.parentElement as HTMLElement);
-	}
-});
-
-onUnmounted(() => {
-	globalEvents.off('themeChanged', calcBg);
-	if (ro) ro.disconnect();
-});
-</script>
-
-<style lang="scss" scoped>
-.fdidabkb {
-	--height: 52px;
-	display: flex;
-	width: 100%;
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-	border-bottom: solid 0.5px var(--divider);
-	contain: strict;
-	height: var(--height);
-
-	&.thin {
-		--height: 42px;
-
-		> .buttons {
-			> .button {
-				font-size: 0.9em;
-			}
-		}
-	}
-
-	&.slim {
-		text-align: center;
-
-		> .titleContainer {
-			flex: 1;
-			margin: 0 auto;
-
-			> *:first-child {
-				margin-left: auto;
-			}
-
-			> *:last-child {
-				margin-right: auto;
-			}
-		}
-	}
-
-	> .buttons {
-		--margin: 8px;
-		display: flex;
-    align-items: center;
-		min-width: var(--height);
-		height: var(--height);
-		margin: 0 var(--margin);
-
-		&.left {
-			margin-right: auto;
-
-			> .avatar {
-				$size: 32px;
-				display: inline-block;
-				width: $size;
-				height: $size;
-				vertical-align: bottom;
-				margin: 0 8px;
-				pointer-events: none;
-			}
-		}
-
-		&.right {
-			margin-left: auto;
-		}
-
-		&:empty {
-			width: var(--height);
-		}
-
-		> .button {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			height: calc(var(--height) - (var(--margin) * 2));
-			width: calc(var(--height) - (var(--margin) * 2));
-			box-sizing: border-box;
-			position: relative;
-			border-radius: 5px;
-
-			&:hover {
-				background: rgba(0, 0, 0, 0.05);
-			}
-
-			&.highlighted {
-				color: var(--accent);
-			}
-		}
-
-		> .fullButton {
-			& + .fullButton {
-				margin-left: 12px;
-			}
-		}
-	}
-
-	> .titleContainer {
-		display: flex;
-		align-items: center;
-		max-width: 400px;
-		overflow: auto;
-		white-space: nowrap;
-		text-align: left;
-		font-weight: bold;
-		flex-shrink: 0;
-		margin-left: 24px;
-
-		> .avatar {
-			$size: 32px;
-			display: inline-block;
-			width: $size;
-			height: $size;
-			vertical-align: bottom;
-			margin: 0 8px;
-			pointer-events: none;
-		}
-
-		> .icon {
-			margin-right: 8px;
-			width: 16px;
-			text-align: center;
-		}
-
-		> .title {
-			min-width: 0;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-			line-height: 1.1;
-
-			> .subtitle {
-				opacity: 0.6;
-				font-size: 0.8em;
-				font-weight: normal;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-
-				&.activeTab {
-					text-align: center;
-
-					> .chevron {
-						display: inline-block;
-						margin-left: 6px;
-					}
-				}
-			}
-		}
-	}
-
-	> .tabs {
-		position: relative;
-		margin-left: 16px;
-		font-size: 0.8em;
-		overflow: auto;
-		white-space: nowrap;
-
-		> .tab {
-			display: inline-block;
-			position: relative;
-			padding: 0 10px;
-			height: 100%;
-			font-weight: normal;
-			opacity: 0.7;
-
-			&:hover {
-				opacity: 1;
-			}
-
-			&.active {
-				opacity: 1;
-			}
-
-			> .icon + .title {
-				margin-left: 8px;
-			}
-		}
-
-		> .highlight {
-			position: absolute;
-			bottom: 0;
-			height: 3px;
-			background: var(--accent);
-			border-radius: 999px;
-			transition: all 0.2s ease;
-			pointer-events: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkSpacer.vue b/packages/client/src/components/global/MkSpacer.vue
deleted file mode 100644
index b3a42d77e7..0000000000
--- a/packages/client/src/components/global/MkSpacer.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }">
-	<div ref="content" :class="$style.content">
-		<slot></slot>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { inject, onMounted, onUnmounted, ref } from 'vue';
-import { deviceKind } from '@/scripts/device-kind';
-
-const props = withDefaults(defineProps<{
-	contentMax?: number | null;
-	marginMin?: number;
-	marginMax?: number;
-}>(), {
-	contentMax: null,
-	marginMin: 12,
-	marginMax: 24,
-});
-
-let ro: ResizeObserver;
-let root = $ref<HTMLElement>();
-let content = $ref<HTMLElement>();
-let margin = $ref(0);
-const widthHistory = [null, null] as [number | null, number | null];
-const heightHistory = [null, null] as [number | null, number | null];
-const shouldSpacerMin = inject('shouldSpacerMin', false);
-
-const adjust = (rect: { width: number; height: number; }) => {
-	if (shouldSpacerMin || deviceKind === 'smartphone') {
-		margin = props.marginMin;
-		return;
-	}
-
-	if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) {
-		margin = props.marginMax;
-	} else {
-		margin = props.marginMin;
-	}
-};
-
-onMounted(() => {
-	ro = new ResizeObserver((entries) => {
-		/* iOSが対応していない
-		adjust({
-			width: entries[0].borderBoxSize[0].inlineSize,
-			height: entries[0].borderBoxSize[0].blockSize,
-		});
-		*/
-
-		const width = root!.offsetWidth;
-		const height = root!.offsetHeight;
-
-		//#region Prevent infinite resizing
-		// https://github.com/misskey-dev/misskey/issues/9076
-		const pastWidth = widthHistory.pop();
-		widthHistory.unshift(width);
-		const pastHeight = heightHistory.pop();
-		heightHistory.unshift(height);
-
-
-		if (pastWidth === width && pastHeight === height) {
-			return;
-		}
-		//#endregion
-
-		adjust({
-			width,
-			height,
-		});
-	});
-	ro.observe(root!);
-
-	if (props.contentMax) {
-		content!.style.maxWidth = `${props.contentMax}px`;
-	}
-});
-
-onUnmounted(() => {
-	ro.disconnect();
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	box-sizing: border-box;
-	width: 100%;
-}
-
-.content {
-	margin: 0 auto;
-	container-type: inline-size;
-}
-</style>
diff --git a/packages/client/src/components/global/MkStickyContainer.vue b/packages/client/src/components/global/MkStickyContainer.vue
deleted file mode 100644
index 44f4f065a6..0000000000
--- a/packages/client/src/components/global/MkStickyContainer.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<div ref="rootEl">
-	<div ref="headerEl">
-		<slot name="header"></slot>
-	</div>
-	<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
-		<slot></slot>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-// なんか動かない
-//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
-const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
-</script>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
-
-const rootEl = $ref<HTMLElement>();
-const headerEl = $ref<HTMLElement>();
-const bodyEl = $ref<HTMLElement>();
-
-let headerHeight = $ref<string | undefined>();
-let childStickyTop = $ref(0);
-const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0));
-provide(CURRENT_STICKY_TOP, $$(childStickyTop));
-
-const calc = () => {
-	childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
-	headerHeight = headerEl.offsetHeight.toString();
-};
-
-const observer = new ResizeObserver(() => {
-	window.setTimeout(() => {
-		calc();
-	}, 100);
-});
-
-onMounted(() => {
-	calc();
-
-	watch(parentStickyTop, calc);
-
-	watch($$(childStickyTop), () => {
-		bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`);
-	}, {
-		immediate: true,
-	});
-
-	headerEl.style.position = 'sticky';
-	headerEl.style.top = 'var(--stickyTop, 0)';
-	headerEl.style.zIndex = '1000';
-
-	observer.observe(headerEl);
-});
-
-onUnmounted(() => {
-	observer.disconnect();
-});
-</script>
-
-<style lang="scss" module>
-
-</style>
diff --git a/packages/client/src/components/global/MkTime.vue b/packages/client/src/components/global/MkTime.vue
deleted file mode 100644
index f72b153f56..0000000000
--- a/packages/client/src/components/global/MkTime.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<template>
-<time :title="absolute">
-	<template v-if="mode === 'relative'">{{ relative }}</template>
-	<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
-	<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
-</time>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted } from 'vue';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	time: Date | string;
-	mode?: 'relative' | 'absolute' | 'detail';
-}>(), {
-	mode: 'relative',
-});
-
-const _time = typeof props.time === 'string' ? new Date(props.time) : props.time;
-const absolute = _time.toLocaleString();
-
-let now = $shallowRef(new Date());
-const relative = $computed(() => {
-	const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
-	return (
-		ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
-		ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
-		ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) :
-		ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
-		ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
-		ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
-		ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
-		ago >= -1 ? i18n.ts._ago.justNow :
-		i18n.ts._ago.future);
-});
-
-function tick() {
-	// TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
-	now = new Date();
-
-	tickId = window.setTimeout(() => {
-		window.requestAnimationFrame(tick);
-	}, 10000);
-}
-
-let tickId: number;
-
-if (props.mode === 'relative' || props.mode === 'detail') {
-	tickId = window.requestAnimationFrame(tick);
-
-	onUnmounted(() => {
-		window.cancelAnimationFrame(tickId);
-	});
-}
-</script>
diff --git a/packages/client/src/components/global/MkUrl.vue b/packages/client/src/components/global/MkUrl.vue
deleted file mode 100644
index 9f5be96224..0000000000
--- a/packages/client/src/components/global/MkUrl.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-<template>
-<component
-	:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel" :target="target"
-	@contextmenu.stop="() => {}"
->
-	<template v-if="!self">
-		<span class="schema">{{ schema }}//</span>
-		<span class="hostname">{{ hostname }}</span>
-		<span v-if="port != ''" class="port">:{{ port }}</span>
-	</template>
-	<template v-if="pathname === '/' && self">
-		<span class="self">{{ hostname }}</span>
-	</template>
-	<span v-if="pathname != ''" class="pathname">{{ self ? pathname.substring(1) : pathname }}</span>
-	<span class="query">{{ query }}</span>
-	<span class="hash">{{ hash }}</span>
-	<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
-</component>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, ref } from 'vue';
-import { toUnicode as decodePunycode } from 'punycode/';
-import { url as local } from '@/config';
-import * as os from '@/os';
-import { useTooltip } from '@/scripts/use-tooltip';
-import { safeURIDecode } from '@/scripts/safe-uri-decode';
-
-const props = defineProps<{
-	url: string;
-	rel?: string;
-}>();
-
-const self = props.url.startsWith(local);
-const url = new URL(props.url);
-const el = ref();
-
-useTooltip(el, (showing) => {
-	os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
-		showing,
-		url: props.url,
-		source: el.value,
-	}, {}, 'closed');
-});
-
-const schema = url.protocol;
-const hostname = decodePunycode(url.hostname);
-const port = url.port;
-const pathname = safeURIDecode(url.pathname);
-const query = safeURIDecode(url.search);
-const hash = safeURIDecode(url.hash);
-const attr = self ? 'to' : 'href';
-const target = self ? null : '_blank';
-</script>
-
-<style lang="scss" scoped>
-.ieqqeuvs {
-	word-break: break-all;
-
-	> .icon {
-		padding-left: 2px;
-		font-size: .9em;
-	}
-
-	> .self {
-		font-weight: bold;
-	}
-
-	> .schema {
-		opacity: 0.5;
-	}
-
-	> .hostname {
-		font-weight: bold;
-	}
-
-	> .pathname {
-		opacity: 0.8;
-	}
-
-	> .query {
-		opacity: 0.5;
-	}
-
-	> .hash {
-		font-style: italic;
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/MkUserName.vue b/packages/client/src/components/global/MkUserName.vue
deleted file mode 100644
index 090de3df30..0000000000
--- a/packages/client/src/components/global/MkUserName.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<template>
-<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-
-const props = withDefaults(defineProps<{
-	user: misskey.entities.User;
-	nowrap?: boolean;
-}>(), {
-	nowrap: true,
-});
-</script>
diff --git a/packages/client/src/components/global/RouterView.vue b/packages/client/src/components/global/RouterView.vue
deleted file mode 100644
index e21a57471c..0000000000
--- a/packages/client/src/components/global/RouterView.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<KeepAlive :max="defaultStore.state.numberOfPageCache">
-	<Suspense>
-		<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
-
-		<template #fallback>
-			<MkLoading/>
-		</template>
-	</Suspense>
-</KeepAlive>
-</template>
-
-<script lang="ts" setup>
-import { inject, nextTick, onBeforeUnmount, onMounted, onUnmounted, provide, watch } from 'vue';
-import { Resolved, Router } from '@/nirax';
-import { defaultStore } from '@/store';
-
-const props = defineProps<{
-	router?: Router;
-}>();
-
-const router = props.router ?? inject('router');
-
-if (router == null) {
-	throw new Error('no router provided');
-}
-
-const currentDepth = inject('routerCurrentDepth', 0);
-provide('routerCurrentDepth', currentDepth + 1);
-
-function resolveNested(current: Resolved, d = 0): Resolved | null {
-	if (d === currentDepth) {
-		return current;
-	} else {
-		if (current.child) {
-			return resolveNested(current.child, d + 1);
-		} else {
-			return null;
-		}
-	}
-}
-
-const current = resolveNested(router.current)!;
-let currentPageComponent = $shallowRef(current.route.component);
-let currentPageProps = $ref(current.props);
-let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
-
-function onChange({ resolved, key: newKey }) {
-	const current = resolveNested(resolved);
-	if (current == null) return;
-	currentPageComponent = current.route.component;
-	currentPageProps = current.props;
-	key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
-}
-
-router.addListener('change', onChange);
-
-onBeforeUnmount(() => {
-	router.removeListener('change', onChange);
-});
-</script>
diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts
deleted file mode 100644
index 1fd293ba10..0000000000
--- a/packages/client/src/components/global/i18n.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { h, defineComponent } from 'vue';
-
-export default defineComponent({
-	props: {
-		src: {
-			type: String,
-			required: true,
-		},
-		tag: {
-			type: String,
-			required: false,
-			default: 'span',
-		},
-		textTag: {
-			type: String,
-			required: false,
-			default: null,
-		},
-	},
-	render() {
-		let str = this.src;
-		const parsed = [] as (string | { arg: string; })[];
-		while (true) {
-			const nextBracketOpen = str.indexOf('{');
-			const nextBracketClose = str.indexOf('}');
-
-			if (nextBracketOpen === -1) {
-				parsed.push(str);
-				break;
-			} else {
-				if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
-				parsed.push({
-					arg: str.substring(nextBracketOpen + 1, nextBracketClose),
-				});
-			}
-
-			str = str.substr(nextBracketClose + 1);
-		}
-
-		return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]()));
-	},
-});
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
deleted file mode 100644
index 8639257003..0000000000
--- a/packages/client/src/components/index.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { App } from 'vue';
-
-import Mfm from './global/MkMisskeyFlavoredMarkdown.vue';
-import MkA from './global/MkA.vue';
-import MkAcct from './global/MkAcct.vue';
-import MkAvatar from './global/MkAvatar.vue';
-import MkEmoji from './global/MkEmoji.vue';
-import MkUserName from './global/MkUserName.vue';
-import MkEllipsis from './global/MkEllipsis.vue';
-import MkTime from './global/MkTime.vue';
-import MkUrl from './global/MkUrl.vue';
-import I18n from './global/i18n';
-import RouterView from './global/RouterView.vue';
-import MkLoading from './global/MkLoading.vue';
-import MkError from './global/MkError.vue';
-import MkAd from './global/MkAd.vue';
-import MkPageHeader from './global/MkPageHeader.vue';
-import MkSpacer from './global/MkSpacer.vue';
-import MkStickyContainer from './global/MkStickyContainer.vue';
-
-export default function(app: App) {
-	app.component('I18n', I18n);
-	app.component('RouterView', RouterView);
-	app.component('Mfm', Mfm);
-	app.component('MkA', MkA);
-	app.component('MkAcct', MkAcct);
-	app.component('MkAvatar', MkAvatar);
-	app.component('MkEmoji', MkEmoji);
-	app.component('MkUserName', MkUserName);
-	app.component('MkEllipsis', MkEllipsis);
-	app.component('MkTime', MkTime);
-	app.component('MkUrl', MkUrl);
-	app.component('MkLoading', MkLoading);
-	app.component('MkError', MkError);
-	app.component('MkAd', MkAd);
-	app.component('MkPageHeader', MkPageHeader);
-	app.component('MkSpacer', MkSpacer);
-	app.component('MkStickyContainer', MkStickyContainer);
-}
-
-declare module '@vue/runtime-core' {
-	export interface GlobalComponents {
-		I18n: typeof I18n;
-		RouterView: typeof RouterView;
-		Mfm: typeof Mfm;
-		MkA: typeof MkA;
-		MkAcct: typeof MkAcct;
-		MkAvatar: typeof MkAvatar;
-		MkEmoji: typeof MkEmoji;
-		MkUserName: typeof MkUserName;
-		MkEllipsis: typeof MkEllipsis;
-		MkTime: typeof MkTime;
-		MkUrl: typeof MkUrl;
-		MkLoading: typeof MkLoading;
-		MkError: typeof MkError;
-		MkAd: typeof MkAd;
-		MkPageHeader: typeof MkPageHeader;
-		MkSpacer: typeof MkSpacer;
-		MkStickyContainer: typeof MkStickyContainer;
-	}
-}
diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts
deleted file mode 100644
index 5b5b1caae3..0000000000
--- a/packages/client/src/components/mfm.ts
+++ /dev/null
@@ -1,331 +0,0 @@
-import { VNode, defineComponent, h } from 'vue';
-import * as mfm from 'mfm-js';
-import MkUrl from '@/components/global/MkUrl.vue';
-import MkLink from '@/components/MkLink.vue';
-import MkMention from '@/components/MkMention.vue';
-import MkEmoji from '@/components/global/MkEmoji.vue';
-import { concat } from '@/scripts/array';
-import MkFormula from '@/components/MkFormula.vue';
-import MkCode from '@/components/MkCode.vue';
-import MkGoogle from '@/components/MkGoogle.vue';
-import MkSparkle from '@/components/MkSparkle.vue';
-import MkA from '@/components/global/MkA.vue';
-import { host } from '@/config';
-import { MFM_TAGS } from '@/scripts/mfm-tags';
-
-export default defineComponent({
-	props: {
-		text: {
-			type: String,
-			required: true,
-		},
-		plain: {
-			type: Boolean,
-			default: false,
-		},
-		nowrap: {
-			type: Boolean,
-			default: false,
-		},
-		author: {
-			type: Object,
-			default: null,
-		},
-		i: {
-			type: Object,
-			default: null,
-		},
-		customEmojis: {
-			required: false,
-		},
-		isNote: {
-			type: Boolean,
-			default: true,
-		},
-	},
-
-	render() {
-		if (this.text == null || this.text === '') return;
-
-		const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS });
-
-		const validTime = (t: string | null | undefined) => {
-			if (t == null) return null;
-			return t.match(/^[0-9.]+s$/) ? t : null;
-		};
-
-		const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => {
-			switch (token.type) {
-				case 'text': {
-					const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
-
-					if (!this.plain) {
-						const res: (VNode | string)[] = [];
-						for (const t of text.split('\n')) {
-							res.push(h('br'));
-							res.push(t);
-						}
-						res.shift();
-						return res;
-					} else {
-						return [text.replace(/\n/g, ' ')];
-					}
-				}
-
-				case 'bold': {
-					return [h('b', genEl(token.children))];
-				}
-
-				case 'strike': {
-					return [h('del', genEl(token.children))];
-				}
-
-				case 'italic': {
-					return h('i', {
-						style: 'font-style: oblique;',
-					}, genEl(token.children));
-				}
-
-				case 'fn': {
-					// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
-					let style;
-					switch (token.props.name) {
-						case 'tada': {
-							const speed = validTime(token.props.args.speed) || '1s';
-							style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : '');
-							break;
-						}
-						case 'jelly': {
-							const speed = validTime(token.props.args.speed) || '1s';
-							style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
-							break;
-						}
-						case 'twitch': {
-							const speed = validTime(token.props.args.speed) || '0.5s';
-							style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
-							break;
-						}
-						case 'shake': {
-							const speed = validTime(token.props.args.speed) || '0.5s';
-							style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
-							break;
-						}
-						case 'spin': {
-							const direction =
-								token.props.args.left ? 'reverse' :
-								token.props.args.alternate ? 'alternate' :
-								'normal';
-							const anime =
-								token.props.args.x ? 'mfm-spinX' :
-								token.props.args.y ? 'mfm-spinY' :
-								'mfm-spin';
-							const speed = validTime(token.props.args.speed) || '1.5s';
-							style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
-							break;
-						}
-						case 'jump': {
-							const speed = validTime(token.props.args.speed) || '0.75s';
-							style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : '';
-							break;
-						}
-						case 'bounce': {
-							const speed = validTime(token.props.args.speed) || '0.75s';
-							style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
-							break;
-						}
-						case 'flip': {
-							const transform =
-								(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
-								token.props.args.v ? 'scaleY(-1)' :
-								'scaleX(-1)';
-							style = `transform: ${transform};`;
-							break;
-						}
-						case 'x2': {
-							return h('span', {
-								class: 'mfm-x2',
-							}, genEl(token.children));
-						}
-						case 'x3': {
-							return h('span', {
-								class: 'mfm-x3',
-							}, genEl(token.children));
-						}
-						case 'x4': {
-							return h('span', {
-								class: 'mfm-x4',
-							}, genEl(token.children));
-						}
-						case 'font': {
-							const family =
-								token.props.args.serif ? 'serif' :
-								token.props.args.monospace ? 'monospace' :
-								token.props.args.cursive ? 'cursive' :
-								token.props.args.fantasy ? 'fantasy' :
-								token.props.args.emoji ? 'emoji' :
-								token.props.args.math ? 'math' :
-								null;
-							if (family) style = `font-family: ${family};`;
-							break;
-						}
-						case 'blur': {
-							return h('span', {
-								class: '_mfm_blur_',
-							}, genEl(token.children));
-						}
-						case 'rainbow': {
-							const speed = validTime(token.props.args.speed) || '1s';
-							style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
-							break;
-						}
-						case 'sparkle': {
-							if (!this.$store.state.animatedMfm) {
-								return genEl(token.children);
-							}
-							return h(MkSparkle, {}, genEl(token.children));
-						}
-						case 'rotate': {
-							const degrees = parseInt(token.props.args.deg) || '90';
-							style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
-							break;
-						}
-					}
-					if (style == null) {
-						return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
-					} else {
-						return h('span', {
-							style: 'display: inline-block;' + style,
-						}, genEl(token.children));
-					}
-				}
-
-				case 'small': {
-					return [h('small', {
-						style: 'opacity: 0.7;',
-					}, genEl(token.children))];
-				}
-
-				case 'center': {
-					return [h('div', {
-						style: 'text-align:center;',
-					}, genEl(token.children))];
-				}
-
-				case 'url': {
-					return [h(MkUrl, {
-						key: Math.random(),
-						url: token.props.url,
-						rel: 'nofollow noopener',
-					})];
-				}
-
-				case 'link': {
-					return [h(MkLink, {
-						key: Math.random(),
-						url: token.props.url,
-						rel: 'nofollow noopener',
-					}, genEl(token.children))];
-				}
-
-				case 'mention': {
-					return [h(MkMention, {
-						key: Math.random(),
-						host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
-						username: token.props.username,
-					})];
-				}
-
-				case 'hashtag': {
-					return [h(MkA, {
-						key: Math.random(),
-						to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
-						style: 'color:var(--hashtag);',
-					}, `#${token.props.hashtag}`)];
-				}
-
-				case 'blockCode': {
-					return [h(MkCode, {
-						key: Math.random(),
-						code: token.props.code,
-						lang: token.props.lang,
-					})];
-				}
-
-				case 'inlineCode': {
-					return [h(MkCode, {
-						key: Math.random(),
-						code: token.props.code,
-						inline: true,
-					})];
-				}
-
-				case 'quote': {
-					if (!this.nowrap) {
-						return [h('div', {
-							class: 'quote',
-						}, genEl(token.children))];
-					} else {
-						return [h('span', {
-							class: 'quote',
-						}, genEl(token.children))];
-					}
-				}
-
-				case 'emojiCode': {
-					return [h(MkEmoji, {
-						key: Math.random(),
-						emoji: `:${token.props.name}:`,
-						customEmojis: this.customEmojis,
-						normal: this.plain,
-					})];
-				}
-
-				case 'unicodeEmoji': {
-					return [h(MkEmoji, {
-						key: Math.random(),
-						emoji: token.props.emoji,
-						customEmojis: this.customEmojis,
-						normal: this.plain,
-					})];
-				}
-
-				case 'mathInline': {
-					return [h(MkFormula, {
-						key: Math.random(),
-						formula: token.props.formula,
-						block: false,
-					})];
-				}
-
-				case 'mathBlock': {
-					return [h(MkFormula, {
-						key: Math.random(),
-						formula: token.props.formula,
-						block: true,
-					})];
-				}
-
-				case 'search': {
-					return [h(MkGoogle, {
-						key: Math.random(),
-						q: token.props.query,
-					})];
-				}
-
-				case 'plain': {
-					return [h('span', genEl(token.children))];
-				}
-
-				default: {
-					// eslint-disable-next-line @typescript-eslint/no-explicit-any
-					console.error('unrecognized ast type:', (token as any).type);
-
-					return [];
-				}
-			}
-		}).flat(Infinity) as (VNode | string)[];
-
-		// Parse ast to DOM
-		return h('span', genEl(ast));
-	},
-});
diff --git a/packages/client/src/components/page/page.block.vue b/packages/client/src/components/page/page.block.vue
deleted file mode 100644
index f3e7764604..0000000000
--- a/packages/client/src/components/page/page.block.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<template>
-<component :is="'x-' + block.type" :key="block.id" :block="block" :hpml="hpml" :h="h"/>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType } from 'vue';
-import XText from './page.text.vue';
-import XSection from './page.section.vue';
-import XImage from './page.image.vue';
-import XButton from './page.button.vue';
-import XNumberInput from './page.number-input.vue';
-import XTextInput from './page.text-input.vue';
-import XTextareaInput from './page.textarea-input.vue';
-import XSwitch from './page.switch.vue';
-import XIf from './page.if.vue';
-import XTextarea from './page.textarea.vue';
-import XPost from './page.post.vue';
-import XCounter from './page.counter.vue';
-import XRadioButton from './page.radio-button.vue';
-import XCanvas from './page.canvas.vue';
-import XNote from './page.note.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { Block } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote,
-	},
-	props: {
-		block: {
-			type: Object as PropType<Block>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-		h: {
-			type: Number,
-			required: true,
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue
deleted file mode 100644
index 83931021d8..0000000000
--- a/packages/client/src/components/page/page.button.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<div>
-	<MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType, unref } from 'vue';
-import MkButton from '../MkButton.vue';
-import * as os from '@/os';
-import { ButtonBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-	props: {
-		block: {
-			type: Object as PropType<ButtonBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	methods: {
-		click() {
-			if (this.block.action === 'dialog') {
-				this.hpml.eval();
-				os.alert({
-					text: this.hpml.interpolate(this.block.content),
-				});
-			} else if (this.block.action === 'resetRandom') {
-				this.hpml.updateRandomSeed(Math.random());
-				this.hpml.eval();
-			} else if (this.block.action === 'pushEvent') {
-				os.api('page-push', {
-					pageId: this.hpml.page.id,
-					event: this.block.event,
-					...(this.block.var ? {
-						var: unref(this.hpml.vars)[this.block.var],
-					} : {}),
-				});
-
-				os.alert({
-					type: 'success',
-					text: this.hpml.interpolate(this.block.message),
-				});
-			} else if (this.block.action === 'callAiScript') {
-				this.hpml.callAiScript(this.block.fn);
-			}
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
-	display: inline-block;
-	min-width: 200px;
-	max-width: 450px;
-	margin: 8px 0;
-}
-</style>
diff --git a/packages/client/src/components/page/page.canvas.vue b/packages/client/src/components/page/page.canvas.vue
deleted file mode 100644
index 80f6c8339c..0000000000
--- a/packages/client/src/components/page/page.canvas.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<template>
-<div class="ysrxegms">
-	<canvas ref="canvas" :width="block.width" :height="block.height"/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
-import * as os from '@/os';
-import { CanvasBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
-	props: {
-		block: {
-			type: Object as PropType<CanvasBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const canvas: Ref<any> = ref(null);
-
-		onMounted(() => {
-			props.hpml.registerCanvas(props.block.name, canvas.value);
-		});
-
-		return {
-			canvas,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.ysrxegms {
-	display: inline-block;
-	vertical-align: bottom;
-	overflow: auto;
-	max-width: 100%;
-
-	> canvas {
-		display: block;
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue
deleted file mode 100644
index a9e1f41a54..0000000000
--- a/packages/client/src/components/page/page.counter.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<template>
-<div>
-	<MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkButton from '../MkButton.vue';
-import * as os from '@/os';
-import { CounterVarBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-	props: {
-		block: {
-			type: Object as PropType<CounterVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function click() {
-			props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1));
-			props.hpml.eval();
-		}
-
-		return {
-			click,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.llumlmnx {
-	display: inline-block;
-	min-width: 300px;
-	max-width: 450px;
-	margin: 8px 0;
-}
-</style>
diff --git a/packages/client/src/components/page/page.if.vue b/packages/client/src/components/page/page.if.vue
deleted file mode 100644
index 372a15f0c6..0000000000
--- a/packages/client/src/components/page/page.if.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<template>
-<div v-show="hpml.vars.value[block.var]">
-	<XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h"/>
-</div>
-</template>
-
-<script lang="ts">
-import { IfBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defineComponent, defineAsyncComponent, PropType } from 'vue';
-
-export default defineComponent({
-	components: {
-		XBlock: defineAsyncComponent(() => import('./page.block.vue')),
-	},
-	props: {
-		block: {
-			type: Object as PropType<IfBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-		h: {
-			type: Number,
-			required: true,
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue
deleted file mode 100644
index 8ba70c5855..0000000000
--- a/packages/client/src/components/page/page.image.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<template>
-<div class="lzyxtsnt">
-	<ImgWithBlurhash v-if="image" :hash="image.blurhash" :src="image.url" :alt="image.comment" :title="image.comment" :cover="false"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineComponent, PropType } from 'vue';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import * as os from '@/os';
-import { ImageBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-const props = defineProps<{
-	block: PropType<ImageBlock>,
-	hpml: PropType<Hpml>,
-}>();
-
-const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId);
-</script>
-
-<style lang="scss" scoped>
-.lzyxtsnt {
-	> img {
-		max-width: 100%;
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue
deleted file mode 100644
index 7d5c484a1b..0000000000
--- a/packages/client/src/components/page/page.note.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<div class="voxdxuby">
-	<XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
-	<XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
-import XNote from '@/components/MkNote.vue';
-import XNoteDetailed from '@/components/MkNoteDetailed.vue';
-import * as os from '@/os';
-import { NoteBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		XNote,
-		XNoteDetailed,
-	},
-	props: {
-		block: {
-			type: Object as PropType<NoteBlock>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const note: Ref<Record<string, any> | null> = ref(null);
-
-		onMounted(() => {
-			os.api('notes/show', { noteId: props.block.note })
-			.then(result => {
-				note.value = result;
-			});
-		});
-
-		return {
-			note,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.voxdxuby {
-	margin: 1em 0;
-}
-</style>
diff --git a/packages/client/src/components/page/page.number-input.vue b/packages/client/src/components/page/page.number-input.vue
deleted file mode 100644
index 50cf6d0770..0000000000
--- a/packages/client/src/components/page/page.number-input.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<template>
-<div>
-	<MkInput class="kudkigyw" :model-value="value" type="number" @update:model-value="updateValue($event)">
-		<template #label>{{ hpml.interpolate(block.text) }}</template>
-	</MkInput>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkInput from '../form/input.vue';
-import * as os from '@/os';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { NumberInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		MkInput,
-	},
-	props: {
-		block: {
-			type: Object as PropType<NumberInputVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function updateValue(newValue) {
-			props.hpml.updatePageVar(props.block.name, newValue);
-			props.hpml.eval();
-		}
-
-		return {
-			value,
-			updateValue,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
-	display: inline-block;
-	min-width: 300px;
-	max-width: 450px;
-	margin: 8px 0;
-}
-</style>
diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue
deleted file mode 100644
index 0ef50d65cd..0000000000
--- a/packages/client/src/components/page/page.post.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<template>
-<div class="ngbfujlo">
-	<MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea>
-	<MkButton class="button" primary :disabled="posting || posted" @click="post()">
-		<i v-if="posted" class="ti ti-check"></i>
-		<i v-else class="ti ti-send"></i>
-	</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType } from 'vue';
-import MkTextarea from '../form/textarea.vue';
-import MkButton from '../MkButton.vue';
-import { apiUrl } from '@/config';
-import * as os from '@/os';
-import { PostBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
-	components: {
-		MkTextarea,
-		MkButton,
-	},
-	props: {
-		block: {
-			type: Object as PropType<PostBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	data() {
-		return {
-			text: this.hpml.interpolate(this.block.text),
-			posted: false,
-			posting: false,
-		};
-	},
-	watch: {
-		'hpml.vars': {
-			handler() {
-				this.text = this.hpml.interpolate(this.block.text);
-			},
-			deep: true,
-		},
-	},
-	methods: {
-		upload() {
-			const promise = new Promise((ok) => {
-				const canvas = this.hpml.canvases[this.block.canvasId];
-				canvas.toBlob(blob => {
-					const formData = new FormData();
-					formData.append('file', blob);
-					formData.append('i', this.$i.token);
-					if (this.$store.state.uploadFolder) {
-						formData.append('folderId', this.$store.state.uploadFolder);
-					}
-
-					window.fetch(apiUrl + '/drive/files/create', {
-						method: 'POST',
-						body: formData,
-					})
-						.then(response => response.json())
-						.then(f => {
-							ok(f);
-						});
-				});
-			});
-			os.promiseDialog(promise);
-			return promise;
-		},
-		async post() {
-			this.posting = true;
-			const file = this.block.attachCanvasImage ? await this.upload() : null;
-			os.apiWithDialog('notes/create', {
-				text: this.text === '' ? null : this.text,
-				fileIds: file ? [file.id] : undefined,
-			}).then(() => {
-				this.posted = true;
-			});
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.ngbfujlo {
-	position: relative;
-	padding: 32px;
-	border-radius: 6px;
-	box-shadow: 0 2px 8px var(--shadow);
-	z-index: 1;
-
-	> .button {
-		margin-top: 32px;
-	}
-
-	@media (max-width: 600px) {
-		padding: 16px;
-
-		> .button {
-			margin-top: 16px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.radio-button.vue b/packages/client/src/components/page/page.radio-button.vue
deleted file mode 100644
index b4d9e01a54..0000000000
--- a/packages/client/src/components/page/page.radio-button.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<div>
-	<div>{{ hpml.interpolate(block.title) }}</div>
-	<MkRadio v-for="item in block.values" :key="item" :modelValue="value" :value="item" @update:model-value="updateValue($event)">{{ item }}</MkRadio>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkRadio from '../form/radio.vue';
-import * as os from '@/os';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { RadioButtonVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		MkRadio,
-	},
-	props: {
-		block: {
-			type: Object as PropType<RadioButtonVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function updateValue(newValue: string) {
-			props.hpml.updatePageVar(props.block.name, newValue);
-			props.hpml.eval();
-		}
-
-		return {
-			value,
-			updateValue,
-		};
-	},
-});
-</script>
diff --git a/packages/client/src/components/page/page.section.vue b/packages/client/src/components/page/page.section.vue
deleted file mode 100644
index 630c1f5179..0000000000
--- a/packages/client/src/components/page/page.section.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
-<section class="sdgxphyu">
-	<component :is="'h' + h">{{ block.title }}</component>
-
-	<div class="children">
-		<XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h + 1"/>
-	</div>
-</section>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, PropType } from 'vue';
-import * as os from '@/os';
-import { SectionBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
-	components: {
-		XBlock: defineAsyncComponent(() => import('./page.block.vue')),
-	},
-	props: {
-		block: {
-			type: Object as PropType<SectionBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-		h: {
-			required: true,
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.sdgxphyu {
-	margin: 1.5em 0;
-
-	> h2 {
-		font-size: 1.35em;
-		margin: 0 0 0.5em 0;
-	}
-
-	> h3 {
-		font-size: 1em;
-		margin: 0 0 0.5em 0;
-	}
-
-	> h4 {
-		font-size: 1em;
-		margin: 0 0 0.5em 0;
-	}
-
-	> .children {
-		//padding 16px
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.switch.vue b/packages/client/src/components/page/page.switch.vue
deleted file mode 100644
index 64dc4ff8aa..0000000000
--- a/packages/client/src/components/page/page.switch.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<template>
-<div class="hkcxmtwj">
-	<MkSwitch :model-value="value" @update:model-value="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkSwitch from '../form/switch.vue';
-import * as os from '@/os';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { SwitchVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		MkSwitch,
-	},
-	props: {
-		block: {
-			type: Object as PropType<SwitchVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function updateValue(newValue: boolean) {
-			props.hpml.updatePageVar(props.block.name, newValue);
-			props.hpml.eval();
-		}
-
-		return {
-			value,
-			updateValue,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.hkcxmtwj {
-	display: inline-block;
-	margin: 16px auto;
-
-	& + .hkcxmtwj {
-		margin-left: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.text-input.vue b/packages/client/src/components/page/page.text-input.vue
deleted file mode 100644
index 840649ece6..0000000000
--- a/packages/client/src/components/page/page.text-input.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<template>
-<div>
-	<MkInput class="kudkigyw" :model-value="value" type="text" @update:model-value="updateValue($event)">
-		<template #label>{{ hpml.interpolate(block.text) }}</template>
-	</MkInput>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkInput from '../form/input.vue';
-import * as os from '@/os';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { TextInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		MkInput,
-	},
-	props: {
-		block: {
-			type: Object as PropType<TextInputVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function updateValue(newValue) {
-			props.hpml.updatePageVar(props.block.name, newValue);
-			props.hpml.eval();
-		}
-
-		return {
-			value,
-			updateValue,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
-	display: inline-block;
-	min-width: 300px;
-	max-width: 450px;
-	margin: 8px 0;
-}
-</style>
diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue
deleted file mode 100644
index 689c484521..0000000000
--- a/packages/client/src/components/page/page.text.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<template>
-<div class="mrdgzndn">
-	<Mfm :key="text" :text="text" :is-note="false" :i="$i"/>
-	<MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url"/>
-</div>
-</template>
-
-<script lang="ts">
-import { TextBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defineAsyncComponent, defineComponent, PropType } from 'vue';
-import * as mfm from 'mfm-js';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
-
-export default defineComponent({
-	components: {
-		MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')),
-	},
-	props: {
-		block: {
-			type: Object as PropType<TextBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	data() {
-		return {
-			text: this.hpml.interpolate(this.block.text),
-		};
-	},
-	computed: {
-		urls(): string[] {
-			if (this.text) {
-				return extractUrlFromMfm(mfm.parse(this.text));
-			} else {
-				return [];
-			}
-		},
-	},
-	watch: {
-		'hpml.vars': {
-			handler() {
-				this.text = this.hpml.interpolate(this.block.text);
-			},
-			deep: true,
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.mrdgzndn {
-	&:not(:first-child) {
-		margin-top: 0.5em;
-	}
-
-	&:not(:last-child) {
-		margin-bottom: 0.5em;
-	}
-
-	> .url {
-		margin: 0.5em 0;
-	}
-}
-</style>
diff --git a/packages/client/src/components/page/page.textarea-input.vue b/packages/client/src/components/page/page.textarea-input.vue
deleted file mode 100644
index 507e1bd97b..0000000000
--- a/packages/client/src/components/page/page.textarea-input.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<div>
-	<MkTextarea :model-value="value" @update:model-value="updateValue($event)">
-		<template #label>{{ hpml.interpolate(block.text) }}</template>
-	</MkTextarea>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkTextarea from '../form/textarea.vue';
-import * as os from '@/os';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { HpmlTextInput } from '@/scripts/hpml';
-import { TextInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
-	components: {
-		MkTextarea,
-	},
-	props: {
-		block: {
-			type: Object as PropType<TextInputVarBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const value = computed(() => {
-			return props.hpml.vars.value[props.block.name];
-		});
-
-		function updateValue(newValue) {
-			props.hpml.updatePageVar(props.block.name, newValue);
-			props.hpml.eval();
-		}
-
-		return {
-			value,
-			updateValue,
-		};
-	},
-});
-</script>
diff --git a/packages/client/src/components/page/page.textarea.vue b/packages/client/src/components/page/page.textarea.vue
deleted file mode 100644
index f809925081..0000000000
--- a/packages/client/src/components/page/page.textarea.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<template>
-<MkTextarea :model-value="text" readonly></MkTextarea>
-</template>
-
-<script lang="ts">
-import { TextBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defineComponent, PropType } from 'vue';
-import MkTextarea from '../form/textarea.vue';
-
-export default defineComponent({
-	components: {
-		MkTextarea,
-	},
-	props: {
-		block: {
-			type: Object as PropType<TextBlock>,
-			required: true,
-		},
-		hpml: {
-			type: Object as PropType<Hpml>,
-			required: true,
-		},
-	},
-	data() {
-		return {
-			text: this.hpml.interpolate(this.block.text),
-		};
-	},
-	watch: {
-		'hpml.vars': {
-			handler() {
-				this.text = this.hpml.interpolate(this.block.text);
-			},
-			deep: true,
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue
deleted file mode 100644
index b5cb73c009..0000000000
--- a/packages/client/src/components/page/page.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<template>
-<div v-if="hpml" class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
-	<XBlock v-for="child in page.content" :key="child.id" :block="child" :hpml="hpml" :h="2"/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue';
-import { parse } from '@syuilo/aiscript';
-import XBlock from './page.block.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { url } from '@/config';
-import { $i } from '@/account';
-import { defaultStore } from '@/store';
-
-export default defineComponent({
-	components: {
-		XBlock,
-	},
-	props: {
-		page: {
-			type: Object as PropType<Record<string, any>>,
-			required: true,
-		},
-	},
-	setup(props, ctx) {
-		const hpml = new Hpml(props.page, {
-			randomSeed: Math.random(),
-			visitor: $i,
-			url: url,
-			enableAiScript: !defaultStore.state.disablePagesScript,
-		});
-
-		onMounted(() => {
-			nextTick(() => {
-				if (props.page.script && hpml.aiscript) {
-					let ast;
-					try {
-						ast = parse(props.page.script);
-					} catch (err) {
-						console.error(err);
-						/*os.alert({
-							type: 'error',
-							text: 'Syntax error :('
-						});*/
-						return;
-					}
-					hpml.aiscript.exec(ast).then(() => {
-						hpml.eval();
-					}).catch(err => {
-						console.error(err);
-						/*os.alert({
-							type: 'error',
-							text: err
-						});*/
-					});
-				} else {
-					hpml.eval();
-				}
-			});
-			onUnmounted(() => {
-				if (hpml.aiscript) hpml.aiscript.abort();
-			});
-		});
-
-		return {
-			hpml,
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.iroscrza {
-	&.serif {
-		> div {
-			font-family: serif;
-		}
-	}
-
-	&.center {
-		text-align: center;
-	}
-}
-</style>
diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts
deleted file mode 100644
index f2022b0f02..0000000000
--- a/packages/client/src/config.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-const address = new URL(location.href);
-const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content;
-
-export const host = address.host;
-export const hostname = address.hostname;
-export const url = address.origin;
-export const apiUrl = url + '/api';
-export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
-export const lang = localStorage.getItem('lang');
-export const langs = _LANGS_;
-export const locale = JSON.parse(localStorage.getItem('locale'));
-export const version = _VERSION_;
-export const instanceName = siteName === 'Misskey' ? host : siteName;
-export const ui = localStorage.getItem('ui');
-export const debug = localStorage.getItem('debug') === 'true';
diff --git a/packages/client/src/const.ts b/packages/client/src/const.ts
deleted file mode 100644
index 77366cf07b..0000000000
--- a/packages/client/src/const.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-// ブラウザで直接表示することを許可するファイルの種類のリスト
-// ここに含まれないものは application/octet-stream としてレスポンスされる
-// SVGはXSSを生むので許可しない
-export const FILE_TYPE_BROWSERSAFE = [
-	// Images
-	'image/png',
-	'image/gif',
-	'image/jpeg',
-	'image/webp',
-	'image/avif',
-	'image/apng',
-	'image/bmp',
-	'image/tiff',
-	'image/x-icon',
-
-	// OggS
-	'audio/opus',
-	'video/ogg',
-	'audio/ogg',
-	'application/ogg',
-
-	// ISO/IEC base media file format
-	'video/quicktime',
-	'video/mp4',
-	'audio/mp4',
-	'video/x-m4v',
-	'audio/x-m4a',
-	'video/3gpp',
-	'video/3gpp2',
-
-	'video/mpeg',
-	'audio/mpeg',
-
-	'video/webm',
-	'audio/webm',
-
-	'audio/aac',
-	'audio/x-flac',
-	'audio/vnd.wave',
-];
-/*
-https://github.com/sindresorhus/file-type/blob/main/supported.js
-https://github.com/sindresorhus/file-type/blob/main/core.js
-https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
-*/
diff --git a/packages/client/src/directives/adaptive-border.ts b/packages/client/src/directives/adaptive-border.ts
deleted file mode 100644
index 619c9f0b6d..0000000000
--- a/packages/client/src/directives/adaptive-border.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Directive } from 'vue';
-
-export default {
-	mounted(src, binding, vn) {
-		const getBgColor = (el: HTMLElement) => {
-			const style = window.getComputedStyle(el);
-			if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
-				return style.backgroundColor;
-			} else {
-				return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
-			}
-		};
-	
-		const parentBg = getBgColor(src.parentElement);
-
-		const myBg = window.getComputedStyle(src).backgroundColor;
-
-		if (parentBg === myBg) {
-			src.style.borderColor = 'var(--divider)';
-		} else {
-			src.style.borderColor = myBg;
-		}
-	},
-} as Directive;
diff --git a/packages/client/src/directives/anim.ts b/packages/client/src/directives/anim.ts
deleted file mode 100644
index 04e1c6a404..0000000000
--- a/packages/client/src/directives/anim.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Directive } from 'vue';
-
-export default {
-	beforeMount(src, binding, vn) {
-		src.style.opacity = '0';
-		src.style.transform = 'scale(0.9)';
-		// ページネーションと相性が悪いので
-		//if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`;
-		src.classList.add('_zoom');
-	},
-
-	mounted(src, binding, vn) {
-		window.setTimeout(() => {
-			src.style.opacity = '1';
-			src.style.transform = 'none';
-		}, 1);
-	},
-} as Directive;
diff --git a/packages/client/src/directives/appear.ts b/packages/client/src/directives/appear.ts
deleted file mode 100644
index 7fa43fc34a..0000000000
--- a/packages/client/src/directives/appear.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Directive } from 'vue';
-
-export default {
-	mounted(src, binding, vn) {
-		const fn = binding.value;
-		if (fn == null) return;
-
-		const observer = new IntersectionObserver(entries => {
-			if (entries.some(entry => entry.isIntersecting)) {
-				fn();
-			}
-		});
-
-		observer.observe(src);
-
-		src._observer_ = observer;
-	},
-
-	unmounted(src, binding, vn) {
-		if (src._observer_) src._observer_.disconnect();
-	},
-} as Directive;
diff --git a/packages/client/src/directives/click-anime.ts b/packages/client/src/directives/click-anime.ts
deleted file mode 100644
index e2f514b7ca..0000000000
--- a/packages/client/src/directives/click-anime.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Directive } from 'vue';
-import { defaultStore } from '@/store';
-
-export default {
-	mounted(el, binding, vn) {
-		/*
-		if (!defaultStore.state.animation) return;
-
-		el.classList.add('_anime_bounce_standBy');
-
-		el.addEventListener('mousedown', () => {
-			el.classList.add('_anime_bounce_standBy');
-			el.classList.add('_anime_bounce_ready');
-
-			el.addEventListener('mouseleave', () => {
-				el.classList.remove('_anime_bounce_ready');
-			});
-		});
-
-		el.addEventListener('click', () => {
-			el.classList.add('_anime_bounce');
-		});
-
-		el.addEventListener('animationend', () => {
-			el.classList.remove('_anime_bounce_ready');
-			el.classList.remove('_anime_bounce');
-			el.classList.add('_anime_bounce_standBy');
-		});
-		*/
-	},
-} as Directive;
diff --git a/packages/client/src/directives/follow-append.ts b/packages/client/src/directives/follow-append.ts
deleted file mode 100644
index 62e0ac3b94..0000000000
--- a/packages/client/src/directives/follow-append.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Directive } from 'vue';
-import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
-
-export default {
-	mounted(src, binding, vn) {
-		if (binding.value === false) return;
-
-		let isBottom = true;
-
-		const container = getScrollContainer(src)!;
-		container.addEventListener('scroll', () => {
-			const pos = getScrollPosition(container);
-			const viewHeight = container.clientHeight;
-			const height = container.scrollHeight;
-			isBottom = (pos + viewHeight > height - 32);
-		}, { passive: true });
-		container.scrollTop = container.scrollHeight;
-
-		const ro = new ResizeObserver((entries, observer) => {
-			if (isBottom) {
-				const height = container.scrollHeight;
-				container.scrollTop = height;
-			}
-		});
-
-		ro.observe(src);
-
-		// TODO: 新たにプロパティを作るのをやめMapを使う
-		src._ro_ = ro;
-	},
-
-	unmounted(src, binding, vn) {
-		if (src._ro_) src._ro_.unobserve(src);
-	},
-} as Directive;
diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts
deleted file mode 100644
index ff3bdd78ac..0000000000
--- a/packages/client/src/directives/get-size.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Directive } from 'vue';
-
-const mountings = new Map<Element, {
-	resize: ResizeObserver;
-	intersection?: IntersectionObserver;
-	fn: (w: number, h: number) => void;
-}>();
-
-function calc(src: Element) {
-	const info = mountings.get(src);
-	const height = src.clientHeight;
-	const width = src.clientWidth;
-
-	if (!info) return;
-
-	// アクティベート前などでsrcが描画されていない場合
-	if (!height) {
-		// IntersectionObserverで表示検出する
-		if (!info.intersection) {
-			info.intersection = new IntersectionObserver(entries => {
-				if (entries.some(entry => entry.isIntersecting)) calc(src);
-			});
-		}
-		info.intersection.observe(src);
-		return;
-	}
-	if (info.intersection) {
-		info.intersection.disconnect();
-		delete info.intersection;
-	}
-
-	info.fn(width, height);
-}
-
-export default {
-	mounted(src, binding, vn) {
-		const resize = new ResizeObserver((entries, observer) => {
-			calc(src);
-		});
-		resize.observe(src);
-
-		mountings.set(src, { resize, fn: binding.value });
-		calc(src);
-	},
-
-	unmounted(src, binding, vn) {
-		binding.value(0, 0);
-		const info = mountings.get(src);
-		if (!info) return;
-		info.resize.disconnect();
-		if (info.intersection) info.intersection.disconnect();
-		mountings.delete(src);
-	},
-} as Directive<Element, (w: number, h: number) => void>;
diff --git a/packages/client/src/directives/hotkey.ts b/packages/client/src/directives/hotkey.ts
deleted file mode 100644
index dfc5f646a4..0000000000
--- a/packages/client/src/directives/hotkey.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Directive } from 'vue';
-import { makeHotkey } from '../scripts/hotkey';
-
-export default {
-	mounted(el, binding) {
-		el._hotkey_global = binding.modifiers.global === true;
-
-		el._keyHandler = makeHotkey(binding.value);
-
-		if (el._hotkey_global) {
-			document.addEventListener('keydown', el._keyHandler);
-		} else {
-			el.addEventListener('keydown', el._keyHandler);
-		}
-	},
-
-	unmounted(el) {
-		if (el._hotkey_global) {
-			document.removeEventListener('keydown', el._keyHandler);
-		} else {
-			el.removeEventListener('keydown', el._keyHandler);
-		}
-	},
-} as Directive;
diff --git a/packages/client/src/directives/index.ts b/packages/client/src/directives/index.ts
deleted file mode 100644
index 401a917cba..0000000000
--- a/packages/client/src/directives/index.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { App } from 'vue';
-
-import userPreview from './user-preview';
-import size from './size';
-import getSize from './get-size';
-import ripple from './ripple';
-import tooltip from './tooltip';
-import hotkey from './hotkey';
-import appear from './appear';
-import anim from './anim';
-import clickAnime from './click-anime';
-import panel from './panel';
-import adaptiveBorder from './adaptive-border';
-
-export default function(app: App) {
-	app.directive('userPreview', userPreview);
-	app.directive('user-preview', userPreview);
-	app.directive('size', size);
-	app.directive('get-size', getSize);
-	app.directive('ripple', ripple);
-	app.directive('tooltip', tooltip);
-	app.directive('hotkey', hotkey);
-	app.directive('appear', appear);
-	app.directive('anim', anim);
-	app.directive('click-anime', clickAnime);
-	app.directive('panel', panel);
-	app.directive('adaptive-border', adaptiveBorder);
-}
diff --git a/packages/client/src/directives/panel.ts b/packages/client/src/directives/panel.ts
deleted file mode 100644
index d31dc41ed4..0000000000
--- a/packages/client/src/directives/panel.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Directive } from 'vue';
-
-export default {
-	mounted(src, binding, vn) {
-		const getBgColor = (el: HTMLElement) => {
-			const style = window.getComputedStyle(el);
-			if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
-				return style.backgroundColor;
-			} else {
-				return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
-			}
-		};
-	
-		const parentBg = getBgColor(src.parentElement);
-
-		const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel');
-
-		if (parentBg === myBg) {
-			src.style.backgroundColor = 'var(--bg)';
-		} else {
-			src.style.backgroundColor = 'var(--panel)';
-		}
-	},
-} as Directive;
diff --git a/packages/client/src/directives/ripple.ts b/packages/client/src/directives/ripple.ts
deleted file mode 100644
index d32f7ab441..0000000000
--- a/packages/client/src/directives/ripple.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import Ripple from '@/components/MkRipple.vue';
-import { popup } from '@/os';
-
-export default {
-	mounted(el, binding, vn) {
-		// 明示的に false であればバインドしない
-		if (binding.value === false) return;
-
-		el.addEventListener('click', () => {
-			const rect = el.getBoundingClientRect();
-
-			const x = rect.left + (el.offsetWidth / 2);
-			const y = rect.top + (el.offsetHeight / 2);
-
-			popup(Ripple, { x, y }, {}, 'end');
-		});
-	},
-};
diff --git a/packages/client/src/directives/size.ts b/packages/client/src/directives/size.ts
deleted file mode 100644
index da8bd78ea1..0000000000
--- a/packages/client/src/directives/size.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Directive } from 'vue';
-
-type Value = { max?: number[]; min?: number[]; };
-
-//const observers = new Map<Element, ResizeObserver>();
-const mountings = new Map<Element, {
-	value: Value;
-	resize: ResizeObserver;
-	intersection?: IntersectionObserver;
-	previousWidth: number;
-	twoPreviousWidth: number;
-}>();
-
-type ClassOrder = {
-	add: string[];
-	remove: string[];
-};
-
-const isContainerQueriesSupported = ('container' in document.documentElement.style);
-
-const cache = new Map<string, ClassOrder>();
-
-function getClassOrder(width: number, queue: Value): ClassOrder {
-	const getMaxClass = (v: number) => `max-width_${v}px`;
-	const getMinClass = (v: number) => `min-width_${v}px`;
-
-	return {
-		add: [
-			...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []),
-			...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []),
-		],
-		remove: [
-			...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []),
-			...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []),
-		],
-	};
-}
-
-function applyClassOrder(el: Element, order: ClassOrder) {
-	el.classList.add(...order.add);
-	el.classList.remove(...order.remove);
-}
-
-function getOrderName(width: number, queue: Value): string {
-	return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`;
-}
-
-function calc(el: Element) {
-	const info = mountings.get(el);
-	const width = el.clientWidth;
-
-	if (!info || info.previousWidth === width) return;
-
-	// アクティベート前などでsrcが描画されていない場合
-	if (!width) {
-		// IntersectionObserverで表示検出する
-		if (!info.intersection) {
-			info.intersection = new IntersectionObserver(entries => {
-				if (entries.some(entry => entry.isIntersecting)) calc(el);
-			});
-		}
-		info.intersection.observe(el);
-		return;
-	}
-	if (info.intersection) {
-		info.intersection.disconnect();
-		delete info.intersection;
-	}
-
-	mountings.set(el, { ...info, ...{ previousWidth: width, twoPreviousWidth: info.previousWidth }});
-
-	// Prevent infinite resizing
-	// https://github.com/misskey-dev/misskey/issues/9076
-	if (info.twoPreviousWidth === width) {
-		return;
-	}
-
-	const cached = cache.get(getOrderName(width, info.value));
-	if (cached) {
-		applyClassOrder(el, cached);
-	} else {
-		const order = getClassOrder(width, info.value);
-		cache.set(getOrderName(width, info.value), order);
-		applyClassOrder(el, order);
-	}
-}
-
-export default {
-	mounted(src, binding, vn) {
-		if (isContainerQueriesSupported) return;
-
-		const resize = new ResizeObserver((entries, observer) => {
-			calc(src);
-		});
-
-		mountings.set(src, {
-			value: binding.value,
-			resize,
-			previousWidth: 0,
-			twoPreviousWidth: 0,
-		});
-
-		calc(src);
-		resize.observe(src);
-	},
-
-	updated(src, binding, vn) {
-		if (isContainerQueriesSupported) return;
-
-		mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value }));
-		calc(src);
-	},
-
-	unmounted(src, binding, vn) {
-		if (isContainerQueriesSupported) return;
-
-		const info = mountings.get(src);
-		if (!info) return;
-		info.resize.disconnect();
-		if (info.intersection) info.intersection.disconnect();
-		mountings.delete(src);
-	},
-} as Directive<Element, Value>;
diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts
deleted file mode 100644
index 5d13497b5f..0000000000
--- a/packages/client/src/directives/tooltip.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-// TODO: useTooltip関数使うようにしたい
-// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明
-
-import { defineAsyncComponent, Directive, ref } from 'vue';
-import { isTouchUsing } from '@/scripts/touch';
-import { popup, alert } from '@/os';
-
-const start = isTouchUsing ? 'touchstart' : 'mouseover';
-const end = isTouchUsing ? 'touchend' : 'mouseleave';
-
-export default {
-	mounted(el: HTMLElement, binding, vn) {
-		const delay = binding.modifiers.noDelay ? 0 : 100;
-
-		const self = (el as any)._tooltipDirective_ = {} as any;
-
-		self.text = binding.value as string;
-		self._close = null;
-		self.showTimer = null;
-		self.hideTimer = null;
-		self.checkTimer = null;
-
-		self.close = () => {
-			if (self._close) {
-				window.clearInterval(self.checkTimer);
-				self._close();
-				self._close = null;
-			}
-		};
-
-		if (binding.arg === 'dialog') {
-			el.addEventListener('click', (ev) => {
-				ev.preventDefault();
-				ev.stopPropagation();
-				alert({
-					type: 'info',
-					text: binding.value,
-				});
-				return false;
-			});
-		}
-
-		self.show = () => {
-			if (!document.body.contains(el)) return;
-			if (self._close) return;
-			if (self.text == null) return;
-
-			const showing = ref(true);
-			popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
-				showing,
-				text: self.text,
-				asMfm: binding.modifiers.mfm,
-				direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top',
-				targetElement: el,
-			}, {}, 'closed');
-
-			self._close = () => {
-				showing.value = false;
-			};
-		};
-
-		el.addEventListener('selectstart', ev => {
-			ev.preventDefault();
-		});
-
-		el.addEventListener(start, () => {
-			window.clearTimeout(self.showTimer);
-			window.clearTimeout(self.hideTimer);
-			self.showTimer = window.setTimeout(self.show, delay);
-		}, { passive: true });
-
-		el.addEventListener(end, () => {
-			window.clearTimeout(self.showTimer);
-			window.clearTimeout(self.hideTimer);
-			self.hideTimer = window.setTimeout(self.close, delay);
-		}, { passive: true });
-
-		el.addEventListener('click', () => {
-			window.clearTimeout(self.showTimer);
-			self.close();
-		});
-	},
-
-	updated(el, binding) {
-		const self = el._tooltipDirective_;
-		self.text = binding.value as string;
-	},
-
-	unmounted(el, binding, vn) {
-		const self = el._tooltipDirective_;
-		window.clearInterval(self.checkTimer);
-	},
-} as Directive;
diff --git a/packages/client/src/directives/user-preview.ts b/packages/client/src/directives/user-preview.ts
deleted file mode 100644
index ed5f00ca65..0000000000
--- a/packages/client/src/directives/user-preview.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { defineAsyncComponent, Directive, ref } from 'vue';
-import autobind from 'autobind-decorator';
-import { popup } from '@/os';
-
-export class UserPreview {
-	private el;
-	private user;
-	private showTimer;
-	private hideTimer;
-	private checkTimer;
-	private promise;
-
-	constructor(el, user) {
-		this.el = el;
-		this.user = user;
-
-		this.attach();
-	}
-
-	@autobind
-	private show() {
-		if (!document.body.contains(this.el)) return;
-		if (this.promise) return;
-
-		const showing = ref(true);
-
-		popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), {
-			showing,
-			q: this.user,
-			source: this.el,
-		}, {
-			mouseover: () => {
-				window.clearTimeout(this.hideTimer);
-			},
-			mouseleave: () => {
-				window.clearTimeout(this.showTimer);
-				this.hideTimer = window.setTimeout(this.close, 500);
-			},
-		}, 'closed');
-
-		this.promise = {
-			cancel: () => {
-				showing.value = false;
-			},
-		};
-
-		this.checkTimer = window.setInterval(() => {
-			if (!document.body.contains(this.el)) {
-				window.clearTimeout(this.showTimer);
-				window.clearTimeout(this.hideTimer);
-				this.close();
-			}
-		}, 1000);
-	}
-
-	@autobind
-	private close() {
-		if (this.promise) {
-			window.clearInterval(this.checkTimer);
-			this.promise.cancel();
-			this.promise = null;
-		}
-	}
-
-	@autobind
-	private onMouseover() {
-		window.clearTimeout(this.showTimer);
-		window.clearTimeout(this.hideTimer);
-		this.showTimer = window.setTimeout(this.show, 500);
-	}
-
-	@autobind
-	private onMouseleave() {
-		window.clearTimeout(this.showTimer);
-		window.clearTimeout(this.hideTimer);
-		this.hideTimer = window.setTimeout(this.close, 500);
-	}
-
-	@autobind
-	private onClick() {
-		window.clearTimeout(this.showTimer);
-		this.close();
-	}
-
-	@autobind
-	public attach() {
-		this.el.addEventListener('mouseover', this.onMouseover);
-		this.el.addEventListener('mouseleave', this.onMouseleave);
-		this.el.addEventListener('click', this.onClick);
-	}
-
-	@autobind
-	public detach() {
-		this.el.removeEventListener('mouseover', this.onMouseover);
-		this.el.removeEventListener('mouseleave', this.onMouseleave);
-		this.el.removeEventListener('click', this.onClick);
-		window.clearInterval(this.checkTimer);
-	}
-}
-
-export default {
-	mounted(el: HTMLElement, binding, vn) {
-		if (binding.value == null) return;
-
-		// TODO: 新たにプロパティを作るのをやめMapを使う
-		// ただメモリ的には↓の方が省メモリかもしれないので検討中
-		const self = (el as any)._userPreviewDirective_ = {} as any;
-
-		self.preview = new UserPreview(el, binding.value);
-	},
-
-	unmounted(el, binding, vn) {
-		if (binding.value == null) return;
-
-		const self = el._userPreviewDirective_;
-		self.preview.detach();
-	},
-} as Directive;
diff --git a/packages/client/src/emojilist.json b/packages/client/src/emojilist.json
deleted file mode 100644
index 402e82e33b..0000000000
--- a/packages/client/src/emojilist.json
+++ /dev/null
@@ -1,1785 +0,0 @@
-[
-	{ "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] },
-	{ "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] },
-	{ "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] },
-	{ "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] },
-	{ "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] },
-	{ "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] },
-	{ "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] },
-	{ "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] },
-	{ "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] },
-	{ "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] },
-	{ "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] },
-	{ "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] },
-	{ "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] },
-	{ "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] },
-	{ "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] },
-	{ "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] },
-	{ "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] },
-	{ "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] },
-	{ "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] },
-	{ "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] },
-	{ "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] },
-	{ "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
-	{ "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] },
-	{ "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] },
-	{ "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
-	{ "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] },
-	{ "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] },
-	{ "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] },
-	{ "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] },
-	{ "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] },
-	{ "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] },
-	{ "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] },
-	{ "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] },
-	{ "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] },
-	{ "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] },
-	{ "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] },
-	{ "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] },
-	{ "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] },
-	{ "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] },
-	{ "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] },
-	{ "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] },
-	{ "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] },
-	{ "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] },
-	{ "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] },
-	{ "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] },
-	{ "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] },
-	{ "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] },
-	{ "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] },
-	{ "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] },
-	{ "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] },
-	{ "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] },
-	{ "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] },
-	{ "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] },
-	{ "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] },
-	{ "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] },
-	{ "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] },
-	{ "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] },
-	{ "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] },
-	{ "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] },
-	{ "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] },
-	{ "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] },
-	{ "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] },
-	{ "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] },
-	{ "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] },
-	{ "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] },
-	{ "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] },
-	{ "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] },
-	{ "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] },
-	{ "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] },
-	{ "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] },
-	{ "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] },
-	{ "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] },
-	{ "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] },
-	{ "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] },
-	{ "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] },
-	{ "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] },
-	{ "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] },
-	{ "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] },
-	{ "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] },
-	{ "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] },
-	{ "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] },
-	{ "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] },
-	{ "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] },
-	{ "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] },
-	{ "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] },
-	{ "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] },
-	{ "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] },
-	{ "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] },
-	{ "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] },
-	{ "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] },
-	{ "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] },
-	{ "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] },
-	{ "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] },
-	{ "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] },
-	{ "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] },
-	{ "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] },
-	{ "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] },
-	{ "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] },
-	{ "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] },
-	{ "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] },
-	{ "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] },
-	{ "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] },
-	{ "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] },
-	{ "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] },
-	{ "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] },
-	{ "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] },
-	{ "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] },
-	{ "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] },
-	{ "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] },
-	{ "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] },
-	{ "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] },
-	{ "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] },
-	{ "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] },
-	{ "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] },
-	{ "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] },
-	{ "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] },
-	{ "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] },
-	{ "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] },
-	{ "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] },
-	{ "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] },
-	{ "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] },
-	{ "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] },
-	{ "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] },
-	{ "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] },
-	{ "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] },
-	{ "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] },
-	{ "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] },
-	{ "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] },
-	{ "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] },
-	{ "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] },
-	{ "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] },
-	{ "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] },
-	{ "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] },
-	{ "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] },
-	{ "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] },
-	{ "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] },
-	{ "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] },
-	{ "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] },
-	{ "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] },
-	{ "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] },
-	{ "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] },
-	{ "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] },
-	{ "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] },
-	{ "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] },
-	{ "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] },
-	{ "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] },
-	{ "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] },
-	{ "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] },
-	{ "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] },
-	{ "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] },
-	{ "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] },
-	{ "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] },
-	{ "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] },
-	{ "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] },
-	{ "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] },
-	{ "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] },
-	{ "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] },
-	{ "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] },
-	{ "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] },
-	{ "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] },
-	{ "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] },
-	{ "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] },
-	{ "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] },
-	{ "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] },
-	{ "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] },
-	{ "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] },
-	{ "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] },
-	{ "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] },
-	{ "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] },
-	{ "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] },
-	{ "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] },
-	{ "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] },
-	{ "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] },
-	{ "category": "people", "char": "🫁", "name": "lungs", "keywords": [] },
-	{ "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] },
-	{ "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] },
-	{ "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] },
-	{ "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] },
-	{ "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] },
-	{ "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] },
-	{ "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] },
-	{ "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] },
-	{ "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] },
-	{ "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] },
-	{ "category": "people", "char": "🧑‍🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] },
-	{ "category": "people", "char": "👩‍🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] },
-	{ "category": "people", "char": "👨‍🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] },
-	{ "category": "people", "char": "🧑‍🦰", "name": "red_hair", "keywords": ["redhead"] },
-	{ "category": "people", "char": "👩‍🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] },
-	{ "category": "people", "char": "👨‍🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] },
-	{ "category": "people", "char": "👱‍♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] },
-	{ "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] },
-	{ "category": "people", "char": "🧑‍🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] },
-	{ "category": "people", "char": "👩‍🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] },
-	{ "category": "people", "char": "👨‍🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] },
-	{ "category": "people", "char": "🧑‍🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] },
-	{ "category": "people", "char": "👩‍🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] },
-	{ "category": "people", "char": "👨‍🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] },
-	{ "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] },
-	{ "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] },
-	{ "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] },
-	{ "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] },
-	{ "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] },
-	{ "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] },
-	{ "category": "people", "char": "👳‍♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] },
-	{ "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] },
-	{ "category": "people", "char": "👮‍♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] },
-	{ "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] },
-	{ "category": "people", "char": "👷‍♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] },
-	{ "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] },
-	{ "category": "people", "char": "💂‍♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] },
-	{ "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] },
-	{ "category": "people", "char": "🕵️‍♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] },
-	{ "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] },
-	{ "category": "people", "char": "🧑‍⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] },
-	{ "category": "people", "char": "👩‍⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] },
-	{ "category": "people", "char": "👨‍⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] },
-	{ "category": "people", "char": "👩‍🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🍳", "name": "cook", "keywords": ["chef", "human"] },
-	{ "category": "people", "char": "👩‍🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🎓", "name": "student", "keywords": ["graduate", "human"] },
-	{ "category": "people", "char": "👩‍🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] },
-	{ "category": "people", "char": "👩‍🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] },
-	{ "category": "people", "char": "👩‍🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] },
-	{ "category": "people", "char": "👩‍🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] },
-	{ "category": "people", "char": "🧑‍💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] },
-	{ "category": "people", "char": "👩‍💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] },
-	{ "category": "people", "char": "👨‍💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] },
-	{ "category": "people", "char": "🧑‍💼", "name": "office_worker", "keywords": ["business", "manager", "human"] },
-	{ "category": "people", "char": "👩‍💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] },
-	{ "category": "people", "char": "👨‍💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] },
-	{ "category": "people", "char": "👩‍🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] },
-	{ "category": "people", "char": "👨‍🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] },
-	{ "category": "people", "char": "🧑‍🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] },
-	{ "category": "people", "char": "👩‍🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🎨", "name": "artist", "keywords": ["painter", "human"] },
-	{ "category": "people", "char": "👩‍🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🚒", "name": "firefighter", "keywords": ["fireman", "human"] },
-	{ "category": "people", "char": "👩‍🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] },
-	{ "category": "people", "char": "🧑‍✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] },
-	{ "category": "people", "char": "👩‍✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] },
-	{ "category": "people", "char": "👨‍✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] },
-	{ "category": "people", "char": "🧑‍🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] },
-	{ "category": "people", "char": "👩‍🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] },
-	{ "category": "people", "char": "👨‍🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] },
-	{ "category": "people", "char": "🧑‍⚖️", "name": "judge", "keywords": ["justice", "court", "human"] },
-	{ "category": "people", "char": "👩‍⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] },
-	{ "category": "people", "char": "👨‍⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] },
-	{ "category": "people", "char": "🦸‍♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] },
-	{ "category": "people", "char": "🦸‍♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] },
-	{ "category": "people", "char": "🦹‍♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] },
-	{ "category": "people", "char": "🦹‍♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] },
-	{ "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] },
-	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] },
-	{ "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] },
-	{ "category": "people", "char": "🥷", "name": "ninja", "keywords": [] },
-	{ "category": "people", "char": "🧙‍♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] },
-	{ "category": "people", "char": "🧙‍♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] },
-	{ "category": "people", "char": "🧝‍♀️", "name": "woman_elf", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧝‍♂️", "name": "man_elf", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "🧛‍♀️", "name": "woman_vampire", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧛‍♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] },
-	{ "category": "people", "char": "🧟‍♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] },
-	{ "category": "people", "char": "🧟‍♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] },
-	{ "category": "people", "char": "🧞‍♀️", "name": "woman_genie", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧞‍♂️", "name": "man_genie", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "🧜‍♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] },
-	{ "category": "people", "char": "🧜‍♂️", "name": "merman", "keywords": ["man", "male", "triton"] },
-	{ "category": "people", "char": "🧚‍♀️", "name": "woman_fairy", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧚‍♂️", "name": "man_fairy", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] },
-	{ "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] },
-	{ "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] },
-	{ "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] },
-	{ "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] },
-	{ "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] },
-	{ "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] },
-	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] },
-	{ "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] },
-	{ "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] },
-	{ "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
-	{ "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
-	{ "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
-	{ "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
-	{ "category": "people", "char": "🏃‍♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] },
-	{ "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] },
-	{ "category": "people", "char": "🚶‍♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] },
-	{ "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] },
-	{ "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] },
-	{ "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] },
-	{ "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] },
-	{ "category": "people", "char": "👯‍♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] },
-	{ "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] },
-	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] },
-	{ "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] },
-	{ "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] },
-	{ "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] },
-	{ "category": "people", "char": "🙇‍♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] },
-	{ "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] },
-	{ "category": "people", "char": "🤦‍♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] },
-	{ "category": "people", "char": "🤦‍♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] },
-	{ "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] },
-	{ "category": "people", "char": "🤷‍♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] },
-	{ "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] },
-	{ "category": "people", "char": "💁‍♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] },
-	{ "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] },
-	{ "category": "people", "char": "🙅‍♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] },
-	{ "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] },
-	{ "category": "people", "char": "🙆‍♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] },
-	{ "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] },
-	{ "category": "people", "char": "🙋‍♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] },
-	{ "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] },
-	{ "category": "people", "char": "🙎‍♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] },
-	{ "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] },
-	{ "category": "people", "char": "🙍‍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] },
-	{ "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] },
-	{ "category": "people", "char": "💇‍♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] },
-	{ "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] },
-	{ "category": "people", "char": "💆‍♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] },
-	{ "category": "people", "char": "🧖‍♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] },
-	{ "category": "people", "char": "🧖‍♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] },
-	{ "category": "people", "char": "🧏‍♀️", "name": "woman_deaf", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧏‍♂️", "name": "man_deaf", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "🧍‍♀️", "name": "woman_standing", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧍‍♂️", "name": "man_standing", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "🧎‍♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] },
-	{ "category": "people", "char": "🧎‍♂️", "name": "man_kneeling", "keywords": ["man", "male"] },
-	{ "category": "people", "char": "🧑‍🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] },
-	{ "category": "people", "char": "👩‍🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] },
-	{ "category": "people", "char": "👨‍🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] },
-	{ "category": "people", "char": "🧑‍🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] },
-	{ "category": "people", "char": "👩‍🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] },
-	{ "category": "people", "char": "👨‍🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] },
-	{ "category": "people", "char": "🧑‍🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] },
-	{ "category": "people", "char": "👩‍🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] },
-	{ "category": "people", "char": "👨‍🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] },
-	{ "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
-	{ "category": "people", "char": "👩‍❤️‍👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
-	{ "category": "people", "char": "👨‍❤️‍👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
-	{ "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
-	{ "category": "people", "char": "👩‍❤️‍💋‍👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
-	{ "category": "people", "char": "👨‍❤️‍💋‍👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
-	{ "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] },
-	{ "category": "people", "char": "👨‍👩‍👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] },
-	{ "category": "people", "char": "👨‍👩‍👧‍👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👩‍👦‍👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👩‍👧‍👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👩‍👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👩‍👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👩‍👧‍👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👩‍👦‍👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👩‍👧‍👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👨‍👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👨‍👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👨‍👧‍👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👨‍👦‍👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👨‍👧‍👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] },
-	{ "category": "people", "char": "👩‍👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] },
-	{ "category": "people", "char": "👩‍👧‍👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👦‍👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "👩‍👧‍👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] },
-	{ "category": "people", "char": "👨‍👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] },
-	{ "category": "people", "char": "👨‍👧‍👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👦‍👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "👨‍👧‍👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
-	{ "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] },
-	{ "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] },
-	{ "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] },
-	{ "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] },
-	{ "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] },
-	{ "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] },
-	{ "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] },
-	{ "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] },
-	{ "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] },
-	{ "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
-	{ "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
-	{ "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] },
-	{ "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] },
-	{ "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] },
-	{ "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] },
-	{ "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] },
-	{ "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] },
-	{ "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] },
-	{ "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] },
-	{ "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] },
-	{ "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] },
-	{ "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] },
-	{ "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] },
-	{ "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] },
-	{ "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] },
-	{ "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] },
-	{ "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] },
-	{ "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] },
-	{ "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] },
-	{ "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] },
-	{ "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] },
-	{ "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] },
-	{ "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] },
-	{ "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] },
-	{ "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] },
-	{ "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] },
-	{ "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] },
-	{ "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] },
-	{ "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] },
-	{ "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] },
-	{ "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] },
-	{ "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] },
-	{ "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] },
-	{ "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] },
-	{ "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] },
-	{ "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] },
-	{ "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] },
-	{ "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] },
-	{ "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
-	{ "category": "animals_and_nature", "char": "🐈‍⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
-	{ "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] },
-	{ "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] },
-	{ "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] },
-	{ "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] },
-	{ "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] },
-	{ "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] },
-	{ "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
-	{ "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] },
-	{ "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] },
-	{ "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] },
-	{ "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] },
-	{ "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] },
-	{ "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] },
-	{ "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] },
-	{ "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] },
-	{ "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] },
-	{ "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] },
-	{ "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] },
-	{ "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] },
-	{ "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] },
-	{ "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] },
-	{ "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] },
-	{ "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] },
-	{ "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] },
-	{ "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] },
-	{ "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] },
-	{ "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] },
-	{ "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] },
-	{ "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] },
-	{ "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] },
-	{ "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] },
-	{ "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] },
-	{ "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] },
-	{ "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] },
-	{ "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] },
-	{ "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] },
-	{ "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] },
-	{ "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] },
-	{ "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] },
-	{ "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] },
-	{ "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] },
-	{ "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] },
-	{ "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] },
-	{ "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] },
-	{ "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] },
-	{ "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] },
-	{ "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] },
-	{ "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] },
-	{ "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] },
-	{ "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] },
-	{ "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] },
-	{ "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] },
-	{ "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] },
-	{ "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] },
-	{ "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] },
-	{ "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] },
-	{ "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] },
-	{ "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] },
-	{ "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] },
-	{ "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
-	{ "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] },
-	{ "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] },
-	{ "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] },
-	{ "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] },
-	{ "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] },
-	{ "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] },
-	{ "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] },
-	{ "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] },
-	{ "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] },
-	{ "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] },
-	{ "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] },
-	{ "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] },
-	{ "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] },
-	{ "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] },
-	{ "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] },
-	{ "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] },
-	{ "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] },
-	{ "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] },
-	{ "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] },
-	{ "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] },
-	{ "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] },
-	{ "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] },
-	{ "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] },
-	{ "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] },
-	{ "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] },
-	{ "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] },
-	{ "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] },
-	{ "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] },
-	{ "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐻‍❄️", "name": "polar_bear", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] },
-	{ "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
-	{ "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
-	{ "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🐕‍🦺", "name": "service_dog", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] },
-	{ "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] },
-	{ "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] },
-	{ "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] },
-	{ "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] },
-	{ "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] },
-	{ "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] },
-	{ "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] },
-	{ "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] },
-	{ "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] },
-	{ "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] },
-	{ "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] },
-	{ "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] },
-	{ "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] },
-	{ "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] },
-	{ "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] },
-	{ "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] },
-	{ "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] },
-	{ "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] },
-	{ "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] },
-	{ "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] },
-	{ "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] },
-	{ "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] },
-	{ "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] },
-	{ "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] },
-	{ "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] },
-	{ "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] },
-	{ "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] },
-	{ "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] },
-	{ "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] },
-	{ "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] },
-	{ "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] },
-	{ "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] },
-	{ "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] },
-	{ "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] },
-	{ "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
-	{ "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] },
-	{ "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] },
-	{ "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] },
-	{ "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] },
-	{ "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] },
-	{ "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] },
-	{ "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] },
-	{ "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] },
-	{ "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] },
-	{ "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] },
-	{ "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] },
-	{ "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] },
-	{ "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] },
-	{ "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] },
-	{ "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] },
-	{ "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] },
-	{ "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] },
-	{ "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] },
-	{ "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] },
-	{ "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] },
-	{ "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] },
-	{ "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] },
-	{ "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] },
-	{ "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] },
-	{ "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] },
-	{ "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] },
-	{ "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] },
-	{ "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] },
-	{ "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] },
-	{ "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] },
-	{ "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] },
-	{ "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] },
-	{ "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] },
-	{ "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] },
-	{ "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] },
-	{ "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] },
-	{ "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] },
-	{ "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] },
-	{ "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] },
-	{ "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] },
-	{ "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] },
-	{ "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] },
-	{ "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] },
-	{ "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] },
-	{ "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] },
-	{ "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] },
-	{ "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] },
-	{ "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] },
-	{ "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] },
-	{ "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] },
-	{ "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] },
-	{ "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] },
-	{ "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] },
-	{ "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] },
-	{ "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] },
-	{ "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] },
-	{ "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] },
-	{ "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] },
-	{ "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] },
-	{ "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] },
-	{ "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] },
-	{ "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] },
-	{ "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] },
-	{ "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] },
-	{ "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] },
-	{ "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] },
-	{ "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] },
-	{ "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] },
-	{ "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] },
-	{ "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] },
-	{ "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] },
-	{ "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] },
-	{ "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] },
-	{ "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] },
-	{ "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] },
-	{ "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] },
-	{ "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] },
-	{ "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] },
-	{ "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] },
-	{ "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] },
-	{ "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] },
-	{ "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] },
-	{ "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] },
-	{ "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] },
-	{ "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] },
-	{ "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] },
-	{ "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] },
-	{ "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] },
-	{ "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] },
-	{ "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] },
-	{ "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] },
-	{ "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] },
-	{ "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] },
-	{ "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] },
-	{ "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] },
-	{ "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] },
-	{ "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] },
-	{ "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] },
-	{ "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] },
-	{ "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] },
-	{ "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] },
-	{ "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] },
-	{ "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] },
-	{ "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] },
-	{ "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] },
-	{ "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] },
-	{ "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] },
-	{ "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] },
-	{ "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] },
-	{ "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] },
-	{ "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] },
-	{ "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] },
-	{ "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] },
-	{ "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] },
-	{ "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] },
-	{ "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] },
-	{ "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] },
-	{ "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] },
-	{ "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] },
-	{ "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
-	{ "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
-	{ "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] },
-	{ "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] },
-	{ "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] },
-	{ "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] },
-	{ "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] },
-	{ "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] },
-	{ "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] },
-	{ "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] },
-	{ "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] },
-	{ "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] },
-	{ "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] },
-	{ "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] },
-	{ "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] },
-	{ "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] },
-	{ "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] },
-	{ "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] },
-	{ "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] },
-	{ "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] },
-	{ "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] },
-	{ "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] },
-	{ "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] },
-	{ "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] },
-	{ "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] },
-	{ "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] },
-	{ "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] },
-	{ "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] },
-	{ "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] },
-	{ "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] },
-	{ "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] },
-	{ "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] },
-	{ "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] },
-	{ "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] },
-	{ "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] },
-	{ "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] },
-	{ "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] },
-	{ "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] },
-	{ "category": "activity", "char": "🏌️‍♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] },
-	{ "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] },
-	{ "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] },
-	{ "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] },
-	{ "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] },
-	{ "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] },
-	{ "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] },
-	{ "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] },
-	{ "category": "activity", "char": "🤼‍♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] },
-	{ "category": "activity", "char": "🤼‍♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] },
-	{ "category": "activity", "char": "🤸‍♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] },
-	{ "category": "activity", "char": "🤸‍♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] },
-	{ "category": "activity", "char": "🤾‍♀️", "name": "woman_playing_handball", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🤾‍♂️", "name": "man_playing_handball", "keywords": ["sports"] },
-	{ "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] },
-	{ "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] },
-	{ "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] },
-	{ "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] },
-	{ "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] },
-	{ "category": "activity", "char": "🚣‍♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] },
-	{ "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] },
-	{ "category": "activity", "char": "🧗‍♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] },
-	{ "category": "activity", "char": "🧗‍♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] },
-	{ "category": "activity", "char": "🏊‍♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] },
-	{ "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] },
-	{ "category": "activity", "char": "🤽‍♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] },
-	{ "category": "activity", "char": "🤽‍♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] },
-	{ "category": "activity", "char": "🧘‍♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
-	{ "category": "activity", "char": "🧘‍♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
-	{ "category": "activity", "char": "🏄‍♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] },
-	{ "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] },
-	{ "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] },
-	{ "category": "activity", "char": "⛹️‍♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] },
-	{ "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] },
-	{ "category": "activity", "char": "🏋️‍♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] },
-	{ "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] },
-	{ "category": "activity", "char": "🚴‍♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] },
-	{ "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] },
-	{ "category": "activity", "char": "🚵‍♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] },
-	{ "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] },
-	{ "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] },
-	{ "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] },
-	{ "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] },
-	{ "category": "activity", "char": "🪢", "name": "knot", "keywords": [] },
-	{ "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] },
-	{ "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] },
-	{ "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] },
-	{ "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] },
-	{ "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] },
-	{ "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] },
-	{ "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] },
-	{ "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] },
-	{ "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] },
-	{ "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] },
-	{ "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] },
-	{ "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] },
-	{ "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] },
-	{ "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] },
-	{ "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] },
-	{ "category": "activity", "char": "🤹‍♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
-	{ "category": "activity", "char": "🤹‍♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
-	{ "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] },
-	{ "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] },
-	{ "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] },
-	{ "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] },
-	{ "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] },
-	{ "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] },
-	{ "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] },
-	{ "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] },
-	{ "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] },
-	{ "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] },
-	{ "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] },
-	{ "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] },
-	{ "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] },
-	{ "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] },
-	{ "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] },
-	{ "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] },
-	{ "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] },
-	{ "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] },
-	{ "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] },
-	{ "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] },
-	{ "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] },
-	{ "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] },
-	{ "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] },
-	{ "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] },
-	{ "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] },
-	{ "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] },
-	{ "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] },
-	{ "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] },
-	{ "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] },
-	{ "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] },
-	{ "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] },
-	{ "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] },
-	{ "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] },
-	{ "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] },
-	{ "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] },
-	{ "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] },
-	{ "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] },
-	{ "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] },
-	{ "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] },
-	{ "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] },
-	{ "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] },
-	{ "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] },
-	{ "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] },
-	{ "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] },
-	{ "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] },
-	{ "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] },
-	{ "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] },
-	{ "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] },
-	{ "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] },
-	{ "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] },
-	{ "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] },
-	{ "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] },
-	{ "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] },
-	{ "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] },
-	{ "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] },
-	{ "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] },
-	{ "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] },
-	{ "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] },
-	{ "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] },
-	{ "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] },
-	{ "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] },
-	{ "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] },
-	{ "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] },
-	{ "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] },
-	{ "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] },
-	{ "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] },
-	{ "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] },
-	{ "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] },
-	{ "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] },
-	{ "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] },
-	{ "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] },
-	{ "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] },
-	{ "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] },
-	{ "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] },
-	{ "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] },
-	{ "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] },
-	{ "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] },
-	{ "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] },
-	{ "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] },
-	{ "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] },
-	{ "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] },
-	{ "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] },
-	{ "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] },
-	{ "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] },
-	{ "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] },
-	{ "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] },
-	{ "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] },
-	{ "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] },
-	{ "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] },
-	{ "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] },
-	{ "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] },
-	{ "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] },
-	{ "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] },
-	{ "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] },
-	{ "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] },
-	{ "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] },
-	{ "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] },
-	{ "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] },
-	{ "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] },
-	{ "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] },
-	{ "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] },
-	{ "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] },
-	{ "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] },
-	{ "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] },
-	{ "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] },
-	{ "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] },
-	{ "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] },
-	{ "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] },
-	{ "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] },
-	{ "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] },
-	{ "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] },
-	{ "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] },
-	{ "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] },
-	{ "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] },
-	{ "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] },
-	{ "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] },
-	{ "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] },
-	{ "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] },
-	{ "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] },
-	{ "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] },
-	{ "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] },
-	{ "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] },
-	{ "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] },
-	{ "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] },
-	{ "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] },
-	{ "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] },
-	{ "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] },
-	{ "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] },
-	{ "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] },
-	{ "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] },
-	{ "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] },
-	{ "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] },
-	{ "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] },
-	{ "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] },
-	{ "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] },
-	{ "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] },
-	{ "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] },
-	{ "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] },
-	{ "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] },
-	{ "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] },
-	{ "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] },
-	{ "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] },
-	{ "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] },
-	{ "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] },
-	{ "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] },
-	{ "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] },
-	{ "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] },
-	{ "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] },
-	{ "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] },
-	{ "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] },
-	{ "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] },
-	{ "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] },
-	{ "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] },
-	{ "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] },
-	{ "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] },
-	{ "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] },
-	{ "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] },
-	{ "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] },
-	{ "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] },
-	{ "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] },
-	{ "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] },
-	{ "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] },
-	{ "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] },
-	{ "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] },
-	{ "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] },
-	{ "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] },
-	{ "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] },
-	{ "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] },
-	{ "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] },
-	{ "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] },
-	{ "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] },
-	{ "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] },
-	{ "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] },
-	{ "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] },
-	{ "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] },
-	{ "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] },
-	{ "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] },
-	{ "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] },
-	{ "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] },
-	{ "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] },
-	{ "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] },
-	{ "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] },
-	{ "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] },
-	{ "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] },
-	{ "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] },
-	{ "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] },
-	{ "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] },
-	{ "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] },
-	{ "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] },
-	{ "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] },
-	{ "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] },
-	{ "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] },
-	{ "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] },
-	{ "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] },
-	{ "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] },
-	{ "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] },
-	{ "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] },
-	{ "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] },
-	{ "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] },
-	{ "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] },
-	{ "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] },
-	{ "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] },
-	{ "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] },
-	{ "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] },
-	{ "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] },
-	{ "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] },
-	{ "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] },
-	{ "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] },
-	{ "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] },
-	{ "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] },
-	{ "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] },
-	{ "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] },
-	{ "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] },
-	{ "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] },
-	{ "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] },
-	{ "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] },
-	{ "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] },
-	{ "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] },
-	{ "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] },
-	{ "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] },
-	{ "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] },
-	{ "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] },
-	{ "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] },
-	{ "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] },
-	{ "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] },
-	{ "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] },
-	{ "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] },
-	{ "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] },
-	{ "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] },
-	{ "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] },
-	{ "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] },
-	{ "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] },
-	{ "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] },
-	{ "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] },
-	{ "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] },
-	{ "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] },
-	{ "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] },
-	{ "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] },
-	{ "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] },
-	{ "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] },
-	{ "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] },
-	{ "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] },
-	{ "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] },
-	{ "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
-	{ "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
-	{ "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
-	{ "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] },
-	{ "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] },
-	{ "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] },
-	{ "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] },
-	{ "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] },
-	{ "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] },
-	{ "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] },
-	{ "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] },
-	{ "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] },
-	{ "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] },
-	{ "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] },
-	{ "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] },
-	{ "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] },
-	{ "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] },
-	{ "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] },
-	{ "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] },
-	{ "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] },
-	{ "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] },
-	{ "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] },
-	{ "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] },
-	{ "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] },
-	{ "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] },
-	{ "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] },
-	{ "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] },
-	{ "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] },
-	{ "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] },
-	{ "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] },
-	{ "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] },
-	{ "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] },
-	{ "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] },
-	{ "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] },
-	{ "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] },
-	{ "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] },
-	{ "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] },
-	{ "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] },
-	{ "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] },
-	{ "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] },
-	{ "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] },
-	{ "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] },
-	{ "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] },
-	{ "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] },
-	{ "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] },
-	{ "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] },
-	{ "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] },
-	{ "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] },
-	{ "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] },
-	{ "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] },
-	{ "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] },
-	{ "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] },
-	{ "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] },
-	{ "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] },
-	{ "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] },
-	{ "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] },
-	{ "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] },
-	{ "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] },
-	{ "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] },
-	{ "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] },
-	{ "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] },
-	{ "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] },
-	{ "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] },
-	{ "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] },
-	{ "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] },
-	{ "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] },
-	{ "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] },
-	{ "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] },
-	{ "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] },
-	{ "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] },
-	{ "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] },
-	{ "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] },
-	{ "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] },
-	{ "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] },
-	{ "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] },
-	{ "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] },
-	{ "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] },
-	{ "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] },
-	{ "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] },
-	{ "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] },
-	{ "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] },
-	{ "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] },
-	{ "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] },
-	{ "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] },
-	{ "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] },
-	{ "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] },
-	{ "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] },
-	{ "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] },
-	{ "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] },
-	{ "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] },
-	{ "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] },
-	{ "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] },
-	{ "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] },
-	{ "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] },
-	{ "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] },
-	{ "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] },
-	{ "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] },
-	{ "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] },
-	{ "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] },
-	{ "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] },
-	{ "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] },
-	{ "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] },
-	{ "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] },
-	{ "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] },
-	{ "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] },
-	{ "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] },
-	{ "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] },
-	{ "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] },
-	{ "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] },
-	{ "category": "objects", "char": "🏳️‍🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] },
-	{ "category": "objects", "char": "🏳️‍⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] },
-	{ "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] },
-	{ "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] },
-	{ "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] },
-	{ "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] },
-	{ "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] },
-	{ "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] },
-	{ "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] },
-	{ "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] },
-	{ "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] },
-	{ "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] },
-	{ "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] },
-	{ "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] },
-	{ "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] },
-	{ "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] },
-	{ "category": "objects", "char": "🪧", "name": "placard", "keywords": [] },
-	{ "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] },
-	{ "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] },
-	{ "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] },
-	{ "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] },
-	{ "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] },
-	{ "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] },
-	{ "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] },
-	{ "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] },
-	{ "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] },
-	{ "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] },
-	{ "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] },
-	{ "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] },
-	{ "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] },
-	{ "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] },
-	{ "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] },
-	{ "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
-	{ "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
-	{ "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] },
-	{ "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] },
-	{ "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] },
-	{ "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] },
-	{ "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] },
-	{ "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] },
-	{ "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] },
-	{ "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
-	{ "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
-	{ "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
-	{ "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] },
-	{ "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
-	{ "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
-	{ "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
-	{ "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] },
-	{ "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] },
-	{ "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
-	{ "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] },
-	{ "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] },
-	{ "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] },
-	{ "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] },
-	{ "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] },
-	{ "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] },
-	{ "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] },
-	{ "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] },
-	{ "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] },
-	{ "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] },
-	{ "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] },
-	{ "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] },
-	{ "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] },
-	{ "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] },
-	{ "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] },
-	{ "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] },
-	{ "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] },
-	{ "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] },
-	{ "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] },
-	{ "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] },
-	{ "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] },
-	{ "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] },
-	{ "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] },
-	{ "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] },
-	{ "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] },
-	{ "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] },
-	{ "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] },
-	{ "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] },
-	{ "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] },
-	{ "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] },
-	{ "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] },
-	{ "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] },
-	{ "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] },
-	{ "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] },
-	{ "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] },
-	{ "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] },
-	{ "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] },
-	{ "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] },
-	{ "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] },
-	{ "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] },
-	{ "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] },
-	{ "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] },
-	{ "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] },
-	{ "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] },
-	{ "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] },
-	{ "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] },
-	{ "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] },
-	{ "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] },
-	{ "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] },
-	{ "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] },
-	{ "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] },
-	{ "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] },
-	{ "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] },
-	{ "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] },
-	{ "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] },
-	{ "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] },
-	{ "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] },
-	{ "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] },
-	{ "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] },
-	{ "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] },
-	{ "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] },
-	{ "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] },
-	{ "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] },
-	{ "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] },
-	{ "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] },
-	{ "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] },
-	{ "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] },
-	{ "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] },
-	{ "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] },
-	{ "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] },
-	{ "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] },
-	{ "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] },
-	{ "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] },
-	{ "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] },
-	{ "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] },
-	{ "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] },
-	{ "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] },
-	{ "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] },
-	{ "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] },
-	{ "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
-	{ "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] },
-	{ "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] },
-	{ "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] },
-	{ "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] },
-	{ "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] },
-	{ "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] },
-	{ "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] },
-	{ "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] },
-	{ "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] },
-	{ "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] },
-	{ "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] },
-	{ "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] },
-	{ "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] },
-	{ "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] },
-	{ "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] },
-	{ "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] },
-	{ "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] },
-	{ "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] },
-	{ "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] },
-	{ "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] },
-	{ "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] },
-	{ "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] },
-	{ "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] },
-	{ "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] },
-	{ "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] },
-	{ "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] },
-	{ "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] },
-	{ "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] },
-	{ "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] },
-	{ "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] },
-	{ "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] },
-	{ "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] },
-	{ "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] },
-	{ "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] },
-	{ "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] },
-	{ "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] },
-	{ "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] },
-	{ "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] },
-	{ "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] },
-	{ "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] },
-	{ "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] },
-	{ "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] },
-	{ "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] },
-	{ "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] },
-	{ "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] },
-	{ "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] },
-	{ "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] },
-	{ "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] },
-	{ "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] },
-	{ "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] },
-	{ "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] },
-	{ "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] },
-	{ "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] },
-	{ "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] },
-	{ "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] },
-	{ "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] },
-	{ "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] },
-	{ "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] },
-	{ "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] },
-	{ "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] },
-	{ "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] },
-	{ "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] },
-	{ "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] },
-	{ "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] },
-	{ "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] },
-	{ "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] },
-	{ "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] },
-	{ "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] },
-	{ "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] },
-	{ "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] },
-	{ "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] },
-	{ "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] },
-	{ "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] },
-	{ "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] },
-	{ "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] },
-	{ "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] },
-	{ "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] },
-	{ "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] },
-	{ "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] },
-	{ "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] },
-	{ "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] },
-	{ "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] },
-	{ "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] },
-	{ "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] },
-	{ "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] },
-	{ "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] },
-	{ "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] },
-	{ "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] },
-	{ "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] },
-	{ "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] },
-	{ "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] },
-	{ "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
-	{ "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
-	{ "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
-	{ "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
-	{ "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] },
-	{ "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] },
-	{ "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] },
-	{ "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] },
-	{ "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] },
-	{ "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] },
-	{ "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] },
-	{ "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] },
-	{ "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] },
-	{ "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] },
-	{ "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] },
-	{ "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] },
-	{ "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] },
-	{ "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] },
-	{ "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] },
-	{ "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] },
-	{ "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] },
-	{ "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] },
-	{ "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] },
-	{ "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] },
-	{ "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] },
-	{ "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] },
-	{ "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] },
-	{ "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] },
-	{ "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] },
-	{ "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] },
-	{ "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] },
-	{ "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] },
-	{ "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] },
-	{ "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] },
-	{ "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] },
-	{ "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] },
-	{ "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] },
-	{ "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] },
-	{ "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] },
-	{ "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] },
-	{ "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] },
-	{ "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] },
-	{ "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] },
-	{ "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] },
-	{ "category": "flags", "char": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "name": "england", "keywords": ["flag", "english"] },
-	{ "category": "flags", "char": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "name": "scotland", "keywords": ["flag", "scottish"] },
-	{ "category": "flags", "char": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", "name": "wales", "keywords": ["flag", "welsh"] },
-	{ "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] },
-	{ "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] },
-	{ "category": "flags", "char": "🏴‍☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] }
-]
-
diff --git a/packages/client/src/events.ts b/packages/client/src/events.ts
deleted file mode 100644
index dbbd908b8f..0000000000
--- a/packages/client/src/events.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { EventEmitter } from 'eventemitter3';
-
-// TODO: 型付け
-export const globalEvents = new EventEmitter();
diff --git a/packages/client/src/filters/bytes.ts b/packages/client/src/filters/bytes.ts
deleted file mode 100644
index c80f2f0ed2..0000000000
--- a/packages/client/src/filters/bytes.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export default (v, digits = 0) => {
-	if (v == null) return '?';
-	const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
-	if (v === 0) return '0';
-	const isMinus = v < 0;
-	if (isMinus) v = -v;
-	const i = Math.floor(Math.log(v) / Math.log(1024));
-	return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i];
-};
diff --git a/packages/client/src/filters/note.ts b/packages/client/src/filters/note.ts
deleted file mode 100644
index cd9b7d98d2..0000000000
--- a/packages/client/src/filters/note.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const notePage = note => {
-	return `/notes/${note.id}`;
-};
diff --git a/packages/client/src/filters/number.ts b/packages/client/src/filters/number.ts
deleted file mode 100644
index 880a848ca4..0000000000
--- a/packages/client/src/filters/number.ts
+++ /dev/null
@@ -1 +0,0 @@
-export default n => n == null ? 'N/A' : n.toLocaleString();
diff --git a/packages/client/src/filters/user.ts b/packages/client/src/filters/user.ts
deleted file mode 100644
index ff2f7e2dae..0000000000
--- a/packages/client/src/filters/user.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import * as misskey from 'misskey-js';
-import * as Acct from 'misskey-js/built/acct';
-import { url } from '@/config';
-
-export const acct = (user: misskey.Acct) => {
-	return Acct.toString(user);
-};
-
-export const userName = (user: misskey.entities.User) => {
-	return user.name || user.username;
-};
-
-export const userPage = (user: misskey.Acct, path?, absolute = false) => {
-	return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
-};
diff --git a/packages/client/src/i18n.ts b/packages/client/src/i18n.ts
deleted file mode 100644
index 31e066960d..0000000000
--- a/packages/client/src/i18n.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { markRaw } from 'vue';
-import { locale } from '@/config';
-import { I18n } from '@/scripts/i18n';
-
-export const i18n = markRaw(new I18n(locale));
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
deleted file mode 100644
index 508d3262b3..0000000000
--- a/packages/client/src/init.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * Client entry point
- */
-// https://vitejs.dev/config/build-options.html#build-modulepreload
-import 'vite/modulepreload-polyfill';
-
-import '@/style.scss';
-
-//#region account indexedDB migration
-import { set } from '@/scripts/idb-proxy';
-
-if (localStorage.getItem('accounts') != null) {
-	set('accounts', JSON.parse(localStorage.getItem('accounts')));
-	localStorage.removeItem('accounts');
-}
-//#endregion
-
-import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
-import { compareVersions } from 'compare-versions';
-import JSON5 from 'json5';
-
-import widgets from '@/widgets';
-import directives from '@/directives';
-import components from '@/components';
-import { version, ui, lang, host } from '@/config';
-import { applyTheme } from '@/scripts/theme';
-import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { i18n } from '@/i18n';
-import { confirm, alert, post, popup, toast } from '@/os';
-import { stream } from '@/stream';
-import * as sound from '@/scripts/sound';
-import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
-import { defaultStore, ColdDeviceStorage } from '@/store';
-import { fetchInstance, instance } from '@/instance';
-import { makeHotkey } from '@/scripts/hotkey';
-import { search } from '@/scripts/search';
-import { deviceKind } from '@/scripts/device-kind';
-import { initializeSw } from '@/scripts/initialize-sw';
-import { reloadChannel } from '@/scripts/unison-reload';
-import { reactionPicker } from '@/scripts/reaction-picker';
-import { getUrlWithoutLoginId } from '@/scripts/login-id';
-import { getAccountFromId } from '@/scripts/get-account-from-id';
-
-(async () => {
-	console.info(`Misskey v${version}`);
-
-	if (_DEV_) {
-		console.warn('Development mode!!!');
-
-		console.info(`vue ${vueVersion}`);
-
-		(window as any).$i = $i;
-		(window as any).$store = defaultStore;
-
-		window.addEventListener('error', event => {
-			console.error(event);
-			/*
-			alert({
-				type: 'error',
-				title: 'DEV: Unhandled error',
-				text: event.message
-			});
-			*/
-		});
-
-		window.addEventListener('unhandledrejection', event => {
-			console.error(event);
-			/*
-			alert({
-				type: 'error',
-				title: 'DEV: Unhandled promise rejection',
-				text: event.reason
-			});
-			*/
-		});
-	}
-
-	// タッチデバイスでCSSの:hoverを機能させる
-	document.addEventListener('touchend', () => {}, { passive: true });
-
-	// 一斉リロード
-	reloadChannel.addEventListener('message', path => {
-		if (path !== null) location.href = path;
-		else location.reload();
-	});
-
-	// If mobile, insert the viewport meta tag
-	if (['smartphone', 'tablet'].includes(deviceKind)) {
-		const viewport = document.getElementsByName('viewport').item(0);
-		viewport.setAttribute('content',
-			`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
-	}
-
-	//#region Set lang attr
-	const html = document.documentElement;
-	html.setAttribute('lang', lang);
-	//#endregion
-
-	//#region loginId
-	const params = new URLSearchParams(location.search);
-	const loginId = params.get('loginId');
-
-	if (loginId) {
-		const target = getUrlWithoutLoginId(location.href);
-
-		if (!$i || $i.id !== loginId) {
-			const account = await getAccountFromId(loginId);
-			if (account) {
-				await login(account.token, target);
-			}
-		}
-
-		history.replaceState({ misskey: 'loginId' }, '', target);
-	}
-
-	//#endregion
-
-	//#region Fetch user
-	if ($i && $i.token) {
-		if (_DEV_) {
-			console.log('account cache found. refreshing...');
-		}
-
-		refreshAccount();
-	} else {
-		if (_DEV_) {
-			console.log('no account cache found.');
-		}
-
-		// 連携ログインの場合用にCookieを参照する
-		const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1];
-
-		if (i != null && i !== 'null') {
-			if (_DEV_) {
-				console.log('signing...');
-			}
-
-			try {
-				document.body.innerHTML = '<div>Please wait...</div>';
-				await login(i);
-			} catch (err) {
-				// Render the error screen
-				// TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな)
-				document.body.innerHTML = '<div id="err">Oops!</div>';
-			}
-		} else {
-			if (_DEV_) {
-				console.log('not signed in');
-			}
-		}
-	}
-	//#endregion
-
-	const fetchInstanceMetaPromise = fetchInstance();
-
-	fetchInstanceMetaPromise.then(() => {
-		localStorage.setItem('v', instance.version);
-
-		// Init service worker
-		initializeSw();
-	});
-
-	const app = createApp(
-		window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
-		!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
-		ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
-		ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
-		defineAsyncComponent(() => import('@/ui/universal.vue')),
-	);
-
-	if (_DEV_) {
-		app.config.performance = true;
-	}
-
-	app.config.globalProperties = {
-		$i,
-		$store: defaultStore,
-		$instance: instance,
-		$t: i18n.t,
-		$ts: i18n.ts,
-	};
-
-	widgets(app);
-	directives(app);
-	components(app);
-
-	const splash = document.getElementById('splash');
-	// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
-	if (splash) splash.addEventListener('transitionend', () => {
-		splash.remove();
-	});
-
-	// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
-	// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する
-	const rootEl = (() => {
-		const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
-
-		const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID);
-
-		if (currentEl) {
-			console.warn('multiple import detected');
-			return currentEl;
-		}
-
-		const rootEl = document.createElement('div');
-		rootEl.id = MISSKEY_MOUNT_DIV_ID;
-		document.body.appendChild(rootEl);
-		return rootEl;
-	})();
-
-	app.mount(rootEl);
-
-	// boot.jsのやつを解除
-	window.onerror = null;
-	window.onunhandledrejection = null;
-
-	reactionPicker.init();
-
-	if (splash) {
-		splash.style.opacity = '0';
-		splash.style.pointerEvents = 'none';
-	}
-
-	// クライアントが更新されたか？
-	const lastVersion = localStorage.getItem('lastVersion');
-	if (lastVersion !== version) {
-		localStorage.setItem('lastVersion', version);
-
-		// テーマリビルドするため
-		localStorage.removeItem('theme');
-
-		try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
-			if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
-				// ログインしてる場合だけ
-				if ($i) {
-					popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
-				}
-			}
-		} catch (err) {
-		}
-	}
-
-	// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
-	watch(defaultStore.reactiveState.darkMode, (darkMode) => {
-		applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
-	}, { immediate: localStorage.theme == null });
-
-	const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
-	const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
-
-	watch(darkTheme, (theme) => {
-		if (defaultStore.state.darkMode) {
-			applyTheme(theme);
-		}
-	});
-
-	watch(lightTheme, (theme) => {
-		if (!defaultStore.state.darkMode) {
-			applyTheme(theme);
-		}
-	});
-
-	//#region Sync dark mode
-	if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
-		defaultStore.set('darkMode', isDeviceDarkmode());
-	}
-
-	window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
-		if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
-			defaultStore.set('darkMode', mql.matches);
-		}
-	});
-	//#endregion
-
-	fetchInstanceMetaPromise.then(() => {
-		if (defaultStore.state.themeInitial) {
-			if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
-			if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
-			defaultStore.set('themeInitial', false);
-		}
-	});
-
-	watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
-		document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
-	}, { immediate: true });
-
-	watch(defaultStore.reactiveState.useBlurEffect, v => {
-		if (v) {
-			document.documentElement.style.removeProperty('--blur');
-		} else {
-			document.documentElement.style.setProperty('--blur', 'none');
-		}
-	}, { immediate: true });
-
-	let reloadDialogShowing = false;
-	stream.on('_disconnected_', async () => {
-		if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
-			location.reload();
-		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
-			if (reloadDialogShowing) return;
-			reloadDialogShowing = true;
-			const { canceled } = await confirm({
-				type: 'warning',
-				title: i18n.ts.disconnectedFromServer,
-				text: i18n.ts.reloadConfirm,
-			});
-			reloadDialogShowing = false;
-			if (!canceled) {
-				location.reload();
-			}
-		}
-	});
-
-	stream.on('emojiAdded', emojiData => {
-		// TODO
-		//store.commit('instance/set', );
-	});
-
-	for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
-		import('./plugin').then(({ install }) => {
-			install(plugin);
-		});
-	}
-
-	const hotkeys = {
-		'd': (): void => {
-			defaultStore.set('darkMode', !defaultStore.state.darkMode);
-		},
-		's': search,
-	};
-
-	if ($i) {
-		// only add post shortcuts if logged in
-		hotkeys['p|n'] = post;
-
-		if ($i.isDeleted) {
-			alert({
-				type: 'warning',
-				text: i18n.ts.accountDeletionInProgress,
-			});
-		}
-
-		const lastUsed = localStorage.getItem('lastUsed');
-		if (lastUsed) {
-			const lastUsedDate = parseInt(lastUsed, 10);
-			// 二時間以上前なら
-			if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
-				toast(i18n.t('welcomeBackWithName', {
-					name: $i.name || $i.username,
-				}));
-			}
-		}
-		localStorage.setItem('lastUsed', Date.now().toString());
-
-		if ('Notification' in window) {
-			// 許可を得ていなかったらリクエスト
-			if (Notification.permission === 'default') {
-				Notification.requestPermission();
-			}
-		}
-
-		const main = markRaw(stream.useChannel('main', null, 'System'));
-
-		// 自分の情報が更新されたとき
-		main.on('meUpdated', i => {
-			updateAccount(i);
-		});
-
-		main.on('readAllNotifications', () => {
-			updateAccount({ hasUnreadNotification: false });
-		});
-
-		main.on('unreadNotification', () => {
-			updateAccount({ hasUnreadNotification: true });
-		});
-
-		main.on('unreadMention', () => {
-			updateAccount({ hasUnreadMentions: true });
-		});
-
-		main.on('readAllUnreadMentions', () => {
-			updateAccount({ hasUnreadMentions: false });
-		});
-
-		main.on('unreadSpecifiedNote', () => {
-			updateAccount({ hasUnreadSpecifiedNotes: true });
-		});
-
-		main.on('readAllUnreadSpecifiedNotes', () => {
-			updateAccount({ hasUnreadSpecifiedNotes: false });
-		});
-
-		main.on('readAllMessagingMessages', () => {
-			updateAccount({ hasUnreadMessagingMessage: false });
-		});
-
-		main.on('unreadMessagingMessage', () => {
-			updateAccount({ hasUnreadMessagingMessage: true });
-			sound.play('chatBg');
-		});
-
-		main.on('readAllAntennas', () => {
-			updateAccount({ hasUnreadAntenna: false });
-		});
-
-		main.on('unreadAntenna', () => {
-			updateAccount({ hasUnreadAntenna: true });
-			sound.play('antenna');
-		});
-
-		main.on('readAllAnnouncements', () => {
-			updateAccount({ hasUnreadAnnouncement: false });
-		});
-
-		main.on('readAllChannels', () => {
-			updateAccount({ hasUnreadChannel: false });
-		});
-
-		main.on('unreadChannel', () => {
-			updateAccount({ hasUnreadChannel: true });
-			sound.play('channel');
-		});
-
-		// トークンが再生成されたとき
-		// このままではMisskeyが利用できないので強制的にサインアウトさせる
-		main.on('myTokenRegenerated', () => {
-			signout();
-		});
-	}
-
-	// shortcut
-	document.addEventListener('keydown', makeHotkey(hotkeys));
-})();
diff --git a/packages/client/src/instance.ts b/packages/client/src/instance.ts
deleted file mode 100644
index 51464f32fb..0000000000
--- a/packages/client/src/instance.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { computed, reactive } from 'vue';
-import * as Misskey from 'misskey-js';
-import { api } from './os';
-
-// TODO: 他のタブと永続化されたstateを同期
-
-const instanceData = localStorage.getItem('instance');
-
-// TODO: instanceをリアクティブにするかは再考の余地あり
-
-export const instance: Misskey.entities.InstanceMetadata = reactive(instanceData ? JSON.parse(instanceData) : {
-	// TODO: set default values
-});
-
-export async function fetchInstance() {
-	const meta = await api('meta', {
-		detail: false,
-	});
-
-	for (const [k, v] of Object.entries(meta)) {
-		instance[k] = v;
-	}
-
-	localStorage.setItem('instance', JSON.stringify(instance));
-}
-
-export const emojiCategories = computed(() => {
-	if (instance.emojis == null) return [];
-	const categories = new Set();
-	for (const emoji of instance.emojis) {
-		categories.add(emoji.category);
-	}
-	return Array.from(categories);
-});
-
-export const emojiTags = computed(() => {
-	if (instance.emojis == null) return [];
-	const tags = new Set();
-	for (const emoji of instance.emojis) {
-		for (const tag of emoji.aliases) {
-			tags.add(tag);
-		}
-	}
-	return Array.from(tags);
-});
diff --git a/packages/client/src/navbar.ts b/packages/client/src/navbar.ts
deleted file mode 100644
index 31e6cd64a4..0000000000
--- a/packages/client/src/navbar.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { computed, ref, reactive } from 'vue';
-import { $i } from './account';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { ui } from '@/config';
-import { unisonReload } from '@/scripts/unison-reload';
-
-export const navbarItemDef = reactive({
-	notifications: {
-		title: 'notifications',
-		icon: 'ti ti-bell',
-		show: computed(() => $i != null),
-		indicated: computed(() => $i != null && $i.hasUnreadNotification),
-		to: '/my/notifications',
-	},
-	messaging: {
-		title: 'messaging',
-		icon: 'ti ti-messages',
-		show: computed(() => $i != null),
-		indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
-		to: '/my/messaging',
-	},
-	drive: {
-		title: 'drive',
-		icon: 'ti ti-cloud',
-		show: computed(() => $i != null),
-		to: '/my/drive',
-	},
-	followRequests: {
-		title: 'followRequests',
-		icon: 'ti ti-user-plus',
-		show: computed(() => $i != null && $i.isLocked),
-		indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
-		to: '/my/follow-requests',
-	},
-	explore: {
-		title: 'explore',
-		icon: 'ti ti-hash',
-		to: '/explore',
-	},
-	announcements: {
-		title: 'announcements',
-		icon: 'ti ti-speakerphone',
-		indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
-		to: '/announcements',
-	},
-	search: {
-		title: 'search',
-		icon: 'ti ti-search',
-		action: () => search(),
-	},
-	lists: {
-		title: 'lists',
-		icon: 'ti ti-list',
-		show: computed(() => $i != null),
-		to: '/my/lists',
-	},
-	/*
-	groups: {
-		title: 'groups',
-		icon: 'ti ti-users',
-		show: computed(() => $i != null),
-		to: '/my/groups',
-	},
-	*/
-	antennas: {
-		title: 'antennas',
-		icon: 'ti ti-antenna',
-		show: computed(() => $i != null),
-		to: '/my/antennas',
-	},
-	favorites: {
-		title: 'favorites',
-		icon: 'ti ti-star',
-		show: computed(() => $i != null),
-		to: '/my/favorites',
-	},
-	pages: {
-		title: 'pages',
-		icon: 'ti ti-news',
-		to: '/pages',
-	},
-	gallery: {
-		title: 'gallery',
-		icon: 'ti ti-icons',
-		to: '/gallery',
-	},
-	clips: {
-		title: 'clip',
-		icon: 'ti ti-paperclip',
-		show: computed(() => $i != null),
-		to: '/my/clips',
-	},
-	channels: {
-		title: 'channel',
-		icon: 'ti ti-device-tv',
-		to: '/channels',
-	},
-	ui: {
-		title: 'switchUi',
-		icon: 'ti ti-devices',
-		action: (ev) => {
-			os.popupMenu([{
-				text: i18n.ts.default,
-				active: ui === 'default' || ui === null,
-				action: () => {
-					localStorage.setItem('ui', 'default');
-					unisonReload();
-				},
-			}, {
-				text: i18n.ts.deck,
-				active: ui === 'deck',
-				action: () => {
-					localStorage.setItem('ui', 'deck');
-					unisonReload();
-				},
-			}, {
-				text: i18n.ts.classic,
-				active: ui === 'classic',
-				action: () => {
-					localStorage.setItem('ui', 'classic');
-					unisonReload();
-				},
-			}], ev.currentTarget ?? ev.target);
-		},
-	},
-	reload: {
-		title: 'reload',
-		icon: 'ti ti-refresh',
-		action: (ev) => {
-			location.reload();
-		},
-	},
-});
diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts
deleted file mode 100644
index 53e73a8d48..0000000000
--- a/packages/client/src/nirax.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-// NIRAX --- A lightweight router
-
-import { EventEmitter } from 'eventemitter3';
-import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue';
-import { pleaseLogin } from '@/scripts/please-login';
-import { safeURIDecode } from '@/scripts/safe-uri-decode';
-
-type RouteDef = {
-	path: string;
-	component: Component;
-	query?: Record<string, string>;
-	loginRequired?: boolean;
-	name?: string;
-	hash?: string;
-	globalCacheKey?: string;
-	children?: RouteDef[];
-};
-
-type ParsedPath = (string | {
-	name: string;
-	startsWith?: string;
-	wildcard?: boolean;
-	optional?: boolean;
-})[];
-
-export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; };
-
-function parsePath(path: string): ParsedPath {
-	const res = [] as ParsedPath;
-
-	path = path.substring(1);
-
-	for (const part of path.split('/')) {
-		if (part.includes(':')) {
-			const prefix = part.substring(0, part.indexOf(':'));
-			const placeholder = part.substring(part.indexOf(':') + 1);
-			const wildcard = placeholder.includes('(*)');
-			const optional = placeholder.endsWith('?');
-			res.push({
-				name: placeholder.replace('(*)', '').replace('?', ''),
-				startsWith: prefix !== '' ? prefix : undefined,
-				wildcard,
-				optional,
-			});
-		} else if (part.length !== 0) {
-			res.push(part);
-		}
-	}
-
-	return res;
-}
-
-export class Router extends EventEmitter<{
-	change: (ctx: {
-		beforePath: string;
-		path: string;
-		resolved: Resolved;
-		key: string;
-	}) => void;
-	replace: (ctx: {
-		path: string;
-		key: string;
-	}) => void;
-	push: (ctx: {
-		beforePath: string;
-		path: string;
-		route: RouteDef | null;
-		props: Map<string, string> | null;
-		key: string;
-	}) => void;
-	same: () => void;
-}> {
-	private routes: RouteDef[];
-	public current: Resolved;
-	public currentRef: ShallowRef<Resolved> = shallowRef();
-	public currentRoute: ShallowRef<RouteDef> = shallowRef();
-	private currentPath: string;
-	private currentKey = Date.now().toString();
-
-	public navHook: ((path: string, flag?: any) => boolean) | null = null;
-
-	constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
-		super();
-
-		this.routes = routes;
-		this.currentPath = currentPath;
-		this.navigate(currentPath, null, false);
-	}
-
-	public resolve(path: string): Resolved | null {
-		let queryString: string | null = null;
-		let hash: string | null = null;
-		if (path[0] === '/') path = path.substring(1);
-		if (path.includes('#')) {
-			hash = path.substring(path.indexOf('#') + 1);
-			path = path.substring(0, path.indexOf('#'));
-		}
-		if (path.includes('?')) {
-			queryString = path.substring(path.indexOf('?') + 1);
-			path = path.substring(0, path.indexOf('?'));
-		}
-
-		if (_DEV_) console.log('Routing: ', path, queryString);
-
-		function check(routes: RouteDef[], _parts: string[]): Resolved | null {
-			forEachRouteLoop:
-			for (const route of routes) {
-				let parts = [..._parts];
-				const props = new Map<string, string>();
-
-				pathMatchLoop:
-				for (const p of parsePath(route.path)) {
-					if (typeof p === 'string') {
-						if (p === parts[0]) {
-							parts.shift();
-						} else {
-							continue forEachRouteLoop;
-						}
-					} else {
-						if (parts[0] == null && !p.optional) {
-							continue forEachRouteLoop;
-						}
-						if (p.wildcard) {
-							if (parts.length !== 0) {
-								props.set(p.name, safeURIDecode(parts.join('/')));
-								parts = [];
-							}
-							break pathMatchLoop;
-						} else {
-							if (p.startsWith) {
-								if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
-
-								props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
-								parts.shift();
-							} else {
-								if (parts[0]) {
-									props.set(p.name, safeURIDecode(parts[0]));
-								}
-								parts.shift();
-							}
-						}
-					}
-				}
-
-				if (parts.length === 0) {
-					if (route.children) {
-						const child = check(route.children, []);
-						if (child) {
-							return {
-								route,
-								props,
-								child,
-							};
-						} else {
-							continue forEachRouteLoop;
-						}
-					}
-
-					if (route.hash != null && hash != null) {
-						props.set(route.hash, safeURIDecode(hash));
-					}
-	
-					if (route.query != null && queryString != null) {
-						const queryObject = [...new URLSearchParams(queryString).entries()]
-							.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
-	
-						for (const q in route.query) {
-							const as = route.query[q];
-							if (queryObject[q]) {
-								props.set(as, safeURIDecode(queryObject[q]));
-							}
-						}
-					}
-	
-					return {
-						route,
-						props,
-					};
-				} else {
-					if (route.children) {
-						const child = check(route.children, parts);
-						if (child) {
-							return {
-								route,
-								props,
-								child,
-							};
-						} else {
-							continue forEachRouteLoop;
-						}
-					} else {
-						continue forEachRouteLoop;
-					}
-				}
-			}
-
-			return null;
-		}
-
-		const _parts = path.split('/').filter(part => part.length !== 0);
-
-		return check(this.routes, _parts);
-	}
-
-	private navigate(path: string, key: string | null | undefined, emitChange = true) {
-		const beforePath = this.currentPath;
-		this.currentPath = path;
-
-		const res = this.resolve(this.currentPath);
-
-		if (res == null) {
-			throw new Error('no route found for: ' + path);
-		}
-
-		if (res.route.loginRequired) {
-			pleaseLogin('/');
-		}
-
-		const isSamePath = beforePath === path;
-		if (isSamePath && key == null) key = this.currentKey;
-		this.current = res;
-		this.currentRef.value = res;
-		this.currentRoute.value = res.route;
-		this.currentKey = res.route.globalCacheKey ?? key ?? path;
-
-		if (emitChange) {
-			this.emit('change', {
-				beforePath,
-				path,
-				resolved: res,
-				key: this.currentKey,
-			});
-		}
-
-		return res;
-	}
-
-	public getCurrentPath() {
-		return this.currentPath;
-	}
-
-	public getCurrentKey() {
-		return this.currentKey;
-	}
-
-	public push(path: string, flag?: any) {
-		const beforePath = this.currentPath;
-		if (path === beforePath) {
-			this.emit('same');
-			return;
-		}
-		if (this.navHook) {
-			const cancel = this.navHook(path, flag);
-			if (cancel) return;
-		}
-		const res = this.navigate(path, null);
-		this.emit('push', {
-			beforePath,
-			path,
-			route: res.route,
-			props: res.props,
-			key: this.currentKey,
-		});
-	}
-
-	public replace(path: string, key?: string | null, emitEvent = true) {
-		this.navigate(path, key);
-		if (emitEvent) {
-			this.emit('replace', {
-				path,
-				key: this.currentKey,
-			});
-		}
-	}
-}
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
deleted file mode 100644
index 7e57dcb4af..0000000000
--- a/packages/client/src/os.ts
+++ /dev/null
@@ -1,588 +0,0 @@
-// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
-
-import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
-import { EventEmitter } from 'eventemitter3';
-import insertTextAtCursor from 'insert-text-at-cursor';
-import * as Misskey from 'misskey-js';
-import { apiUrl, url } from '@/config';
-import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
-import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
-import { MenuItem } from '@/types/menu';
-import { $i } from '@/account';
-
-export const pendingApiRequestsCount = ref(0);
-
-const apiClient = new Misskey.api.APIClient({
-	origin: url,
-});
-
-export const api = ((endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) => {
-	pendingApiRequestsCount.value++;
-
-	const onFinally = () => {
-		pendingApiRequestsCount.value--;
-	};
-
-	const promise = new Promise((resolve, reject) => {
-		// Append a credential
-		if ($i) (data as any).i = $i.token;
-		if (token !== undefined) (data as any).i = token;
-
-		// Send request
-		window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
-			method: 'POST',
-			body: JSON.stringify(data),
-			credentials: 'omit',
-			cache: 'no-cache',
-			headers: {
-				'Content-Type': 'application/json',
-			},
-		}).then(async (res) => {
-			const body = res.status === 204 ? null : await res.json();
-
-			if (res.status === 200) {
-				resolve(body);
-			} else if (res.status === 204) {
-				resolve();
-			} else {
-				reject(body.error);
-			}
-		}).catch(reject);
-	});
-
-	promise.then(onFinally, onFinally);
-
-	return promise;
-}) as typeof apiClient.request;
-
-export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => {
-	pendingApiRequestsCount.value++;
-
-	const onFinally = () => {
-		pendingApiRequestsCount.value--;
-	};
-
-	const query = new URLSearchParams(data);
-
-	const promise = new Promise((resolve, reject) => {
-		// Send request
-		window.fetch(`${apiUrl}/${endpoint}?${query}`, {
-			method: 'GET',
-			credentials: 'omit',
-			cache: 'default',
-		}).then(async (res) => {
-			const body = res.status === 204 ? null : await res.json();
-
-			if (res.status === 200) {
-				resolve(body);
-			} else if (res.status === 204) {
-				resolve();
-			} else {
-				reject(body.error);
-			}
-		}).catch(reject);
-	});
-
-	promise.then(onFinally, onFinally);
-
-	return promise;
-}) as typeof apiClient.request;
-
-export const apiWithDialog = ((
-	endpoint: string,
-	data: Record<string, any> = {},
-	token?: string | null | undefined,
-) => {
-	const promise = api(endpoint, data, token);
-	promiseDialog(promise, null, (err) => {
-		alert({
-			type: 'error',
-			text: err.message + '\n' + (err as any).id,
-		});
-	});
-
-	return promise;
-}) as typeof api;
-
-export function promiseDialog<T extends Promise<any>>(
-	promise: T,
-	onSuccess?: ((res: any) => void) | null,
-	onFailure?: ((err: Error) => void) | null,
-	text?: string,
-): T {
-	const showing = ref(true);
-	const success = ref(false);
-
-	promise.then(res => {
-		if (onSuccess) {
-			showing.value = false;
-			onSuccess(res);
-		} else {
-			success.value = true;
-			window.setTimeout(() => {
-				showing.value = false;
-			}, 1000);
-		}
-	}).catch(err => {
-		showing.value = false;
-		if (onFailure) {
-			onFailure(err);
-		} else {
-			alert({
-				type: 'error',
-				text: err,
-			});
-		}
-	});
-
-	// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
-	popup(MkWaitingDialog, {
-		success: success,
-		showing: showing,
-		text: text,
-	}, {}, 'closed');
-
-	return promise;
-}
-
-let popupIdCount = 0;
-export const popups = ref([]) as Ref<{
-	id: any;
-	component: any;
-	props: Record<string, any>;
-}[]>;
-
-const zIndexes = {
-	low: 1000000,
-	middle: 2000000,
-	high: 3000000,
-};
-export function claimZIndex(priority: 'low' | 'middle' | 'high' = 'low'): number {
-	zIndexes[priority] += 100;
-	return zIndexes[priority];
-}
-
-export async function popup(component: Component, props: Record<string, any>, events = {}, disposeEvent?: string) {
-	markRaw(component);
-
-	const id = ++popupIdCount;
-	const dispose = () => {
-		// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ？
-		window.setTimeout(() => {
-			popups.value = popups.value.filter(popup => popup.id !== id);
-		}, 0);
-	};
-	const state = {
-		component,
-		props,
-		events: disposeEvent ? {
-			...events,
-			[disposeEvent]: dispose,
-		} : events,
-		id,
-	};
-
-	popups.value.push(state);
-
-	return {
-		dispose,
-	};
-}
-
-export function pageWindow(path: string) {
-	popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), {
-		initialPath: path,
-	}, {}, 'closed');
-}
-
-export function modalPageWindow(path: string) {
-	popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), {
-		initialPath: path,
-	}, {}, 'closed');
-}
-
-export function toast(message: string) {
-	popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), {
-		message,
-	}, {}, 'closed');
-}
-
-export function alert(props: {
-	type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
-	title?: string | null;
-	text?: string | null;
-}): Promise<void> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, {
-			done: result => {
-				resolve();
-			},
-		}, 'closed');
-	});
-}
-
-export function confirm(props: {
-	type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
-	title?: string | null;
-	text?: string | null;
-}): Promise<{ canceled: boolean }> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
-			...props,
-			showCancelButton: true,
-		}, {
-			done: result => {
-				resolve(result ? result : { canceled: true });
-			},
-		}, 'closed');
-	});
-}
-
-export function inputText(props: {
-	type?: 'text' | 'email' | 'password' | 'url';
-	title?: string | null;
-	text?: string | null;
-	placeholder?: string | null;
-	default?: string | null;
-}): Promise<{ canceled: true; result: undefined; } | {
-	canceled: false; result: string;
-}> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
-			title: props.title,
-			text: props.text,
-			input: {
-				type: props.type,
-				placeholder: props.placeholder,
-				default: props.default,
-			},
-		}, {
-			done: result => {
-				resolve(result ? result : { canceled: true });
-			},
-		}, 'closed');
-	});
-}
-
-export function inputNumber(props: {
-	title?: string | null;
-	text?: string | null;
-	placeholder?: string | null;
-	default?: number | null;
-}): Promise<{ canceled: true; result: undefined; } | {
-	canceled: false; result: number;
-}> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
-			title: props.title,
-			text: props.text,
-			input: {
-				type: 'number',
-				placeholder: props.placeholder,
-				default: props.default,
-			},
-		}, {
-			done: result => {
-				resolve(result ? result : { canceled: true });
-			},
-		}, 'closed');
-	});
-}
-
-export function inputDate(props: {
-	title?: string | null;
-	text?: string | null;
-	placeholder?: string | null;
-	default?: Date | null;
-}): Promise<{ canceled: true; result: undefined; } | {
-	canceled: false; result: Date;
-}> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
-			title: props.title,
-			text: props.text,
-			input: {
-				type: 'date',
-				placeholder: props.placeholder,
-				default: props.default,
-			},
-		}, {
-			done: result => {
-				resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
-			},
-		}, 'closed');
-	});
-}
-
-export function select<C = any>(props: {
-	title?: string | null;
-	text?: string | null;
-	default?: string | null;
-} & ({
-	items: {
-		value: C;
-		text: string;
-	}[];
-} | {
-	groupedItems: {
-		label: string;
-		items: {
-			value: C;
-			text: string;
-		}[];
-	}[];
-})): Promise<{ canceled: true; result: undefined; } | {
-	canceled: false; result: C;
-}> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
-			title: props.title,
-			text: props.text,
-			select: {
-				items: props.items,
-				groupedItems: props.groupedItems,
-				default: props.default,
-			},
-		}, {
-			done: result => {
-				resolve(result ? result : { canceled: true });
-			},
-		}, 'closed');
-	});
-}
-
-export function success() {
-	return new Promise((resolve, reject) => {
-		const showing = ref(true);
-		window.setTimeout(() => {
-			showing.value = false;
-		}, 1000);
-		popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
-			success: true,
-			showing: showing,
-		}, {
-			done: () => resolve(),
-		}, 'closed');
-	});
-}
-
-export function waiting() {
-	return new Promise((resolve, reject) => {
-		const showing = ref(true);
-		popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
-			success: false,
-			showing: showing,
-		}, {
-			done: () => resolve(),
-		}, 'closed');
-	});
-}
-
-export function form(title, form) {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
-			done: result => {
-				resolve(result);
-			},
-		}, 'closed');
-	});
-}
-
-export async function selectUser() {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, {
-			ok: user => {
-				resolve(user);
-			},
-		}, 'closed');
-	});
-}
-
-export async function selectDriveFile(multiple: boolean) {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
-			type: 'file',
-			multiple,
-		}, {
-			done: files => {
-				if (files) {
-					resolve(multiple ? files : files[0]);
-				}
-			},
-		}, 'closed');
-	});
-}
-
-export async function selectDriveFolder(multiple: boolean) {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
-			type: 'folder',
-			multiple,
-		}, {
-			done: folders => {
-				if (folders) {
-					resolve(multiple ? folders : folders[0]);
-				}
-			},
-		}, 'closed');
-	});
-}
-
-export async function pickEmoji(src: HTMLElement | null, opts) {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
-			src,
-			...opts,
-		}, {
-			done: emoji => {
-				resolve(emoji);
-			},
-		}, 'closed');
-	});
-}
-
-export async function cropImage(image: Misskey.entities.DriveFile, options: {
-	aspectRatio: number;
-}): Promise<Misskey.entities.DriveFile> {
-	return new Promise((resolve, reject) => {
-		popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
-			file: image,
-			aspectRatio: options.aspectRatio,
-		}, {
-			ok: x => {
-				resolve(x);
-			},
-		}, 'closed');
-	});
-}
-
-type AwaitType<T> =
-	T extends Promise<infer U> ? U :
-	T extends (...args: any[]) => Promise<infer V> ? V :
-	T;
-let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
-let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
-export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) {
-	if (openingEmojiPicker) return;
-
-	activeTextarea = initialTextarea;
-
-	const textareas = document.querySelectorAll('textarea, input');
-	for (const textarea of Array.from(textareas)) {
-		textarea.addEventListener('focus', () => {
-			activeTextarea = textarea;
-		});
-	}
-
-	const observer = new MutationObserver(records => {
-		for (const record of records) {
-			for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
-				const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
-				for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
-					if (document.activeElement === textarea) activeTextarea = textarea;
-					textarea.addEventListener('focus', () => {
-						activeTextarea = textarea;
-					});
-				}
-			}
-		}
-	});
-
-	observer.observe(document.body, {
-		childList: true,
-		subtree: true,
-		attributes: false,
-		characterData: false,
-	});
-
-	openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), {
-		src,
-		...opts,
-	}, {
-		chosen: emoji => {
-			insertTextAtCursor(activeTextarea, emoji);
-		},
-		closed: () => {
-			openingEmojiPicker!.dispose();
-			openingEmojiPicker = null;
-			observer.disconnect();
-		},
-	});
-}
-
-export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
-	align?: string;
-	width?: number;
-	viaKeyboard?: boolean;
-}) {
-	return new Promise((resolve, reject) => {
-		let dispose;
-		popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), {
-			items,
-			src,
-			width: options?.width,
-			align: options?.align,
-			viaKeyboard: options?.viaKeyboard,
-		}, {
-			closed: () => {
-				resolve();
-				dispose();
-			},
-		}).then(res => {
-			dispose = res.dispose;
-		});
-	});
-}
-
-export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
-	ev.preventDefault();
-	return new Promise((resolve, reject) => {
-		let dispose;
-		popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), {
-			items,
-			ev,
-		}, {
-			closed: () => {
-				resolve();
-				dispose();
-			},
-		}).then(res => {
-			dispose = res.dispose;
-		});
-	});
-}
-
-export function post(props: Record<string, any> = {}) {
-	return new Promise((resolve, reject) => {
-		// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
-		// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
-		//       Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
-		//       複数のpost formを開いたときに場合によってはエラーになる
-		//       もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
-		let dispose;
-		popup(MkPostFormDialog, props, {
-			closed: () => {
-				resolve();
-				dispose();
-			},
-		}).then(res => {
-			dispose = res.dispose;
-		});
-	});
-}
-
-export const deckGlobalEvents = new EventEmitter();
-
-/*
-export function checkExistence(fileData: ArrayBuffer): Promise<any> {
-	return new Promise((resolve, reject) => {
-		const data = new FormData();
-		data.append('md5', getMD5(fileData));
-
-		os.api('drive/files/find-by-hash', {
-			md5: getMD5(fileData)
-		}).then(resp => {
-			resolve(resp.length > 0 ? resp[0] : null);
-		});
-	});
-}*/
diff --git a/packages/client/src/pages/_empty_.vue b/packages/client/src/pages/_empty_.vue
deleted file mode 100644
index 000b6decc9..0000000000
--- a/packages/client/src/pages/_empty_.vue
+++ /dev/null
@@ -1,7 +0,0 @@
-<template>
-<div></div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-</script>
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
deleted file mode 100644
index 232d525347..0000000000
--- a/packages/client/src/pages/_error_.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-<template>
-<MkLoading v-if="!loaded"/>
-<transition :name="$store.state.animation ? 'zoom' : ''" appear>
-	<div v-show="loaded" class="mjndxjch">
-		<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
-		<p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p>
-		<p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p>
-		<p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p>
-		<template v-else>
-			<p>{{ i18n.ts.newVersionOfClientAvailable }}</p>
-			<p>{{ i18n.ts.youShouldUpgradeClient }}</p>
-			<MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton>
-		</template>
-		<p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p>
-		<p v-if="error" class="error">ERROR: {{ error }}</p>
-	</div>
-</transition>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import MkButton from '@/components/MkButton.vue';
-import { version } from '@/config';
-import * as os from '@/os';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = withDefaults(defineProps<{
-	error?: Error;
-}>(), {
-});
-
-let loaded = $ref(false);
-let serverIsDead = $ref(false);
-let meta = $ref<misskey.entities.LiteInstanceMetadata | null>(null);
-
-os.api('meta', {
-	detail: false,
-}).then(res => {
-	loaded = true;
-	serverIsDead = false;
-	meta = res;
-	localStorage.setItem('v', res.version);
-}, () => {
-	loaded = true;
-	serverIsDead = true;
-});
-
-function reload() {
-	unisonReload();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.error,
-	icon: 'ti ti-alert-triangle',
-});
-</script>
-
-<style lang="scss" scoped>
-.mjndxjch {
-	padding: 32px;
-	text-align: center;
-
-	> p {
-		margin: 0 0 12px 0;
-	}
-
-	> .button {
-		margin: 8px auto;
-	}
-
-	> img {
-		vertical-align: bottom;
-		height: 128px;
-		margin-bottom: 24px;
-		border-radius: 16px;
-	}
-
-	> .error {
-		opacity: 0.7;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/_loading_.vue b/packages/client/src/pages/_loading_.vue
deleted file mode 100644
index 1dd2e46e10..0000000000
--- a/packages/client/src/pages/_loading_.vue
+++ /dev/null
@@ -1,6 +0,0 @@
-<template>
-<MkLoading/>
-</template>
-
-<script lang="ts" setup>
-</script>
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
deleted file mode 100644
index 3ec972bcda..0000000000
--- a/packages/client/src/pages/about-misskey.vue
+++ /dev/null
@@ -1,264 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<div style="overflow: clip;">
-		<MkSpacer :content-max="600" :margin-min="20">
-			<div class="_formRoot znqjceqz">
-				<div id="debug"></div>
-				<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
-					<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
-					<div class="misskey">Misskey</div>
-					<div class="version">v{{ version }}</div>
-					<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
-				</div>
-				<div class="_formBlock" style="text-align: center;">
-					{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
-				</div>
-				<div class="_formBlock" style="text-align: center;">
-					<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
-				</div>
-				<FormSection>
-					<div class="_formLinks">
-						<FormLink to="https://github.com/misskey-dev/misskey" external>
-							<template #icon><i class="ti ti-code"></i></template>
-							{{ i18n.ts._aboutMisskey.source }}
-							<template #suffix>GitHub</template>
-						</FormLink>
-						<FormLink to="https://crowdin.com/project/misskey" external>
-							<template #icon><i class="ti ti-language-hiragana"></i></template>
-							{{ i18n.ts._aboutMisskey.translation }}
-							<template #suffix>Crowdin</template>
-						</FormLink>
-						<FormLink to="https://www.patreon.com/syuilo" external>
-							<template #icon><i class="ti ti-pig-money"></i></template>
-							{{ i18n.ts._aboutMisskey.donate }}
-							<template #suffix>Patreon</template>
-						</FormLink>
-					</div>
-				</FormSection>
-				<FormSection>
-					<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
-					<div class="_formLinks">
-						<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
-						<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
-						<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
-						<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
-						<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
-						<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
-						<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
-						<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
-						<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
-					</div>
-					<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
-				</FormSection>
-				<FormSection>
-					<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
-					<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
-					<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
-				</FormSection>
-			</div>
-		</MkSpacer>
-	</div>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onBeforeUnmount } from 'vue';
-import { version } from '@/config';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkLink from '@/components/MkLink.vue';
-import { physics } from '@/scripts/physics';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const patrons = [
-	'まっちゃとーにゅ',
-	'mametsuko',
-	'noellabo',
-	'AureoleArk',
-	'Gargron',
-	'Nokotaro Takeda',
-	'Suji Yan',
-	'oi_yekssim',
-	'regtan',
-	'Hekovic',
-	'nenohi',
-	'Gitmo Life Services',
-	'naga_rus',
-	'Efertone',
-	'Melilot',
-	'motcha',
-	'nanami kan',
-	'sevvie Rose',
-	'Hayato Ishikawa',
-	'Puniko',
-	'skehmatics',
-	'Quinton Macejkovic',
-	'YUKIMOCHI',
-	'dansup',
-	'mewl hayabusa',
-	'Emilis',
-	'Fristi',
-	'makokunsan',
-	'chidori ninokura',
-	'Peter G.',
-	'見当かなみ',
-	'natalie',
-	'Maronu',
-	'Steffen K9',
-	'takimura',
-	'sikyosyounin',
-	'Nesakko',
-	'YuzuRyo61',
-	'blackskye',
-	'sheeta.s',
-	'osapon',
-	'public_yusuke',
-	'CG',
-	'吴浥',
-	't_w',
-	'Jerry',
-	'nafuchoco',
-	'Takumi Sugita',
-	'GLaTAN',
-	'mkatze',
-	'kabo2468y',
-	'mydarkstar',
-	'Roujo',
-	'DignifiedSilence',
-	'uroco @99',
-	'totokoro',
-	'うし',
-	'kiritan',
-	'weepjp',
-	'Liaizon Wakest',
-	'Duponin',
-	'Blue',
-	'Naoki Hirayama',
-	'wara',
-	'Wataru Manji (manji0)',
-	'みなしま',
-	'kanoy',
-	'xianon',
-	'Denshi',
-	'Osushimaru',
-	'にょんへら',
-	'おのだい',
-	'Leni',
-	'oss',
-	'Weeble',
-	'蝉暮せせせ',
-	'ThatOneCalculator',
-	'pixeldesu',
-];
-
-let easterEggReady = false;
-let easterEggEmojis = $ref([]);
-let easterEggEngine = $ref(null);
-const containerEl = $ref<HTMLElement>();
-
-function iconLoaded() {
-	const emojis = defaultStore.state.reactions;
-	const containerWidth = containerEl.offsetWidth;
-	for (let i = 0; i < 32; i++) {
-		easterEggEmojis.push({
-			id: i.toString(),
-			top: -(128 + (Math.random() * 256)),
-			left: (Math.random() * containerWidth),
-			emoji: emojis[Math.floor(Math.random() * emojis.length)],
-		});
-	}
-
-	nextTick(() => {
-		easterEggReady = true;
-	});
-}
-
-function gravity() {
-	if (!easterEggReady) return;
-	easterEggReady = false;
-	easterEggEngine = physics(containerEl);
-}
-
-function iLoveMisskey() {
-	os.post({
-		initialText: 'I $[jelly ❤] #Misskey',
-		instant: true,
-	});
-}
-
-onBeforeUnmount(() => {
-	if (easterEggEngine) {
-		easterEggEngine.stop();
-	}
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.aboutMisskey,
-	icon: null,
-});
-</script>
-
-<style lang="scss" scoped>
-.znqjceqz {
-	> .about {
-		position: relative;
-		text-align: center;
-		padding: 16px;
-		border-radius: var(--radius);
-
-		&.playing {
-			&, * {
-				user-select: none;
-			}
-
-			* {
-				will-change: transform;
-			}
-
-			> .emoji {
-				visibility: visible;
-			}
-		}
-
-		> .icon {
-			display: block;
-			width: 100px;
-			margin: 0 auto;
-			border-radius: 16px;
-		}
-
-		> .misskey {
-			margin: 0.75em auto 0 auto;
-			width: max-content;
-		}
-
-		> .version {
-			margin: 0 auto;
-			width: max-content;
-			opacity: 0.5;
-		}
-
-		> .emoji {
-			position: absolute;
-			top: 0;
-			left: 0;
-			visibility: hidden;
-
-			> .emoji {
-				pointer-events: none;
-				font-size: 24px;
-				width: 24px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/about.emojis.vue b/packages/client/src/pages/about.emojis.vue
deleted file mode 100644
index 53ce1e4b75..0000000000
--- a/packages/client/src/pages/about.emojis.vue
+++ /dev/null
@@ -1,134 +0,0 @@
-<template>
-<div class="driuhtrh">
-	<div class="query">
-		<MkInput v-model="q" class="" :placeholder="$ts.search">
-			<template #prefix><i class="ti ti-search"></i></template>
-		</MkInput>
-
-		<!-- たくさんあると邪魔
-		<div class="tags">
-			<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
-		</div>
-		-->
-	</div>
-
-	<MkFolder v-if="searchEmojis" class="emojis">
-		<template #header>{{ $ts.searchResult }}</template>
-		<div class="zuvgdzyt">
-			<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
-		</div>
-	</MkFolder>
-	
-	<MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis">
-		<template #header>{{ category || $ts.other }}</template>
-		<div class="zuvgdzyt">
-			<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
-		</div>
-	</MkFolder>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, computed } from 'vue';
-import XEmoji from './emojis.emoji.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkTab from '@/components/MkTab.vue';
-import * as os from '@/os';
-import { emojiCategories, emojiTags } from '@/instance';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		MkInput,
-		MkSelect,
-		MkFolder,
-		MkTab,
-		XEmoji,
-	},
-
-	data() {
-		return {
-			q: '',
-			customEmojiCategories: emojiCategories,
-			customEmojis: this.$instance.emojis,
-			tags: emojiTags,
-			selectedTags: new Set(),
-			searchEmojis: null,
-		};
-	},
-
-	watch: {
-		q() { this.search(); },
-		selectedTags: {
-			handler() {
-				this.search();
-			},
-			deep: true,
-		},
-	},
-
-	methods: {
-		search() {
-			if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
-				this.searchEmojis = null;
-				return;
-			}
-
-			if (this.selectedTags.size === 0) {
-				this.searchEmojis = this.customEmojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q));
-			} else {
-				this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.aliases.includes(t)));
-			}
-		},
-
-		toggleTag(tag) {
-			if (this.selectedTags.has(tag)) {
-				this.selectedTags.delete(tag);
-			} else {
-				this.selectedTags.add(tag);
-			}
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.driuhtrh {
-	background: var(--bg);
-
-	> .query {
-		background: var(--bg);
-		padding: 16px;
-
-		> .tags {
-			> .tag {
-				display: inline-block;
-				margin: 8px 8px 0 0;
-				padding: 4px 8px;
-				font-size: 0.9em;
-				background: var(--accentedBg);
-				border-radius: 5px;
-
-				&.active {
-					background: var(--accent);
-					color: var(--fgOnAccent);
-				}
-			}
-		}
-	}
-
-	> .emojis {
-		--x-padding: 0 16px;
-
-		.zuvgdzyt {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-			margin: 0 var(--margin) var(--margin) var(--margin);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/about.federation.vue b/packages/client/src/pages/about.federation.vue
deleted file mode 100644
index 6c92ab1264..0000000000
--- a/packages/client/src/pages/about.federation.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<template>
-<div class="taeiyria">
-	<div class="query">
-		<MkInput v-model="host" :debounce="true" class="">
-			<template #prefix><i class="ti ti-search"></i></template>
-			<template #label>{{ i18n.ts.host }}</template>
-		</MkInput>
-		<FormSplit style="margin-top: var(--margin);">
-			<MkSelect v-model="state">
-				<template #label>{{ i18n.ts.state }}</template>
-				<option value="all">{{ i18n.ts.all }}</option>
-				<option value="federating">{{ i18n.ts.federating }}</option>
-				<option value="subscribing">{{ i18n.ts.subscribing }}</option>
-				<option value="publishing">{{ i18n.ts.publishing }}</option>
-				<option value="suspended">{{ i18n.ts.suspended }}</option>
-				<option value="blocked">{{ i18n.ts.blocked }}</option>
-				<option value="notResponding">{{ i18n.ts.notResponding }}</option>
-			</MkSelect>
-			<MkSelect v-model="sort">
-				<template #label>{{ i18n.ts.sort }}</template>
-				<option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
-				<option value="+lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.descendingOrder }})</option>
-				<option value="-lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.ascendingOrder }})</option>
-			</MkSelect>
-		</FormSplit>
-	</div>
-
-	<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
-		<div class="dqokceoi">
-			<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
-				<MkInstanceCardMini :instance="instance"/>
-			</MkA>
-		</div>
-	</MkPagination>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
-import FormSplit from '@/components/form/split.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-let host = $ref('');
-let state = $ref('federating');
-let sort = $ref('+pubSub');
-const pagination = {
-	endpoint: 'federation/instances' as const,
-	limit: 10,
-	offsetMode: true,
-	params: computed(() => ({
-		sort: sort,
-		host: host !== '' ? host : null,
-		...(
-			state === 'federating' ? { federating: true } :
-			state === 'subscribing' ? { subscribing: true } :
-			state === 'publishing' ? { publishing: true } :
-			state === 'suspended' ? { suspended: true } :
-			state === 'blocked' ? { blocked: true } :
-			state === 'notResponding' ? { notResponding: true } :
-			{}),
-	})),
-};
-
-function getStatus(instance) {
-	if (instance.isSuspended) return 'Suspended';
-	if (instance.isBlocked) return 'Blocked';
-	if (instance.isNotResponding) return 'Error';
-	return 'Alive';
-}
-</script>
-
-<style lang="scss" scoped>
-.taeiyria {
-	> .query {
-		background: var(--bg);
-		margin-bottom: 16px;
-	}
-}
-
-.dqokceoi {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
-	grid-gap: 12px;
-
-	> .instance:hover {
-		text-decoration: none;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
deleted file mode 100644
index 0ed692c5c5..0000000000
--- a/packages/client/src/pages/about.vue
+++ /dev/null
@@ -1,166 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
-		<div class="_formRoot">
-			<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
-				<div class="content">
-					<img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/>
-					<div class="name">
-						<b>{{ $instance.name ?? host }}</b>
-					</div>
-				</div>
-			</div>
-
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.description }}</template>
-				<template #value><div v-html="$instance.description"></div></template>
-			</MkKeyValue>
-
-			<FormSection>
-				<MkKeyValue class="_formBlock" :copy="version">
-					<template #key>Misskey</template>
-					<template #value>{{ version }}</template>
-				</MkKeyValue>
-				<div class="_formBlock" v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })">
-				</div>
-				<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
-			</FormSection>
-
-			<FormSection>
-				<FormSplit>
-					<MkKeyValue class="_formBlock">
-						<template #key>{{ i18n.ts.administrator }}</template>
-						<template #value>{{ $instance.maintainerName }}</template>
-					</MkKeyValue>
-					<MkKeyValue class="_formBlock">
-						<template #key>{{ i18n.ts.contact }}</template>
-						<template #value>{{ $instance.maintainerEmail }}</template>
-					</MkKeyValue>
-				</FormSplit>
-				<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ i18n.ts.tos }}</FormLink>
-			</FormSection>
-
-			<FormSuspense :p="initStats">
-				<FormSection>
-					<template #label>{{ i18n.ts.statistics }}</template>
-					<FormSplit>
-						<MkKeyValue class="_formBlock">
-							<template #key>{{ i18n.ts.users }}</template>
-							<template #value>{{ number(stats.originalUsersCount) }}</template>
-						</MkKeyValue>
-						<MkKeyValue class="_formBlock">
-							<template #key>{{ i18n.ts.notes }}</template>
-							<template #value>{{ number(stats.originalNotesCount) }}</template>
-						</MkKeyValue>
-					</FormSplit>
-				</FormSection>
-			</FormSuspense>
-
-			<FormSection>
-				<template #label>Well-known resources</template>
-				<div class="_formLinks">
-					<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
-					<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
-					<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
-					<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
-					<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
-				</div>
-			</FormSection>
-		</div>
-	</MkSpacer>
-	<MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20">
-		<XEmojis/>
-	</MkSpacer>
-	<MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20">
-		<XFederation/>
-	</MkSpacer>
-	<MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20">
-		<MkInstanceStats :chart-limit="500" :detailed="true"/>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed } from 'vue';
-import XEmojis from './about.emojis.vue';
-import XFederation from './about.federation.vue';
-import { version, instanceName, host } from '@/config';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormSplit from '@/components/form/split.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkInstanceStats from '@/components/MkInstanceStats.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = withDefaults(defineProps<{
-	initialTab?: string;
-}>(), {
-	initialTab: 'overview',
-});
-
-let stats = $ref(null);
-let tab = $ref(props.initialTab);
-
-const initStats = () => os.api('stats', {
-}).then((res) => {
-	stats = res;
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => [{
-	key: 'overview',
-	title: i18n.ts.overview,
-}, {
-	key: 'emojis',
-	title: i18n.ts.customEmojis,
-	icon: 'ti ti-mood-happy',
-}, {
-	key: 'federation',
-	title: i18n.ts.federation,
-	icon: 'ti ti-whirl',
-}, {
-	key: 'charts',
-	title: i18n.ts.charts,
-	icon: 'ti ti-chart-line',
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.instanceInfo,
-	icon: 'ti ti-info-circle',
-})));
-</script>
-
-<style lang="scss" scoped>
-.fwhjspax {
-	text-align: center;
-	border-radius: 10px;
-	overflow: clip;
-	background-size: cover;
-	background-position: center center;
-
-	> .content {
-		overflow: hidden;
-
-		> .icon {
-			display: block;
-			margin: 16px auto 0 auto;
-			height: 64px;
-			border-radius: 8px;
-		}
-
-		> .name {
-			display: block;
-			padding: 16px;
-			color: #fff;
-			text-shadow: 0 0 8px #000;
-			background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue
deleted file mode 100644
index a11249e75d..0000000000
--- a/packages/client/src/pages/admin-file.vue
+++ /dev/null
@@ -1,160 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32">
-		<div v-if="tab === 'overview'" class="cxqhhsmd _formRoot">
-			<a class="_formBlock thumbnail" :href="file.url" target="_blank">
-				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-			</a>
-			<div class="_formBlock">
-				<MkKeyValue :copy="file.type" oneline style="margin: 1em 0;">
-					<template #key>MIME Type</template>
-					<template #value><span class="_monospace">{{ file.type }}</span></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>Size</template>
-					<template #value><span class="_monospace">{{ bytes(file.size) }}</span></template>
-				</MkKeyValue>
-				<MkKeyValue :copy="file.id" oneline style="margin: 1em 0;">
-					<template #key>ID</template>
-					<template #value><span class="_monospace">{{ file.id }}</span></template>
-				</MkKeyValue>
-				<MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;">
-					<template #key>MD5</template>
-					<template #value><span class="_monospace">{{ file.md5 }}</span></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.createdAt }}</template>
-					<template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template>
-				</MkKeyValue>
-			</div>
-			<MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`">
-				<MkUserCardMini :user="file.user"/>
-			</MkA>
-			<div class="_formBlock">
-				<MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch>
-			</div>
-
-			<div class="_formBlock">
-				<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
-			</div>
-		</div>
-		<div v-else-if="tab === 'ip' && info" class="_formRoot">
-			<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
-			<MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline>
-				<template #key>IP</template>
-				<template #value>{{ info.requestIp }}</template>
-			</MkKeyValue>
-			<FormSection v-if="info.requestHeaders">
-				<template #label>Headers</template>
-				<MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace">
-					<template #key>{{ k }}</template>
-					<template #value>{{ v }}</template>
-				</MkKeyValue>
-			</FormSection>
-		</div>
-		<div v-else-if="tab === 'raw'" class="_formRoot">
-			<MkObjectView v-if="info" tall :value="info">
-			</MkObjectView>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import MkObjectView from '@/components/MkObjectView.vue';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import FormSection from '@/components/form/section.vue';
-import MkUserCardMini from '@/components/MkUserCardMini.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { acct } from '@/filters/user';
-import { iAmAdmin, iAmModerator } from '@/account';
-
-let tab = $ref('overview');
-let file: any = $ref(null);
-let info: any = $ref(null);
-let isSensitive: boolean = $ref(false);
-
-const props = defineProps<{
-	fileId: string,
-}>();
-
-async function fetch() {
-	file = await os.api('drive/files/show', { fileId: props.fileId });
-	info = await os.api('admin/drive/show-file', { fileId: props.fileId });
-	isSensitive = file.isSensitive;
-}
-
-fetch();
-
-async function del() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: file.name }),
-	});
-	if (canceled) return;
-
-	os.apiWithDialog('drive/files/delete', {
-		fileId: file.id,
-	});
-}
-
-async function toggleIsSensitive(v) {
-	await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v });
-	isSensitive = v;
-}
-
-const headerActions = $computed(() => [{
-	text: i18n.ts.openInNewTab,
-	icon: 'ti ti-external-link',
-	handler: () => {
-		window.open(file.url, '_blank');
-	},
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'overview',
-	title: i18n.ts.overview,
-	icon: 'ti ti-info-circle',
-}, iAmModerator ? {
-	key: 'ip',
-	title: 'IP',
-	icon: 'ti ti-password',
-} : null, {
-	key: 'raw',
-	title: 'Raw data',
-	icon: 'ti ti-code',
-}]);
-
-definePageMetadata(computed(() => ({
-	title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
-	icon: 'ti ti-file',
-})));
-</script>
-
-<style lang="scss" scoped>
-.cxqhhsmd {
-	> .thumbnail {
-		display: block;
-
-		> .thumbnail {
-			height: 300px;
-			max-width: 100%;
-		}
-	}
-
-	> .user {
-		&:hover {
-			text-decoration: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
deleted file mode 100644
index bdb41b2d2c..0000000000
--- a/packages/client/src/pages/admin/_header_.vue
+++ /dev/null
@@ -1,292 +0,0 @@
-<template>
-<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
-	<template v-if="metadata">
-		<div class="titleContainer" @click="showTabsPopup">
-			<i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
-
-			<div class="title">
-				<div class="title">{{ metadata.title }}</div>
-			</div>
-		</div>
-		<div class="tabs">
-			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
-				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
-				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
-			</button>
-			<div ref="tabHighlightEl" class="highlight"></div>
-		</div>
-	</template>
-	<div class="buttons right">
-		<template v-if="actions">
-			<template v-for="action in actions">
-				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
-				<button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
-			</template>
-		</template>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
-import tinycolor from 'tinycolor2';
-import { popupMenu } from '@/os';
-import { url } from '@/config';
-import { scrollToTop } from '@/scripts/scroll';
-import MkButton from '@/components/MkButton.vue';
-import { i18n } from '@/i18n';
-import { globalEvents } from '@/events';
-import { injectPageMetadata } from '@/scripts/page-metadata';
-
-type Tab = {
-	key?: string | null;
-	title: string;
-	icon?: string;
-	iconOnly?: boolean;
-	onClick?: (ev: MouseEvent) => void;
-};
-
-const props = defineProps<{
-	tabs?: Tab[];
-	tab?: string;
-	actions?: {
-		text: string;
-		icon: string;
-		asFullButton?: boolean;
-		handler: (ev: MouseEvent) => void;
-	}[];
-	thin?: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:tab', key: string);
-}>();
-
-const metadata = injectPageMetadata();
-
-const el = ref<HTMLElement>(null);
-const tabRefs = {};
-const tabHighlightEl = $ref<HTMLElement | null>(null);
-const bg = ref(null);
-const height = ref(0);
-const hasTabs = computed(() => {
-	return props.tabs && props.tabs.length > 0;
-});
-
-const showTabsPopup = (ev: MouseEvent) => {
-	if (!hasTabs.value) return;
-	ev.preventDefault();
-	ev.stopPropagation();
-	const menu = props.tabs.map(tab => ({
-		text: tab.title,
-		icon: tab.icon,
-		active: tab.key != null && tab.key === props.tab,
-		action: (ev) => {
-			onTabClick(tab, ev);
-		},
-	}));
-	popupMenu(menu, ev.currentTarget ?? ev.target);
-};
-
-const preventDrag = (ev: TouchEvent) => {
-	ev.stopPropagation();
-};
-
-const onClick = () => {
-	scrollToTop(el.value, { behavior: 'smooth' });
-};
-
-function onTabMousedown(tab: Tab, ev: MouseEvent): void {
-	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
-	if (tab.key) {
-		emit('update:tab', tab.key);
-	}
-}
-
-function onTabClick(tab: Tab, ev: MouseEvent): void {
-	if (tab.onClick) {
-		ev.preventDefault();
-		ev.stopPropagation();
-		tab.onClick(ev);
-	}
-	if (tab.key) {
-		emit('update:tab', tab.key);
-	}
-}
-
-const calcBg = () => {
-	const rawBg = metadata?.bg || 'var(--bg)';
-	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
-	tinyBg.setAlpha(0.85);
-	bg.value = tinyBg.toRgbString();
-};
-
-onMounted(() => {
-	calcBg();
-	globalEvents.on('themeChanged', calcBg);
-
-	watch(() => [props.tab, props.tabs], () => {
-		nextTick(() => {
-			const tabEl = tabRefs[props.tab];
-			if (tabEl && tabHighlightEl) {
-				// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
-				// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
-				const parentRect = tabEl.parentElement.getBoundingClientRect();
-				const rect = tabEl.getBoundingClientRect();
-				tabHighlightEl.style.width = rect.width + 'px';
-				tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
-			}
-		});
-	}, {
-		immediate: true,
-	});
-});
-
-onUnmounted(() => {
-	globalEvents.off('themeChanged', calcBg);
-});
-</script>
-
-<style lang="scss" scoped>
-.fdidabkc {
-	--height: 60px;
-	display: flex;
-	width: 100%;
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-
-	> .buttons {
-		--margin: 8px;
-		display: flex;
-    align-items: center;
-		height: var(--height);
-		margin: 0 var(--margin);
-
-		&.right {
-			margin-left: auto;
-		}
-
-		&:empty {
-			width: var(--height);
-		}
-
-		> .button {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			height: calc(var(--height) - (var(--margin) * 2));
-			width: calc(var(--height) - (var(--margin) * 2));
-			box-sizing: border-box;
-			position: relative;
-			border-radius: 5px;
-
-			&:hover {
-				background: rgba(0, 0, 0, 0.05);
-			}
-
-			&.highlighted {
-				color: var(--accent);
-			}
-		}
-
-		> .fullButton {
-			& + .fullButton {
-				margin-left: 12px;
-			}
-		}
-	}
-
-	> .titleContainer {
-		display: flex;
-		align-items: center;
-		max-width: 400px;
-		overflow: auto;
-		white-space: nowrap;
-		text-align: left;
-		font-weight: bold;
-		flex-shrink: 0;
-		margin-left: 24px;
-
-		> .avatar {
-			$size: 32px;
-			display: inline-block;
-			width: $size;
-			height: $size;
-			vertical-align: bottom;
-			margin: 0 8px;
-			pointer-events: none;
-		}
-
-		> .icon {
-			margin-right: 8px;
-			width: 16px;
-			text-align: center;
-		}
-
-		> .title {
-			min-width: 0;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-			line-height: 1.1;
-
-			> .subtitle {
-				opacity: 0.6;
-				font-size: 0.8em;
-				font-weight: normal;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-
-				&.activeTab {
-					text-align: center;
-
-					> .chevron {
-						display: inline-block;
-						margin-left: 6px;
-					}
-				}
-			}
-		}
-	}
-
-	> .tabs {
-		position: relative;
-		margin-left: 16px;
-		font-size: 0.8em;
-		overflow: auto;
-		white-space: nowrap;
-
-		> .tab {
-			display: inline-block;
-			position: relative;
-			padding: 0 10px;
-			height: 100%;
-			font-weight: normal;
-			opacity: 0.7;
-
-			&:hover {
-				opacity: 1;
-			}
-
-			&.active {
-				opacity: 1;
-			}
-
-			> .icon + .title {
-				margin-left: 8px;
-			}
-		}
-
-		> .highlight {
-			position: absolute;
-			bottom: 0;
-			height: 3px;
-			background: var(--accent);
-			border-radius: 999px;
-			transition: all 0.2s ease;
-			pointer-events: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
deleted file mode 100644
index 973ec871ab..0000000000
--- a/packages/client/src/pages/admin/abuses.vue
+++ /dev/null
@@ -1,97 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="900">
-		<div class="lcixvhis">
-			<div class="_section reports">
-				<div class="_content">
-					<div class="inputs" style="display: flex;">
-						<MkSelect v-model="state" style="margin: 0; flex: 1;">
-							<template #label>{{ i18n.ts.state }}</template>
-							<option value="all">{{ i18n.ts.all }}</option>
-							<option value="unresolved">{{ i18n.ts.unresolved }}</option>
-							<option value="resolved">{{ i18n.ts.resolved }}</option>
-						</MkSelect>
-						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
-							<template #label>{{ i18n.ts.reporteeOrigin }}</template>
-							<option value="combined">{{ i18n.ts.all }}</option>
-							<option value="local">{{ i18n.ts.local }}</option>
-							<option value="remote">{{ i18n.ts.remote }}</option>
-						</MkSelect>
-						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
-							<template #label>{{ i18n.ts.reporterOrigin }}</template>
-							<option value="combined">{{ i18n.ts.all }}</option>
-							<option value="local">{{ i18n.ts.local }}</option>
-							<option value="remote">{{ i18n.ts.remote }}</option>
-						</MkSelect>
-					</div>
-					<!-- TODO
-			<div class="inputs" style="display: flex; padding-top: 1.2em;">
-				<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
-					<span>{{ i18n.ts.username }}</span>
-				</MkInput>
-				<MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'">
-					<span>{{ i18n.ts.host }}</span>
-				</MkInput>
-			</div>
-			-->
-
-					<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
-						<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
-					</MkPagination>
-				</div>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-
-import XHeader from './_header_.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import XAbuseReport from '@/components/MkAbuseReport.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let reports = $ref<InstanceType<typeof MkPagination>>();
-
-let state = $ref('unresolved');
-let reporterOrigin = $ref('combined');
-let targetUserOrigin = $ref('combined');
-let searchUsername = $ref('');
-let searchHost = $ref('');
-
-const pagination = {
-	endpoint: 'admin/abuse-user-reports' as const,
-	limit: 10,
-	params: computed(() => ({
-		state,
-		reporterOrigin,
-		targetUserOrigin,
-	})),
-};
-
-function resolved(reportId) {
-	reports.removeItem(item => item.id === reportId);
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.abuseReports,
-	icon: 'ti ti-exclamation-circle',
-});
-</script>
-
-<style lang="scss" scoped>
-.lcixvhis {
-	margin: var(--margin);
-}
-</style>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
deleted file mode 100644
index 2ec926c65c..0000000000
--- a/packages/client/src/pages/admin/ads.vue
+++ /dev/null
@@ -1,132 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="900">
-		<div class="uqshojas">
-			<div v-for="ad in ads" class="_panel _formRoot ad">
-				<MkAd v-if="ad.url" :specify="ad"/>
-				<MkInput v-model="ad.url" type="url" class="_formBlock">
-					<template #label>URL</template>
-				</MkInput>
-				<MkInput v-model="ad.imageUrl" class="_formBlock">
-					<template #label>{{ i18n.ts.imageUrl }}</template>
-				</MkInput>
-				<FormRadios v-model="ad.place" class="_formBlock">
-					<template #label>Form</template>
-					<option value="square">square</option>
-					<option value="horizontal">horizontal</option>
-					<option value="horizontal-big">horizontal-big</option>
-				</FormRadios>
-				<!--
-			<div style="margin: 32px 0;">
-				{{ i18n.ts.priority }}
-				<MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
-				<MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio>
-				<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
-			</div>
-			-->
-				<FormSplit>
-					<MkInput v-model="ad.ratio" type="number">
-						<template #label>{{ i18n.ts.ratio }}</template>
-					</MkInput>
-					<MkInput v-model="ad.expiresAt" type="date">
-						<template #label>{{ i18n.ts.expiration }}</template>
-					</MkInput>
-				</FormSplit>
-				<MkTextarea v-model="ad.memo" class="_formBlock">
-					<template #label>{{ i18n.ts.memo }}</template>
-				</MkTextarea>
-				<div class="buttons _formBlock">
-					<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-					<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
-				</div>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormSplit from '@/components/form/split.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let ads: any[] = $ref([]);
-
-os.api('admin/ad/list').then(adsResponse => {
-	ads = adsResponse;
-});
-
-function add() {
-	ads.unshift({
-		id: null,
-		memo: '',
-		place: 'square',
-		priority: 'middle',
-		ratio: 1,
-		url: '',
-		imageUrl: null,
-		expiresAt: null,
-	});
-}
-
-function remove(ad) {
-	os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: ad.url }),
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		ads = ads.filter(x => x !== ad);
-		os.apiWithDialog('admin/ad/delete', {
-			id: ad.id,
-		});
-	});
-}
-
-function save(ad) {
-	if (ad.id == null) {
-		os.apiWithDialog('admin/ad/create', {
-			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime(),
-		});
-	} else {
-		os.apiWithDialog('admin/ad/update', {
-			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime(),
-		});
-	}
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-plus',
-	text: i18n.ts.add,
-	handler: add,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.ads,
-	icon: 'ti ti-ad',
-});
-</script>
-
-<style lang="scss" scoped>
-.uqshojas {
-	> .ad {
-		padding: 32px;
-
-		&:not(:last-child) {
-			margin-bottom: var(--margin);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
deleted file mode 100644
index 607ad8aa02..0000000000
--- a/packages/client/src/pages/admin/announcements.vue
+++ /dev/null
@@ -1,112 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="900">
-		<div class="ztgjmzrw">
-			<section v-for="announcement in announcements" class="_card _gap announcements">
-				<div class="_content announcement">
-					<MkInput v-model="announcement.title">
-						<template #label>{{ i18n.ts.title }}</template>
-					</MkInput>
-					<MkTextarea v-model="announcement.text">
-						<template #label>{{ i18n.ts.text }}</template>
-					</MkTextarea>
-					<MkInput v-model="announcement.imageUrl">
-						<template #label>{{ i18n.ts.imageUrl }}</template>
-					</MkInput>
-					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
-					<div class="buttons">
-						<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-						<MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
-					</div>
-				</div>
-			</section>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let announcements: any[] = $ref([]);
-
-os.api('admin/announcements/list').then(announcementResponse => {
-	announcements = announcementResponse;
-});
-
-function add() {
-	announcements.unshift({
-		id: null,
-		title: '',
-		text: '',
-		imageUrl: null,
-	});
-}
-
-function remove(announcement) {
-	os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: announcement.title }),
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		announcements = announcements.filter(x => x !== announcement);
-		os.api('admin/announcements/delete', announcement);
-	});
-}
-
-function save(announcement) {
-	if (announcement.id == null) {
-		os.api('admin/announcements/create', announcement).then(() => {
-			os.alert({
-				type: 'success',
-				text: i18n.ts.saved,
-			});
-		}).catch(err => {
-			os.alert({
-				type: 'error',
-				text: err,
-			});
-		});
-	} else {
-		os.api('admin/announcements/update', announcement).then(() => {
-			os.alert({
-				type: 'success',
-				text: i18n.ts.saved,
-			});
-		}).catch(err => {
-			os.alert({
-				type: 'error',
-				text: err,
-			});
-		});
-	}
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-plus',
-	text: i18n.ts.add,
-	handler: add,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.announcements,
-	icon: 'ti ti-speakerphone',
-});
-</script>
-
-<style lang="scss" scoped>
-.ztgjmzrw {
-	margin: var(--margin);
-}
-</style>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
deleted file mode 100644
index d03961cf95..0000000000
--- a/packages/client/src/pages/admin/bot-protection.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<template>
-<div>
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormRadios v-model="provider" class="_formBlock">
-				<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
-				<option value="hcaptcha">hCaptcha</option>
-				<option value="recaptcha">reCAPTCHA</option>
-				<option value="turnstile">Turnstile</option>
-			</FormRadios>
-
-			<template v-if="provider === 'hcaptcha'">
-				<FormInput v-model="hcaptchaSiteKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
-				</FormInput>
-				<FormInput v-model="hcaptchaSecretKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
-				</FormInput>
-				<FormSlot class="_formBlock">
-					<template #label>{{ i18n.ts.preview }}</template>
-					<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
-				</FormSlot>
-			</template>
-			<template v-else-if="provider === 'recaptcha'">
-				<FormInput v-model="recaptchaSiteKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
-				</FormInput>
-				<FormInput v-model="recaptchaSecretKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
-				</FormInput>
-				<FormSlot v-if="recaptchaSiteKey" class="_formBlock">
-					<template #label>{{ i18n.ts.preview }}</template>
-					<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
-				</FormSlot>
-			</template>
-			<template v-else-if="provider === 'turnstile'">
-				<FormInput v-model="turnstileSiteKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
-				</FormInput>
-				<FormInput v-model="turnstileSecretKey" class="_formBlock">
-					<template #prefix><i class="ti ti-key"></i></template>
-					<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
-				</FormInput>
-				<FormSlot class="_formBlock">
-					<template #label>{{ i18n.ts.preview }}</template>
-					<MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/>
-				</FormSlot>
-			</template>
-
-			<FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-		</div>
-	</FormSuspense>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormSlot from '@/components/form/slot.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
-
-let provider = $ref(null);
-let hcaptchaSiteKey: string | null = $ref(null);
-let hcaptchaSecretKey: string | null = $ref(null);
-let recaptchaSiteKey: string | null = $ref(null);
-let recaptchaSecretKey: string | null = $ref(null);
-let turnstileSiteKey: string | null = $ref(null);
-let turnstileSecretKey: string | null = $ref(null);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	hcaptchaSiteKey = meta.hcaptchaSiteKey;
-	hcaptchaSecretKey = meta.hcaptchaSecretKey;
-	recaptchaSiteKey = meta.recaptchaSiteKey;
-	recaptchaSecretKey = meta.recaptchaSecretKey;
-	turnstileSiteKey = meta.turnstileSiteKey;
-	turnstileSecretKey = meta.turnstileSecretKey;
-
-	provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		enableHcaptcha: provider === 'hcaptcha',
-		hcaptchaSiteKey,
-		hcaptchaSecretKey,
-		enableRecaptcha: provider === 'recaptcha',
-		recaptchaSiteKey,
-		recaptchaSecretKey,
-		enableTurnstile: provider === 'turnstile',
-		turnstileSiteKey,
-		turnstileSecretKey,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-</script>
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
deleted file mode 100644
index 5a0d3d5e51..0000000000
--- a/packages/client/src/pages/admin/database.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
-		<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
-			<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
-				<template #key>{{ table[0] }}</template>
-				<template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
-			</MkKeyValue>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import * as os from '@/os';
-import bytes from '@/filters/bytes';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.database,
-	icon: 'ti ti-database',
-});
-</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
deleted file mode 100644
index 6c9dee1704..0000000000
--- a/packages/client/src/pages/admin/email-settings.vue
+++ /dev/null
@@ -1,126 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<div class="_formRoot">
-				<FormSwitch v-model="enableEmail" class="_formBlock">
-					<template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template>
-					<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
-				</FormSwitch>
-
-				<template v-if="enableEmail">
-					<FormInput v-model="email" type="email" class="_formBlock">
-						<template #label>{{ i18n.ts.emailAddress }}</template>
-					</FormInput>
-
-					<FormSection>
-						<template #label>{{ i18n.ts.smtpConfig }}</template>
-						<FormSplit :min-width="280">
-							<FormInput v-model="smtpHost" class="_formBlock">
-								<template #label>{{ i18n.ts.smtpHost }}</template>
-							</FormInput>
-							<FormInput v-model="smtpPort" type="number" class="_formBlock">
-								<template #label>{{ i18n.ts.smtpPort }}</template>
-							</FormInput>
-						</FormSplit>
-						<FormSplit :min-width="280">
-							<FormInput v-model="smtpUser" class="_formBlock">
-								<template #label>{{ i18n.ts.smtpUser }}</template>
-							</FormInput>
-							<FormInput v-model="smtpPass" type="password" class="_formBlock">
-								<template #label>{{ i18n.ts.smtpPass }}</template>
-							</FormInput>
-						</FormSplit>
-						<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
-						<FormSwitch v-model="smtpSecure" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpSecure }}</template>
-							<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
-						</FormSwitch>
-					</FormSection>
-				</template>
-			</div>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormSection from '@/components/form/section.vue';
-import * as os from '@/os';
-import { fetchInstance, instance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let enableEmail: boolean = $ref(false);
-let email: any = $ref(null);
-let smtpSecure: boolean = $ref(false);
-let smtpHost: string = $ref('');
-let smtpPort: number = $ref(0);
-let smtpUser: string = $ref('');
-let smtpPass: string = $ref('');
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	enableEmail = meta.enableEmail;
-	email = meta.email;
-	smtpSecure = meta.smtpSecure;
-	smtpHost = meta.smtpHost;
-	smtpPort = meta.smtpPort;
-	smtpUser = meta.smtpUser;
-	smtpPass = meta.smtpPass;
-}
-
-async function testEmail() {
-	const { canceled, result: destination } = await os.inputText({
-		title: i18n.ts.destination,
-		type: 'email',
-		placeholder: instance.maintainerEmail,
-	});
-	if (canceled) return;
-	os.apiWithDialog('admin/send-email', {
-		to: destination,
-		subject: 'Test email',
-		text: 'Yo',
-	});
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		enableEmail,
-		email,
-		smtpSecure,
-		smtpHost,
-		smtpPort,
-		smtpUser,
-		smtpPass,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	text: i18n.ts.testEmail,
-	handler: testEmail,
-}, {
-	asFullButton: true,
-	icon: 'ti ti-check',
-	text: i18n.ts.save,
-	handler: save,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.emailServer,
-	icon: 'ti ti-mail',
-});
-</script>
diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue
deleted file mode 100644
index bd601cb1de..0000000000
--- a/packages/client/src/pages/admin/emoji-edit-dialog.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<template>
-<XModalWindow
-	ref="dialog"
-	:width="370"
-	:with-ok-button="true"
-	@close="$refs.dialog.close()"
-	@closed="$emit('closed')"
-	@ok="ok()"
->
-	<template #header>:{{ emoji.name }}:</template>
-
-	<div class="_monolithic_">
-		<div class="yigymqpb _section">
-			<img :src="emoji.url" class="img"/>
-			<MkInput v-model="name" class="_formBlock">
-				<template #label>{{ i18n.ts.name }}</template>
-			</MkInput>
-			<MkInput v-model="category" class="_formBlock" :datalist="categories">
-				<template #label>{{ i18n.ts.category }}</template>
-			</MkInput>
-			<MkInput v-model="aliases" class="_formBlock">
-				<template #label>{{ i18n.ts.tags }}</template>
-				<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
-			</MkInput>
-			<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
-		</div>
-	</div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XModalWindow from '@/components/MkModalWindow.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-import { unique } from '@/scripts/array';
-import { i18n } from '@/i18n';
-import { emojiCategories } from '@/instance';
-
-const props = defineProps<{
-	emoji: any,
-}>();
-
-let dialog = $ref(null);
-let name: string = $ref(props.emoji.name);
-let category: string = $ref(props.emoji.category);
-let aliases: string = $ref(props.emoji.aliases.join(' '));
-let categories: string[] = $ref(emojiCategories);
-
-const emit = defineEmits<{
-	(ev: 'done', v: { deleted?: boolean, updated?: any }): void,
-	(ev: 'closed'): void
-}>();
-
-function ok() {
-	update();
-}
-
-async function update() {
-	await os.apiWithDialog('admin/emoji/update', {
-		id: props.emoji.id,
-		name,
-		category,
-		aliases: aliases.split(' '),
-	});
-
-	emit('done', {
-		updated: {
-			id: props.emoji.id,
-			name,
-			category,
-			aliases: aliases.split(' '),
-		},
-	});
-
-	dialog.close();
-}
-
-async function del() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: name }),
-	});
-	if (canceled) return;
-
-	os.api('admin/emoji/delete', {
-		id: props.emoji.id,
-	}).then(() => {
-		emit('done', {
-			deleted: true,
-		});
-		dialog.close();
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.yigymqpb {
-	> .img {
-		display: block;
-		height: 64px;
-		margin: 0 auto;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
deleted file mode 100644
index 14c8466d73..0000000000
--- a/packages/client/src/pages/admin/emojis.vue
+++ /dev/null
@@ -1,398 +0,0 @@
-<template>
-<div>
-	<MkStickyContainer>
-		<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="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" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-						<MkButton inline @click="selectAll">Select all</MkButton>
-						<MkButton inline @click="setCategoryBulk">Set category</MkButton>
-						<MkButton inline @click="addTagBulk">Add tag</MkButton>
-						<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
-						<MkButton inline @click="setTagBulk">Set tag</MkButton>
-						<MkButton inline danger @click="delBulk">Delete</MkButton>
-					</div>
-					<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
-						<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
-						<template #default="{items}">
-							<div class="ldhfsamy">
-								<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
-									<img :src="emoji.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>
-						</template>
-					</MkPagination>
-				</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>
-				</div>
-			</div>
-		</MkSpacer>
-	</MkStickyContainer>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkTab from '@/components/MkTab.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import FormSplit from '@/components/form/split.vue';
-import { selectFile, selectFiles } from '@/scripts/select-file';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
-
-const tab = ref('local');
-const query = ref(null);
-const queryRemote = ref(null);
-const host = ref(null);
-const selectMode = ref(false);
-const selectedEmojis = ref<string[]>([]);
-
-const pagination = {
-	endpoint: 'admin/emoji/list' as const,
-	limit: 30,
-	params: computed(() => ({
-		query: (query.value && query.value !== '') ? query.value : null,
-	})),
-};
-
-const remotePagination = {
-	endpoint: 'admin/emoji/list-remote' as const,
-	limit: 30,
-	params: computed(() => ({
-		query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null,
-		host: (host.value && host.value !== '') ? host.value : null,
-	})),
-};
-
-const selectAll = () => {
-	if (selectedEmojis.value.length > 0) {
-		selectedEmojis.value = [];
-	} else {
-		selectedEmojis.value = 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 add = async (ev: MouseEvent) => {
-	const files = await selectFiles(ev.currentTarget ?? ev.target, null);
-
-	const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
-		fileId: file.id,
-	})));
-	promise.then(() => {
-		emojisPaginationComponent.value.reload();
-	});
-	os.promiseDialog(promise);
-};
-
-const edit = (emoji) => {
-	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
-		emoji: emoji,
-	}, {
-		done: result => {
-			if (result.updated) {
-				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
-					...oldEmoji,
-					...result.updated,
-				}));
-			} else if (result.deleted) {
-				emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
-			}
-		},
-	}, 'closed');
-};
-
-const im = (emoji) => {
-	os.apiWithDialog('admin/emoji/copy', {
-		emojiId: emoji.id,
-	});
-};
-
-const remoteMenu = (emoji, ev: MouseEvent) => {
-	os.popupMenu([{
-		type: 'label',
-		text: ':' + emoji.name + ':',
-	}, {
-		text: i18n.ts.import,
-		icon: 'ti ti-plus',
-		action: () => { im(emoji); },
-	}], ev.currentTarget ?? ev.target);
-};
-
-const menu = (ev: MouseEvent) => {
-	os.popupMenu([{
-		icon: 'ti ti-download',
-		text: i18n.ts.export,
-		action: async () => {
-			os.api('export-custom-emojis', {
-			})
-				.then(() => {
-					os.alert({
-						type: 'info',
-						text: i18n.ts.exportRequested,
-					});
-				}).catch((err) => {
-					os.alert({
-						type: 'error',
-						text: err.message,
-					});
-				});
-		},
-	}, {
-		icon: 'ti ti-upload',
-		text: i18n.ts.import,
-		action: async () => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
-			os.api('admin/emoji/import-zip', {
-				fileId: file.id,
-			})
-				.then(() => {
-					os.alert({
-						type: 'info',
-						text: i18n.ts.importRequested,
-					});
-				}).catch((err) => {
-					os.alert({
-						type: 'error',
-						text: err.message,
-					});
-				});
-		},
-	}], 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 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',
-	text: i18n.ts.addEmoji,
-	handler: add,
-}, {
-	icon: 'ti ti-dots',
-	handler: menu,
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'local',
-	title: i18n.ts.local,
-}, {
-	key: 'remote',
-	title: i18n.ts.remote,
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.customEmojis,
-	icon: 'ti ti-mood-happy',
-})));
-</script>
-
-<style lang="scss" scoped>
-.ogwlenmc {
-	> .local {
-		.empty {
-			margin: var(--margin);
-		}
-		
-		.ldhfsamy {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-			margin: var(--margin) 0;
-	
-			> .emoji {
-				display: flex;
-				align-items: center;
-				padding: 11px;
-				text-align: left;
-				border: solid 1px var(--panel);
-
-				&:hover {
-					border-color: var(--inputBorderHover);
-				}
-
-				&.selected {
-					border-color: var(--accent);
-				}
-
-				> .img {
-					width: 42px;
-					height: 42px;
-				}
-
-				> .body {
-					padding: 0 0 0 8px;
-					white-space: nowrap;
-					overflow: hidden;
-
-					> .name {
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-
-					> .info {
-						opacity: 0.5;
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-				}
-			}
-		}
-	}
-
-	> .remote {
-		.empty {
-			margin: var(--margin);
-		}
-
-		.ldhfsamy {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-			margin: var(--margin) 0;
-
-			> .emoji {
-				display: flex;
-				align-items: center;
-				padding: 12px;
-				text-align: left;
-
-				&:hover {
-					color: var(--accent);
-				}
-
-				> .img {
-					width: 32px;
-					height: 32px;
-				}
-
-				> .body {
-					padding: 0 0 0 8px;
-					white-space: nowrap;
-					overflow: hidden;
-
-					> .name {
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-
-					> .info {
-						opacity: 0.5;
-						font-size: 90%;
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
deleted file mode 100644
index 8ad6bd4fc0..0000000000
--- a/packages/client/src/pages/admin/files.vue
+++ /dev/null
@@ -1,120 +0,0 @@
-<template>
-<div>
-	<MkStickyContainer>
-		<template #header><XHeader :actions="headerActions"/></template>
-		<MkSpacer :content-max="900">
-			<div class="xrmjdkdw">
-				<div>
-					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-						<MkSelect v-model="origin" style="margin: 0; flex: 1;">
-							<template #label>{{ i18n.ts.instance }}</template>
-							<option value="combined">{{ i18n.ts.all }}</option>
-							<option value="local">{{ i18n.ts.local }}</option>
-							<option value="remote">{{ i18n.ts.remote }}</option>
-						</MkSelect>
-						<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
-							<template #label>{{ i18n.ts.host }}</template>
-						</MkInput>
-					</div>
-					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;">
-						<MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
-							<template #label>User ID</template>
-						</MkInput>
-						<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
-							<template #label>MIME type</template>
-						</MkInput>
-					</div>
-					<MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/>
-				</div>
-			</div>
-		</MkSpacer>
-	</MkStickyContainer>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let origin = $ref('local');
-let type = $ref(null);
-let searchHost = $ref('');
-let userId = $ref('');
-let viewMode = $ref('grid');
-const pagination = {
-	endpoint: 'admin/drive/files' as const,
-	limit: 10,
-	params: computed(() => ({
-		type: (type && type !== '') ? type : null,
-		userId: (userId && userId !== '') ? userId : null,
-		origin: origin,
-		hostname: (searchHost && searchHost !== '') ? searchHost : null,
-	})),
-};
-
-function clear() {
-	os.confirm({
-		type: 'warning',
-		text: i18n.ts.clearCachedFilesConfirm,
-	}).then(({ canceled }) => {
-		if (canceled) return;
-
-		os.apiWithDialog('admin/drive/clean-remote-files', {});
-	});
-}
-
-function show(file) {
-	os.pageWindow(`/admin/file/${file.id}`);
-}
-
-async function find() {
-	const { canceled, result: q } = await os.inputText({
-		title: i18n.ts.fileIdOrUrl,
-		allowEmpty: false,
-	});
-	if (canceled) return;
-
-	os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
-		show(file);
-	}).catch(err => {
-		if (err.code === 'NO_SUCH_FILE') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.notFound,
-			});
-		}
-	});
-}
-
-const headerActions = $computed(() => [{
-	text: i18n.ts.lookup,
-	icon: 'ti ti-search',
-	handler: find,
-}, {
-	text: i18n.ts.clearCachedFiles,
-	icon: 'ti ti-trash',
-	handler: clear,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.files,
-	icon: 'ti ti-cloud',
-})));
-</script>
-
-<style lang="scss" scoped>
-.xrmjdkdw {
-	margin: var(--margin);
-}
-</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
deleted file mode 100644
index 6c07a87eeb..0000000000
--- a/packages/client/src/pages/admin/index.vue
+++ /dev/null
@@ -1,316 +0,0 @@
-<template>
-<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
-	<div v-if="!narrow || currentPage?.route.name == null" class="nav">	
-		<MkSpacer :content-max="700" :margin-min="16">
-			<div class="lxpfedzu">
-				<div class="banner">
-					<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
-				</div>
-
-				<MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
-				<MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
-				<MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
-				<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
-
-				<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
-			</div>
-		</MkSpacer>
-	</div>
-	<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
-		<RouterView/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
-import { i18n } from '@/i18n';
-import MkSuperMenu from '@/components/MkSuperMenu.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import { scroll } from '@/scripts/scroll';
-import { instance } from '@/instance';
-import * as os from '@/os';
-import { lookupUser } from '@/scripts/lookup-user';
-import { useRouter } from '@/router';
-import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-
-const isEmpty = (x: string | null) => x == null || x === '';
-
-const router = useRouter();
-
-const indexInfo = {
-	title: i18n.ts.controlPanel,
-	icon: 'ti ti-settings',
-	hideHeader: true,
-};
-
-provide('shouldOmitHeaderTitle', false);
-
-let INFO = $ref(indexInfo);
-let childInfo = $ref(null);
-let narrow = $ref(false);
-let view = $ref(null);
-let el = $ref(null);
-let pageProps = $ref({});
-let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
-let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
-let noEmailServer = !instance.enableEmail;
-let thereIsUnresolvedAbuseReport = $ref(false);
-let currentPage = $computed(() => router.currentRef.value.child);
-
-os.api('admin/abuse-user-reports', {
-	state: 'unresolved',
-	limit: 1,
-}).then(reports => {
-	if (reports.length > 0) thereIsUnresolvedAbuseReport = true;
-});
-
-const NARROW_THRESHOLD = 600;
-const ro = new ResizeObserver((entries, observer) => {
-	if (entries.length === 0) return;
-	narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
-});
-
-const menuDef = $computed(() => [{
-	title: i18n.ts.quickAction,
-	items: [{
-		type: 'button',
-		icon: 'ti ti-search',
-		text: i18n.ts.lookup,
-		action: lookup,
-	}, ...(instance.disableRegistration ? [{
-		type: 'button',
-		icon: 'ti ti-user',
-		text: i18n.ts.invite,
-		action: invite,
-	}] : [])],
-}, {
-	title: i18n.ts.administration,
-	items: [{
-		icon: 'ti ti-dashboard',
-		text: i18n.ts.dashboard,
-		to: '/admin/overview',
-		active: currentPage?.route.name === 'overview',
-	}, {
-		icon: 'ti ti-users',
-		text: i18n.ts.users,
-		to: '/admin/users',
-		active: currentPage?.route.name === 'users',
-	}, {
-		icon: 'ti ti-mood-happy',
-		text: i18n.ts.customEmojis,
-		to: '/admin/emojis',
-		active: currentPage?.route.name === 'emojis',
-	}, {
-		icon: 'ti ti-whirl',
-		text: i18n.ts.federation,
-		to: '/about#federation',
-		active: currentPage?.route.name === 'federation',
-	}, {
-		icon: 'ti ti-clock-play',
-		text: i18n.ts.jobQueue,
-		to: '/admin/queue',
-		active: currentPage?.route.name === 'queue',
-	}, {
-		icon: 'ti ti-cloud',
-		text: i18n.ts.files,
-		to: '/admin/files',
-		active: currentPage?.route.name === 'files',
-	}, {
-		icon: 'ti ti-speakerphone',
-		text: i18n.ts.announcements,
-		to: '/admin/announcements',
-		active: currentPage?.route.name === 'announcements',
-	}, {
-		icon: 'ti ti-ad',
-		text: i18n.ts.ads,
-		to: '/admin/ads',
-		active: currentPage?.route.name === 'ads',
-	}, {
-		icon: 'ti ti-exclamation-circle',
-		text: i18n.ts.abuseReports,
-		to: '/admin/abuses',
-		active: currentPage?.route.name === 'abuses',
-	}],
-}, {
-	title: i18n.ts.settings,
-	items: [{
-		icon: 'ti ti-settings',
-		text: i18n.ts.general,
-		to: '/admin/settings',
-		active: currentPage?.route.name === 'settings',
-	}, {
-		icon: 'ti ti-mail',
-		text: i18n.ts.emailServer,
-		to: '/admin/email-settings',
-		active: currentPage?.route.name === 'email-settings',
-	}, {
-		icon: 'ti ti-cloud',
-		text: i18n.ts.objectStorage,
-		to: '/admin/object-storage',
-		active: currentPage?.route.name === 'object-storage',
-	}, {
-		icon: 'ti ti-lock',
-		text: i18n.ts.security,
-		to: '/admin/security',
-		active: currentPage?.route.name === 'security',
-	}, {
-		icon: 'ti ti-planet',
-		text: i18n.ts.relays,
-		to: '/admin/relays',
-		active: currentPage?.route.name === 'relays',
-	}, {
-		icon: 'ti ti-share',
-		text: i18n.ts.integration,
-		to: '/admin/integrations',
-		active: currentPage?.route.name === 'integrations',
-	}, {
-		icon: 'ti ti-ban',
-		text: i18n.ts.instanceBlocking,
-		to: '/admin/instance-block',
-		active: currentPage?.route.name === 'instance-block',
-	}, {
-		icon: 'ti ti-ghost',
-		text: i18n.ts.proxyAccount,
-		to: '/admin/proxy-account',
-		active: currentPage?.route.name === 'proxy-account',
-	}, {
-		icon: 'ti ti-adjustments',
-		text: i18n.ts.other,
-		to: '/admin/other-settings',
-		active: currentPage?.route.name === 'other-settings',
-	}],
-}, {
-	title: i18n.ts.info,
-	items: [{
-		icon: 'ti ti-database',
-		text: i18n.ts.database,
-		to: '/admin/database',
-		active: currentPage?.route.name === 'database',
-	}],
-}]);
-
-watch(narrow, () => {
-	if (currentPage?.route.name == null && !narrow) {
-		router.push('/admin/overview');
-	}
-});
-
-onMounted(() => {
-	ro.observe(el);
-
-	narrow = el.offsetWidth < NARROW_THRESHOLD;
-	if (currentPage?.route.name == null && !narrow) {
-		router.push('/admin/overview');
-	}
-});
-
-onUnmounted(() => {
-	ro.disconnect();
-});
-
-provideMetadataReceiver((info) => {
-	if (info == null) {
-		childInfo = null;
-	} else {
-		childInfo = info;
-	}
-});
-
-const invite = () => {
-	os.api('admin/invite').then(x => {
-		os.alert({
-			type: 'info',
-			text: x.code,
-		});
-	}).catch(err => {
-		os.alert({
-			type: 'error',
-			text: err,
-		});
-	});
-};
-
-const lookup = (ev) => {
-	os.popupMenu([{
-		text: i18n.ts.user,
-		icon: 'ti ti-user',
-		action: () => {
-			lookupUser();
-		},
-	}, {
-		text: i18n.ts.note,
-		icon: 'ti ti-pencil',
-		action: () => {
-			alert('TODO');
-		},
-	}, {
-		text: i18n.ts.file,
-		icon: 'ti ti-cloud',
-		action: () => {
-			alert('TODO');
-		},
-	}, {
-		text: i18n.ts.instance,
-		icon: 'ti ti-planet',
-		action: () => {
-			alert('TODO');
-		},
-	}], ev.currentTarget ?? ev.target);
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(INFO);
-
-defineExpose({
-	header: {
-		title: i18n.ts.controlPanel,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.hiyeyicy {
-	&.wide {
-		display: flex;
-		margin: 0 auto;
-		height: 100%;
-
-		> .nav {
-			width: 32%;
-			max-width: 280px;
-			box-sizing: border-box;
-			border-right: solid 0.5px var(--divider);
-			overflow: auto;
-			height: 100%;
-		}
-
-		> .main {
-			flex: 1;
-			min-width: 0;
-		}
-	}
-
-	> .nav {
-		.lxpfedzu {
-			> .info {
-				margin: 16px 0;
-			}
-
-			> .banner {
-				margin: 16px;
-
-				> .icon {
-					display: block;
-					margin: auto;
-					height: 42px;
-					border-radius: 8px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
deleted file mode 100644
index 1bdd174de4..0000000000
--- a/packages/client/src/pages/admin/instance-block.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<FormTextarea v-model="blockedHosts" class="_formBlock">
-				<span>{{ i18n.ts.blockedInstances }}</span>
-				<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
-			</FormTextarea>
-
-			<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let blockedHosts: string = $ref('');
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	blockedHosts = meta.blockedHosts.join('\n');
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		blockedHosts: blockedHosts.split('\n') || [],
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.instanceBlocking,
-	icon: 'ti ti-ban',
-});
-</script>
diff --git a/packages/client/src/pages/admin/integrations.discord.vue b/packages/client/src/pages/admin/integrations.discord.vue
deleted file mode 100644
index 0a69c44c93..0000000000
--- a/packages/client/src/pages/admin/integrations.discord.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
-<FormSuspense :p="init">
-	<div class="_formRoot">
-		<FormSwitch v-model="enableDiscordIntegration" class="_formBlock">
-			<template #label>{{ i18n.ts.enable }}</template>
-		</FormSwitch>
-
-		<template v-if="enableDiscordIntegration">
-			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo>
-		
-			<FormInput v-model="discordClientId" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Client ID</template>
-			</FormInput>
-
-			<FormInput v-model="discordClientSecret" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Client Secret</template>
-			</FormInput>
-		</template>
-
-		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-	</div>
-</FormSuspense>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-
-let uri: string = $ref('');
-let enableDiscordIntegration: boolean = $ref(false);
-let discordClientId: string | null = $ref(null);
-let discordClientSecret: string | null = $ref(null);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	uri = meta.uri;
-	enableDiscordIntegration = meta.enableDiscordIntegration;
-	discordClientId = meta.discordClientId;
-	discordClientSecret = meta.discordClientSecret;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		enableDiscordIntegration,
-		discordClientId,
-		discordClientSecret,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-</script>
diff --git a/packages/client/src/pages/admin/integrations.github.vue b/packages/client/src/pages/admin/integrations.github.vue
deleted file mode 100644
index 66419d5891..0000000000
--- a/packages/client/src/pages/admin/integrations.github.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
-<FormSuspense :p="init">
-	<div class="_formRoot">
-		<FormSwitch v-model="enableGithubIntegration" class="_formBlock">
-			<template #label>{{ i18n.ts.enable }}</template>
-		</FormSwitch>
-
-		<template v-if="enableGithubIntegration">
-			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo>
-		
-			<FormInput v-model="githubClientId" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Client ID</template>
-			</FormInput>
-
-			<FormInput v-model="githubClientSecret" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Client Secret</template>
-			</FormInput>
-		</template>
-
-		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-	</div>
-</FormSuspense>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-
-let uri: string = $ref('');
-let enableGithubIntegration: boolean = $ref(false);
-let githubClientId: string | null = $ref(null);
-let githubClientSecret: string | null = $ref(null);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	uri = meta.uri;
-	enableGithubIntegration = meta.enableGithubIntegration;
-	githubClientId = meta.githubClientId;
-	githubClientSecret = meta.githubClientSecret;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		enableGithubIntegration,
-		githubClientId,
-		githubClientSecret,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-</script>
diff --git a/packages/client/src/pages/admin/integrations.twitter.vue b/packages/client/src/pages/admin/integrations.twitter.vue
deleted file mode 100644
index 1e8d882b9c..0000000000
--- a/packages/client/src/pages/admin/integrations.twitter.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
-<FormSuspense :p="init">
-	<div class="_formRoot">
-		<FormSwitch v-model="enableTwitterIntegration" class="_formBlock">
-			<template #label>{{ i18n.ts.enable }}</template>
-		</FormSwitch>
-
-		<template v-if="enableTwitterIntegration">
-			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo>
-		
-			<FormInput v-model="twitterConsumerKey" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Consumer Key</template>
-			</FormInput>
-
-			<FormInput v-model="twitterConsumerSecret" class="_formBlock">
-				<template #prefix><i class="ti ti-key"></i></template>
-				<template #label>Consumer Secret</template>
-			</FormInput>
-		</template>
-
-		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-	</div>
-</FormSuspense>
-</template>
-
-<script lang="ts" setup>
-import { defineComponent } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-
-let uri: string = $ref('');
-let enableTwitterIntegration: boolean = $ref(false);
-let twitterConsumerKey: string | null = $ref(null);
-let twitterConsumerSecret: string | null = $ref(null);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	uri = meta.uri;
-	enableTwitterIntegration = meta.enableTwitterIntegration;
-	twitterConsumerKey = meta.twitterConsumerKey;
-	twitterConsumerSecret = meta.twitterConsumerSecret;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		enableTwitterIntegration,
-		twitterConsumerKey,
-		twitterConsumerSecret,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
deleted file mode 100644
index 9cc35baefd..0000000000
--- a/packages/client/src/pages/admin/integrations.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<template><MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<FormFolder class="_formBlock">
-			<template #icon><i class="ti ti-brand-twitter"></i></template>
-			<template #label>Twitter</template>
-			<template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
-			<XTwitter/>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #icon><i class="ti ti-brand-github"></i></template>
-			<template #label>GitHub</template>
-			<template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
-			<XGithub/>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #icon><i class="ti ti-brand-discord"></i></template>
-			<template #label>Discord</template>
-			<template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
-			<XDiscord/>
-		</FormFolder>
-	</FormSuspense>
-</MkSpacer></MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XTwitter from './integrations.twitter.vue';
-import XGithub from './integrations.github.vue';
-import XDiscord from './integrations.discord.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormFolder from '@/components/form/folder.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let enableTwitterIntegration: boolean = $ref(false);
-let enableGithubIntegration: boolean = $ref(false);
-let enableDiscordIntegration: boolean = $ref(false);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	enableTwitterIntegration = meta.enableTwitterIntegration;
-	enableGithubIntegration = meta.enableGithubIntegration;
-	enableDiscordIntegration = meta.enableDiscordIntegration;
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.integration,
-	icon: 'ti ti-share',
-});
-</script>
diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue
deleted file mode 100644
index db8e448639..0000000000
--- a/packages/client/src/pages/admin/metrics.vue
+++ /dev/null
@@ -1,472 +0,0 @@
-<template>
-<div class="_debobigegoItem">
-	<div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
-	<div class="_debobigegoPanel xhexznfu">
-		<div>
-			<canvas :ref="cpumem"></canvas>
-		</div>
-		<div v-if="serverInfo">
-			<div class="_table">
-				<div class="_row">
-					<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
-					<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
-					<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
-				</div>
-			</div>
-		</div>
-	</div>
-</div>
-<div class="_debobigegoItem">
-	<div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
-	<div class="_debobigegoPanel xhexznfu">
-		<div>
-			<canvas :ref="disk"></canvas>
-		</div>
-		<div v-if="serverInfo">
-			<div class="_table">
-				<div class="_row">
-					<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
-					<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
-					<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
-				</div>
-			</div>
-		</div>
-	</div>
-</div>
-<div class="_debobigegoItem">
-	<div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
-	<div class="_debobigegoPanel xhexznfu">
-		<div>
-			<canvas :ref="net"></canvas>
-		</div>
-		<div v-if="serverInfo">
-			<div class="_table">
-				<div class="_row">
-					<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
-				</div>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import {
-  Chart,
-  ArcElement,
-  LineElement,
-  BarElement,
-  PointElement,
-  BarController,
-  LineController,
-  CategoryScale,
-  LinearScale,
-  Legend,
-  Title,
-  Tooltip,
-  SubTitle,
-} from 'chart.js';
-import MkButton from '@/components/MkButton.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkInput from '@/components/form/input.vue';
-import MkContainer from '@/components/MkContainer.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkwFederation from '../../widgets/federation.vue';
-import { version, url } from '@/config';
-import bytes from '@/filters/bytes';
-import number from '@/filters/number';
-
-Chart.register(
-  ArcElement,
-  LineElement,
-  BarElement,
-  PointElement,
-  BarController,
-  LineController,
-  CategoryScale,
-  LinearScale,
-  Legend,
-  Title,
-  Tooltip,
-  SubTitle,
-);
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-import * as os from '@/os';
-import { stream } from '@/stream';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		MkSelect,
-		MkInput,
-		MkContainer,
-		MkFolder,
-		MkwFederation,
-	},
-
-	data() {
-		return {
-			version,
-			url,
-			stats: null,
-			serverInfo: null,
-			connection: null,
-			queueConnection: markRaw(stream.useChannel('queueStats')),
-			memUsage: 0,
-			chartCpuMem: null,
-			chartNet: null,
-			jobs: [],
-			logs: [],
-			logLevel: 'all',
-			logDomain: '',
-			modLogs: [],
-			dbInfo: null,
-			overviewHeight: '1fr',
-			queueHeight: '1fr',
-			paused: false,
-		};
-	},
-
-	computed: {
-		gridColor() {
-			// TODO: var(--panel)の色が暗いか明るいかで判定する
-			return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-		},
-	},
-
-	mounted() {
-		this.fetchJobs();
-
-		Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-		os.api('admin/server-info', {}).then(res => {
-			this.serverInfo = res;
-
-			this.connection = markRaw(stream.useChannel('serverStats'));
-			this.connection.on('stats', this.onStats);
-			this.connection.on('statsLog', this.onStatsLog);
-			this.connection.send('requestLog', {
-				id: Math.random().toString().substr(2, 8),
-				length: 150,
-			});
-
-			this.$nextTick(() => {
-				this.queueConnection.send('requestLog', {
-					id: Math.random().toString().substr(2, 8),
-					length: 200,
-				});
-			});
-		});
-	},
-
-	beforeUnmount() {
-		if (this.connection) {
-			this.connection.off('stats', this.onStats);
-			this.connection.off('statsLog', this.onStatsLog);
-			this.connection.dispose();
-		}
-		this.queueConnection.dispose();
-	},
-
-	methods: {
-		cpumem(el) {
-			if (this.chartCpuMem != null) return;
-			this.chartCpuMem = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'CPU',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#86b300',
-						backgroundColor: alpha('#86b300', 0.1),
-						data: [],
-					}, {
-						label: 'MEM (active)',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#935dbf',
-						backgroundColor: alpha('#935dbf', 0.02),
-						data: [],
-					}, {
-						label: 'MEM (used)',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#935dbf',
-						borderDash: [5, 5],
-						fill: false,
-						data: [],
-					}],
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 0,
-						},
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						},
-					},
-					scales: {
-						x: {
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							},
-						},
-						y: {
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-								max: 100,
-							},
-						},
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					},
-				},
-			}));
-		},
-
-		net(el) {
-			if (this.chartNet != null) return;
-			this.chartNet = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'In',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#94a029',
-						backgroundColor: alpha('#94a029', 0.1),
-						data: [],
-					}, {
-						label: 'Out',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#ff9156',
-						backgroundColor: alpha('#ff9156', 0.1),
-						data: [],
-					}],
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 0,
-						},
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						},
-					},
-					scales: {
-						x: {
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							},
-						},
-						y: {
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							},
-						},
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					},
-				},
-			}));
-		},
-
-		disk(el) {
-			if (this.chartDisk != null) return;
-			this.chartDisk = markRaw(new Chart(el, {
-				type: 'line',
-				data: {
-					labels: [],
-					datasets: [{
-						label: 'Read',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#94a029',
-						backgroundColor: alpha('#94a029', 0.1),
-						data: [],
-					}, {
-						label: 'Write',
-						pointRadius: 0,
-						tension: 0,
-						borderWidth: 2,
-						borderColor: '#ff9156',
-						backgroundColor: alpha('#ff9156', 0.1),
-						data: [],
-					}],
-				},
-				options: {
-					aspectRatio: 3,
-					layout: {
-						padding: {
-							left: 16,
-							right: 16,
-							top: 16,
-							bottom: 0,
-						},
-					},
-					legend: {
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						},
-					},
-					scales: {
-						x: {
-							gridLines: {
-								display: false,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							},
-						},
-						y: {
-							position: 'right',
-							gridLines: {
-								display: true,
-								color: this.gridColor,
-								zeroLineColor: this.gridColor,
-							},
-							ticks: {
-								display: false,
-							},
-						},
-					},
-					tooltips: {
-						intersect: false,
-						mode: 'index',
-					},
-				},
-			}));
-		},
-
-		fetchJobs() {
-			os.api('admin/queue/deliver-delayed', {}).then(jobs => {
-				this.jobs = jobs;
-			});
-		},
-
-		onStats(stats) {
-			if (this.paused) return;
-
-			const cpu = (stats.cpu * 100).toFixed(0);
-			const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
-			const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
-			this.memUsage = stats.mem.active;
-
-			this.chartCpuMem.data.labels.push('');
-			this.chartCpuMem.data.datasets[0].data.push(cpu);
-			this.chartCpuMem.data.datasets[1].data.push(memActive);
-			this.chartCpuMem.data.datasets[2].data.push(memUsed);
-			this.chartNet.data.labels.push('');
-			this.chartNet.data.datasets[0].data.push(stats.net.rx);
-			this.chartNet.data.datasets[1].data.push(stats.net.tx);
-			this.chartDisk.data.labels.push('');
-			this.chartDisk.data.datasets[0].data.push(stats.fs.r);
-			this.chartDisk.data.datasets[1].data.push(stats.fs.w);
-			if (this.chartCpuMem.data.datasets[0].data.length > 150) {
-				this.chartCpuMem.data.labels.shift();
-				this.chartCpuMem.data.datasets[0].data.shift();
-				this.chartCpuMem.data.datasets[1].data.shift();
-				this.chartCpuMem.data.datasets[2].data.shift();
-				this.chartNet.data.labels.shift();
-				this.chartNet.data.datasets[0].data.shift();
-				this.chartNet.data.datasets[1].data.shift();
-				this.chartDisk.data.labels.shift();
-				this.chartDisk.data.datasets[0].data.shift();
-				this.chartDisk.data.datasets[1].data.shift();
-			}
-			this.chartCpuMem.update();
-			this.chartNet.update();
-			this.chartDisk.update();
-		},
-
-		onStatsLog(statsLog) {
-			for (const stats of [...statsLog].reverse()) {
-				this.onStats(stats);
-			}
-		},
-
-		bytes,
-
-		number,
-
-		pause() {
-			this.paused = true;
-		},
-
-		resume() {
-			this.paused = false;
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.xhexznfu {
-	> div:nth-child(2) {
-		padding: 16px;
-		border-top: solid 0.5px var(--divider);
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
deleted file mode 100644
index f2ab30eaa5..0000000000
--- a/packages/client/src/pages/admin/object-storage.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<div class="_formRoot">
-				<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
-
-				<template v-if="useObjectStorage">
-					<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
-						<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
-					</FormInput>
-
-					<FormInput v-model="objectStorageBucket" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageBucket }}</template>
-						<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
-					</FormInput>
-
-					<FormInput v-model="objectStoragePrefix" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
-						<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
-					</FormInput>
-
-					<FormInput v-model="objectStorageEndpoint" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
-						<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
-					</FormInput>
-
-					<FormInput v-model="objectStorageRegion" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageRegion }}</template>
-						<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
-					</FormInput>
-
-					<FormSplit :min-width="280">
-						<FormInput v-model="objectStorageAccessKey" class="_formBlock">
-							<template #prefix><i class="ti ti-key"></i></template>
-							<template #label>Access key</template>
-						</FormInput>
-
-						<FormInput v-model="objectStorageSecretKey" class="_formBlock">
-							<template #prefix><i class="ti ti-key"></i></template>
-							<template #label>Secret key</template>
-						</FormInput>
-					</FormSplit>
-
-					<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
-						<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
-					</FormSwitch>
-
-					<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
-						<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
-					</FormSwitch>
-
-					<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
-						<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
-					</FormSwitch>
-
-					<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
-						<template #label>s3ForcePathStyle</template>
-					</FormSwitch>
-				</template>
-			</div>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormSection from '@/components/form/section.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let useObjectStorage: boolean = $ref(false);
-let objectStorageBaseUrl: string | null = $ref(null);
-let objectStorageBucket: string | null = $ref(null);
-let objectStoragePrefix: string | null = $ref(null);
-let objectStorageEndpoint: string | null = $ref(null);
-let objectStorageRegion: string | null = $ref(null);
-let objectStoragePort: number | null = $ref(null);
-let objectStorageAccessKey: string | null = $ref(null);
-let objectStorageSecretKey: string | null = $ref(null);
-let objectStorageUseSSL: boolean = $ref(false);
-let objectStorageUseProxy: boolean = $ref(false);
-let objectStorageSetPublicRead: boolean = $ref(false);
-let objectStorageS3ForcePathStyle: boolean = $ref(true);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	useObjectStorage = meta.useObjectStorage;
-	objectStorageBaseUrl = meta.objectStorageBaseUrl;
-	objectStorageBucket = meta.objectStorageBucket;
-	objectStoragePrefix = meta.objectStoragePrefix;
-	objectStorageEndpoint = meta.objectStorageEndpoint;
-	objectStorageRegion = meta.objectStorageRegion;
-	objectStoragePort = meta.objectStoragePort;
-	objectStorageAccessKey = meta.objectStorageAccessKey;
-	objectStorageSecretKey = meta.objectStorageSecretKey;
-	objectStorageUseSSL = meta.objectStorageUseSSL;
-	objectStorageUseProxy = meta.objectStorageUseProxy;
-	objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
-	objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		useObjectStorage,
-		objectStorageBaseUrl,
-		objectStorageBucket,
-		objectStoragePrefix,
-		objectStorageEndpoint,
-		objectStorageRegion,
-		objectStoragePort,
-		objectStorageAccessKey,
-		objectStorageSecretKey,
-		objectStorageUseSSL,
-		objectStorageUseProxy,
-		objectStorageSetPublicRead,
-		objectStorageS3ForcePathStyle,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-check',
-	text: i18n.ts.save,
-	handler: save,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.objectStorage,
-	icon: 'ti ti-cloud',
-});
-</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
deleted file mode 100644
index 62dff6ce7f..0000000000
--- a/packages/client/src/pages/admin/other-settings.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			none
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-async function init() {
-	await os.api('admin/meta');
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta').then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-check',
-	text: i18n.ts.save,
-	handler: save,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.other,
-	icon: 'ti ti-adjustments',
-});
-</script>
diff --git a/packages/client/src/pages/admin/overview.active-users.vue b/packages/client/src/pages/admin/overview.active-users.vue
deleted file mode 100644
index c3ce5ac901..0000000000
--- a/packages/client/src/pages/admin/overview.active-users.vue
+++ /dev/null
@@ -1,217 +0,0 @@
-<template>
-<div>
-	<MkLoading v-if="fetching"/>
-	<div v-show="!fetching" :class="$style.root" class="_panel">
-		<canvas ref="chartEl"></canvas>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import { enUS } from 'date-fns/locale';
-import tinycolor from 'tinycolor2';
-import * as os from '@/os';
-import 'chartjs-adapter-date-fns';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import gradient from 'chartjs-plugin-gradient';
-import { chartVLine } from '@/scripts/chart-vline';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	gradient,
-);
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const chartEl = $ref<HTMLCanvasElement>(null);
-const now = new Date();
-let chartInstance: Chart = null;
-const chartLimit = 7;
-let fetching = $ref(true);
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-
-async function renderChart() {
-	if (chartInstance) {
-		chartInstance.destroy();
-	}
-
-	const getDate = (ago: number) => {
-		const y = now.getFullYear();
-		const m = now.getMonth();
-		const d = now.getDate();
-
-		return new Date(y, m, d - ago);
-	};
-
-	const format = (arr) => {
-		return arr.map((v, i) => ({
-			x: getDate(i).getTime(),
-			y: v,
-		}));
-	};
-
-	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
-
-	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-
-	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-	const colorRead = '#3498db';
-	const colorWrite = '#2ecc71';
-
-	const max = Math.max(...raw.read);
-
-	chartInstance = new Chart(chartEl, {
-		type: 'bar',
-		data: {
-			datasets: [{
-				parsing: false,
-				label: 'Read',
-				data: format(raw.read).slice().reverse(),
-				pointRadius: 0,
-				borderWidth: 0,
-				borderJoinStyle: 'round',
-				borderRadius: 4,
-				backgroundColor: colorRead,
-				barPercentage: 0.7,
-				categoryPercentage: 0.5,
-				fill: true,
-			}, {
-				parsing: false,
-				label: 'Write',
-				data: format(raw.write).slice().reverse(),
-				pointRadius: 0,
-				borderWidth: 0,
-				borderJoinStyle: 'round',
-				borderRadius: 4,
-				backgroundColor: colorWrite,
-				barPercentage: 0.7,
-				categoryPercentage: 0.5,
-				fill: true,
-			}],
-		},
-		options: {
-			aspectRatio: 2.5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 8,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					type: 'time',
-					offset: true,
-					time: {
-						stepSize: 1,
-						unit: 'day',
-					},
-					grid: {
-						display: false,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: true,
-						maxRotation: 0,
-						autoSkipPadding: 8,
-					},
-					adapters: {
-						date: {
-							locale: enUS,
-						},
-					},
-				},
-				y: {
-					position: 'left',
-					suggestedMax: 10,
-					grid: {
-						display: true,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: true,
-						//mirror: true,
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-				mode: 'index',
-			},
-			animation: false,
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-				gradient,
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-
-	fetching = false;
-}
-
-onMounted(async () => {
-	renderChart();
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	padding: 20px;
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.ap-requests.vue b/packages/client/src/pages/admin/overview.ap-requests.vue
deleted file mode 100644
index 024ffdc245..0000000000
--- a/packages/client/src/pages/admin/overview.ap-requests.vue
+++ /dev/null
@@ -1,346 +0,0 @@
-<template>
-<div>
-	<MkLoading v-if="fetching"/>
-	<div v-show="!fetching" :class="$style.root">
-		<div class="charts _panel">
-			<div class="chart">
-				<canvas ref="chartEl2"></canvas>
-			</div>
-			<div class="chart">
-				<canvas ref="chartEl"></canvas>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import gradient from 'chartjs-plugin-gradient';
-import { enUS } from 'date-fns/locale';
-import tinycolor from 'tinycolor2';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import MkNumberDiff from '@/components/MkNumberDiff.vue';
-import { i18n } from '@/i18n';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import { chartVLine } from '@/scripts/chart-vline';
-import { defaultStore } from '@/store';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	gradient,
-);
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const chartLimit = 50;
-const chartEl = $ref<HTMLCanvasElement>();
-const chartEl2 = $ref<HTMLCanvasElement>();
-let fetching = $ref(true);
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-const { handler: externalTooltipHandler2 } = useChartTooltip();
-
-onMounted(async () => {
-	const now = new Date();
-
-	const getDate = (ago: number) => {
-		const y = now.getFullYear();
-		const m = now.getMonth();
-		const d = now.getDate();
-
-		return new Date(y, m, d - ago);
-	};
-
-	const format = (arr) => {
-		return arr.map((v, i) => ({
-			x: getDate(i).getTime(),
-			y: v,
-		}));
-	};
-
-	const formatMinus = (arr) => {
-		return arr.map((v, i) => ({
-			x: getDate(i).getTime(),
-			y: -v,
-		}));
-	};
-
-	const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
-
-	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-	const succColor = '#87e000';
-	const failColor = '#ff4400';
-
-	const succMax = Math.max(...raw.deliverSucceeded);
-	const failMax = Math.max(...raw.deliverFailed);
-
-	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-	new Chart(chartEl, {
-		type: 'line',
-		data: {
-			datasets: [{
-				stack: 'a',
-				parsing: false,
-				label: 'Out: Succ',
-				data: format(raw.deliverSucceeded).slice().reverse(),
-				tension: 0.3,
-				pointRadius: 0,
-				borderWidth: 2,
-				borderColor: succColor,
-				borderJoinStyle: 'round',
-				borderRadius: 4,
-				backgroundColor: alpha(succColor, 0.35),
-				fill: true,
-				clip: 8,
-			}, {
-				stack: 'a',
-				parsing: false,
-				label: 'Out: Fail',
-				data: formatMinus(raw.deliverFailed).slice().reverse(),
-				tension: 0.3,
-				pointRadius: 0,
-				borderWidth: 2,
-				borderColor: failColor,
-				borderJoinStyle: 'round',
-				borderRadius: 4,
-				backgroundColor: alpha(failColor, 0.35),
-				fill: true,
-				clip: 8,
-			}],
-		},
-		options: {
-			aspectRatio: 2.5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 8,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					type: 'time',
-					stacked: true,
-					offset: false,
-					time: {
-						stepSize: 1,
-						unit: 'day',
-					},
-					grid: {
-						display: true,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: true,
-						maxRotation: 0,
-						autoSkipPadding: 16,
-					},
-					adapters: {
-						date: {
-							locale: enUS,
-						},
-					},
-					min: getDate(chartLimit).getTime(),
-				},
-				y: {
-					stacked: true,
-					position: 'left',
-					suggestedMax: 10,
-					grid: {
-						display: true,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: true,
-						//mirror: true,
-						callback: (value, index, values) => value < 0 ? -value : value,
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-				mode: 'index',
-			},
-			elements: {
-				point: {
-					hoverRadius: 5,
-					hoverBorderWidth: 2,
-				},
-			},
-			animation: false,
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-				gradient,
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-
-	new Chart(chartEl2, {
-		type: 'bar',
-		data: {
-			datasets: [{
-				parsing: false,
-				label: 'In',
-				data: format(raw.inboxReceived).slice().reverse(),
-				tension: 0.3,
-				pointRadius: 0,
-				borderWidth: 0,
-				borderJoinStyle: 'round',
-				borderRadius: 4,
-				backgroundColor: '#0cc2d6',
-				barPercentage: 0.8,
-				categoryPercentage: 0.9,
-				fill: true,
-				clip: 8,
-			}],
-		},
-		options: {
-			aspectRatio: 5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 8,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					type: 'time',
-					offset: false,
-					time: {
-						stepSize: 1,
-						unit: 'day',
-					},
-					grid: {
-						display: false,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: false,
-						maxRotation: 0,
-						autoSkipPadding: 16,
-					},
-					adapters: {
-						date: {
-							locale: enUS,
-						},
-					},
-					min: getDate(chartLimit).getTime(),
-				},
-				y: {
-					position: 'left',
-					suggestedMax: 10,
-					grid: {
-						display: true,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-				mode: 'index',
-			},
-			elements: {
-				point: {
-					hoverRadius: 5,
-					hoverBorderWidth: 2,
-				},
-			},
-			animation: false,
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler2,
-				},
-				gradient,
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-	
-	fetching = false;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	&:global {
-		> .charts {
-			> .chart {
-				padding: 16px;
-
-				&:first-child {
-					border-bottom: solid 0.5px var(--divider);
-				}
-			}
-		}
-	}
-}
-</style>
-
diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue
deleted file mode 100644
index 71f5a054b4..0000000000
--- a/packages/client/src/pages/admin/overview.federation.vue
+++ /dev/null
@@ -1,185 +0,0 @@
-<template>
-<div>
-	<MkLoading v-if="fetching"/>
-	<div v-show="!fetching" :class="$style.root">
-		<div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies">
-			<div class="pie deliver _panel">
-				<div class="title">Sub</div>
-				<XPie :data="topSubInstancesForPie" class="chart"/>
-				<div class="subTitle">Top 10</div>
-			</div>
-			<div class="pie inbox _panel">
-				<div class="title">Pub</div>
-				<XPie :data="topPubInstancesForPie" class="chart"/>
-				<div class="subTitle">Top 10</div>
-			</div>
-		</div>
-		<div v-if="!fetching" class="items">
-			<div class="item _panel sub">
-				<div class="icon"><i class="ti ti-world-download"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(federationSubActive) }}
-						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff>
-					</div>
-					<div class="label">Sub</div>
-				</div>
-			</div>
-			<div class="item _panel pub">
-				<div class="icon"><i class="ti ti-world-upload"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(federationPubActive) }}
-						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff>
-					</div>
-					<div class="label">Pub</div>
-				</div>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import XPie from './overview.pie.vue';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import MkNumberDiff from '@/components/MkNumberDiff.vue';
-import { i18n } from '@/i18n';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-
-let topSubInstancesForPie: any = $ref(null);
-let topPubInstancesForPie: any = $ref(null);
-let federationPubActive = $ref<number | null>(null);
-let federationPubActiveDiff = $ref<number | null>(null);
-let federationSubActive = $ref<number | null>(null);
-let federationSubActiveDiff = $ref<number | null>(null);
-let fetching = $ref(true);
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-	
-onMounted(async () => {
-	const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' });
-	federationPubActive = chart.pubActive[0];
-	federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
-	federationSubActive = chart.subActive[0];
-	federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
-
-	os.apiGet('federation/stats', { limit: 10 }).then(res => {
-		topSubInstancesForPie = res.topSubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followersCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
-		topPubInstancesForPie = res.topPubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followingCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
-	});
-
-	fetching = false;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-
-	&:global {
-		> .pies {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-			margin-bottom: 12px;
-
-			> .pie {
-				position: relative;
-				padding: 12px;
-
-				> .title {
-					position: absolute;
-					top: 20px;
-					left: 20px;
-					font-size: 90%;
-				}
-
-				> .chart {
-					max-height: 150px;
-				}
-
-				> .subTitle {
-					position: absolute;
-					bottom: 20px;
-					right: 20px;
-					font-size: 85%;
-				}
-			}
-		}
-
-		> .items {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-
-			> .item {
-				display: flex;
-				box-sizing: border-box;
-				padding: 12px;
-
-				> .icon {
-					display: grid;
-					place-items: center;
-					height: 100%;
-					aspect-ratio: 1;
-					margin-right: 12px;
-					background: var(--accentedBg);
-					color: var(--accent);
-					border-radius: 10px;
-				}
-
-				&.sub {
-					> .icon {
-						background: #d5ba0026;
-						color: #dfc300;
-					}
-				}
-
-				&.pub {
-					> .icon {
-						background: #00cf2326;
-						color: #00cd5b;
-					}
-				}
-
-				> .body {
-					padding: 2px 0;
-
-					> .value {
-						font-size: 1.25em;
-						font-weight: bold;
-
-						> .diff {
-							font-size: 0.65em;
-							font-weight: normal;
-						}
-					}
-
-					> .label {
-						font-size: 0.8em;
-						opacity: 0.5;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
-
diff --git a/packages/client/src/pages/admin/overview.heatmap.vue b/packages/client/src/pages/admin/overview.heatmap.vue
deleted file mode 100644
index 16d1c83b9f..0000000000
--- a/packages/client/src/pages/admin/overview.heatmap.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<template>
-<div class="_panel" :class="$style.root">
-	<MkActiveUsersHeatmap/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
-</script>
-
-<style lang="scss" module>
-.root {
-	padding: 20px;
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.instances.vue b/packages/client/src/pages/admin/overview.instances.vue
deleted file mode 100644
index 29848bf03b..0000000000
--- a/packages/client/src/pages/admin/overview.instances.vue
+++ /dev/null
@@ -1,50 +0,0 @@
-<template>
-<div class="wbrkwale">
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<MkLoading v-if="fetching"/>
-		<div v-else class="instances">
-			<MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance">
-				<MkInstanceCardMini :instance="instance"/>
-			</MkA>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
-
-const instances = ref([]);
-const fetching = ref(true);
-
-const fetch = async () => {
-	const fetchedInstances = await os.api('federation/instances', {
-		sort: '+lastCommunicatedAt',
-		limit: 6,
-	});
-	instances.value = fetchedInstances;
-	fetching.value = false;
-};
-
-useInterval(fetch, 1000 * 60, {
-	immediate: true,
-	afterMounted: true,
-});
-</script>
-
-<style lang="scss" scoped>
-.wbrkwale {
-	> .instances {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
-		grid-gap: 12px;
-
-		> .instance:hover {
-			text-decoration: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.moderators.vue b/packages/client/src/pages/admin/overview.moderators.vue
deleted file mode 100644
index a1f63c8711..0000000000
--- a/packages/client/src/pages/admin/overview.moderators.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<template>
-<div>
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<MkLoading v-if="fetching"/>
-		<div v-else :class="$style.root" class="_panel">
-			<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`">
-				<MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/>
-			</MkA>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-let moderators: any = $ref(null);
-let fetching = $ref(true);
-
-onMounted(async () => {
-	moderators = await os.api('admin/show-users', {
-		sort: '+lastActiveDate',
-		state: 'adminOrModerator',
-		limit: 30,
-	});
-
-	fetching = false;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
-	grid-gap: 12px;
-	place-content: center;
-	padding: 12px;
-
-	&:global {
-		> .user {
-			width: 100%;
-			height: 100%;
-			aspect-ratio: 1;
-
-			> .avatar {
-				width: 100%;
-				height: 100%;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.pie.vue b/packages/client/src/pages/admin/overview.pie.vue
deleted file mode 100644
index 94509cf006..0000000000
--- a/packages/client/src/pages/admin/overview.pie.vue
+++ /dev/null
@@ -1,110 +0,0 @@
-<template>
-<canvas ref="chartEl"></canvas>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	DoughnutController,
-} from 'chart.js';
-import number from '@/filters/number';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	DoughnutController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
-
-const props = defineProps<{
-	data: { name: string; value: number; color: string; onClick?: () => void }[];
-}>();
-
-const chartEl = ref<HTMLCanvasElement>(null);
-
-// フォントカラー
-Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-const { handler: externalTooltipHandler } = useChartTooltip({
-	position: 'middle',
-});
-
-let chartInstance: Chart;
-
-onMounted(() => {
-	chartInstance = new Chart(chartEl.value, {
-		type: 'doughnut',
-		data: {
-			labels: props.data.map(x => x.name),
-			datasets: [{
-				backgroundColor: props.data.map(x => x.color),
-				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
-				borderWidth: 2,
-				hoverOffset: 0,
-				data: props.data.map(x => x.value),
-			}],
-		},
-		options: {
-			layout: {
-				padding: {
-					left: 16,
-					right: 16,
-					top: 16,
-					bottom: 16,
-				},
-			},
-			onClick: (ev) => {
-				const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
-				if (hit && props.data[hit.index].onClick) {
-					props.data[hit.index].onClick();
-				}
-			},
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-			},
-		},
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/admin/overview.queue.chart.vue b/packages/client/src/pages/admin/overview.queue.chart.vue
deleted file mode 100644
index 1e095bddaa..0000000000
--- a/packages/client/src/pages/admin/overview.queue.chart.vue
+++ /dev/null
@@ -1,186 +0,0 @@
-<template>
-<canvas ref="chartEl"></canvas>
-</template>
-
-<script lang="ts" setup>
-import { watch, onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import { chartVLine } from '@/scripts/chart-vline';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
-
-const props = defineProps<{
-	type: string;
-}>();
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const chartEl = ref<HTMLCanvasElement>(null);
-
-const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
-// フォントカラー
-Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-
-let chartInstance: Chart;
-
-function setData(values) {
-	if (chartInstance == null) return;
-	for (const value of values) {
-		chartInstance.data.labels.push('');
-		chartInstance.data.datasets[0].data.push(value);
-		if (chartInstance.data.datasets[0].data.length > 100) {
-			chartInstance.data.labels.shift();
-			chartInstance.data.datasets[0].data.shift();
-		}
-	}
-	chartInstance.update();
-}
-
-function pushData(value) {
-	if (chartInstance == null) return;
-	chartInstance.data.labels.push('');
-	chartInstance.data.datasets[0].data.push(value);
-	if (chartInstance.data.datasets[0].data.length > 100) {
-		chartInstance.data.labels.shift();
-		chartInstance.data.datasets[0].data.shift();
-	}
-	chartInstance.update();
-}
-
-const label =
-	props.type === 'process' ? 'Process' :
-	props.type === 'active' ? 'Active' :
-	props.type === 'delayed' ? 'Delayed' :
-	props.type === 'waiting' ? 'Waiting' :
-	'?' as never;
-
-const color =
-	props.type === 'process' ? '#00E396' :
-	props.type === 'active' ? '#00BCD4' :
-	props.type === 'delayed' ? '#E53935' :
-	props.type === 'waiting' ? '#FFB300' :
-	'?' as never;
-
-onMounted(() => {
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-
-	chartInstance = new Chart(chartEl.value, {
-		type: 'line',
-		data: {
-			labels: [],
-			datasets: [{
-				label: label,
-				pointRadius: 0,
-				tension: 0.3,
-				borderWidth: 2,
-				borderJoinStyle: 'round',
-				borderColor: color,
-				backgroundColor: alpha(color, 0.2),
-				fill: true,
-				data: [],
-			}],
-		},
-		options: {
-			aspectRatio: 2.5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 0,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					grid: {
-						display: false,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: false,
-						maxTicksLimit: 10,
-					},
-				},
-				y: {
-					min: 0,
-					grid: {
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-			},
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-});
-
-defineExpose({
-	setData,
-	pushData,
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/admin/overview.queue.vue b/packages/client/src/pages/admin/overview.queue.vue
deleted file mode 100644
index 72ebddc72f..0000000000
--- a/packages/client/src/pages/admin/overview.queue.vue
+++ /dev/null
@@ -1,127 +0,0 @@
-<template>
-<div :class="$style.root">
-	<div class="_table status">
-		<div class="_row">
-			<div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
-			<div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div>
-			<div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
-			<div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
-		</div>
-	</div>
-	<div class="charts">
-		<div class="chart">
-			<div class="title">Process</div>
-			<XChart ref="chartProcess" type="process"/>
-		</div>
-		<div class="chart">
-			<div class="title">Active</div>
-			<XChart ref="chartActive" type="active"/>
-		</div>
-		<div class="chart">
-			<div class="title">Delayed</div>
-			<XChart ref="chartDelayed" type="delayed"/>
-		</div>
-		<div class="chart">
-			<div class="title">Waiting</div>
-			<XChart ref="chartWaiting" type="waiting"/>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref } from 'vue';
-import XChart from './overview.queue.chart.vue';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-const connection = markRaw(stream.useChannel('queueStats'));
-
-const activeSincePrevTick = ref(0);
-const active = ref(0);
-const delayed = ref(0);
-const waiting = ref(0);
-let chartProcess = $ref<InstanceType<typeof XChart>>();
-let chartActive = $ref<InstanceType<typeof XChart>>();
-let chartDelayed = $ref<InstanceType<typeof XChart>>();
-let chartWaiting = $ref<InstanceType<typeof XChart>>();
-
-const props = defineProps<{
-	domain: string;
-}>();
-
-const onStats = (stats) => {
-	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
-	active.value = stats[props.domain].active;
-	delayed.value = stats[props.domain].delayed;
-	waiting.value = stats[props.domain].waiting;
-
-	chartProcess.pushData(stats[props.domain].activeSincePrevTick);
-	chartActive.pushData(stats[props.domain].active);
-	chartDelayed.pushData(stats[props.domain].delayed);
-	chartWaiting.pushData(stats[props.domain].waiting);
-};
-
-const onStatsLog = (statsLog) => {
-	const dataProcess = [];
-	const dataActive = [];
-	const dataDelayed = [];
-	const dataWaiting = [];
-
-	for (const stats of [...statsLog].reverse()) {
-		dataProcess.push(stats[props.domain].activeSincePrevTick);
-		dataActive.push(stats[props.domain].active);
-		dataDelayed.push(stats[props.domain].delayed);
-		dataWaiting.push(stats[props.domain].waiting);
-	}
-
-	chartProcess.setData(dataProcess);
-	chartActive.setData(dataActive);
-	chartDelayed.setData(dataDelayed);
-	chartWaiting.setData(dataWaiting);
-};
-
-onMounted(() => {
-	connection.on('stats', onStats);
-	connection.on('statsLog', onStatsLog);
-	connection.send('requestLog', {
-		id: Math.random().toString().substr(2, 8),
-		length: 100,
-	});
-});
-
-onUnmounted(() => {
-	connection.off('stats', onStats);
-	connection.off('statsLog', onStatsLog);
-	connection.dispose();
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	&:global {
-		> .status {
-			padding: 0 0 16px 0;
-		}
-
-		> .charts {
-			display: grid;
-			grid-template-columns: 1fr 1fr;
-			gap: 12px;
-
-			> .chart {
-				min-width: 0;
-				padding: 16px;
-				background: var(--panel);
-				border-radius: var(--radius);
-
-				> .title {
-					font-size: 0.85em;
-				}
-			}
-		}
-	}
-}
-</style>	
diff --git a/packages/client/src/pages/admin/overview.retention.vue b/packages/client/src/pages/admin/overview.retention.vue
deleted file mode 100644
index feac6f8118..0000000000
--- a/packages/client/src/pages/admin/overview.retention.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<template>
-<div>
-	<MkLoading v-if="fetching"/>
-	<div v-else :class="$style.root">
-		<div v-for="row in retention" class="row">
-			<div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell">
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-let retention: any = $ref(null);
-let fetching = $ref(true);
-
-function getValues(row) {
-	const data = [];
-	for (const key in row.data) {
-		data.push({
-			date: new Date(key),
-			value: number(row.data[key]),
-			percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`,
-		});
-	}
-	data.sort((a, b) => a.date > b.date);
-	return data;
-}
-
-onMounted(async () => {
-	retention = await os.apiGet('retention', {});
-
-	fetching = false;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-
-	&:global {
-
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.stats.vue b/packages/client/src/pages/admin/overview.stats.vue
deleted file mode 100644
index 4dcf7e751a..0000000000
--- a/packages/client/src/pages/admin/overview.stats.vue
+++ /dev/null
@@ -1,155 +0,0 @@
-<template>
-<div>
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<MkLoading v-if="fetching"/>
-		<div v-else :class="$style.root">
-			<div class="item _panel users">
-				<div class="icon"><i class="ti ti-users"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(stats.originalUsersCount) }}
-						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff>
-					</div>
-					<div class="label">Users</div>
-				</div>
-			</div>
-			<div class="item _panel notes">
-				<div class="icon"><i class="ti ti-pencil"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(stats.originalNotesCount) }}
-						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff>
-					</div>
-					<div class="label">Notes</div>
-				</div>
-			</div>
-			<div class="item _panel instances">
-				<div class="icon"><i class="ti ti-planet"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(stats.instances) }}
-					</div>
-					<div class="label">Instances</div>
-				</div>
-			</div>
-			<div class="item _panel online">
-				<div class="icon"><i class="ti ti-access-point"></i></div>
-				<div class="body">
-					<div class="value">
-						{{ number(onlineUsersCount) }}
-					</div>
-					<div class="label">Online</div>
-				</div>
-			</div>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import MkNumberDiff from '@/components/MkNumberDiff.vue';
-import { i18n } from '@/i18n';
-
-let stats: any = $ref(null);
-let usersComparedToThePrevDay = $ref<number>();
-let notesComparedToThePrevDay = $ref<number>();
-let onlineUsersCount = $ref(0);
-let fetching = $ref(true);
-
-onMounted(async () => {
-	const [_stats, _onlineUsersCount] = await Promise.all([
-		os.api('stats', {}),
-		os.api('get-online-users-count').then(res => res.count),
-	]);
-	stats = _stats;
-	onlineUsersCount = _onlineUsersCount;
-
-	os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
-		usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
-	});
-
-	os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
-		notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
-	});
-
-	fetching = false;
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-	grid-gap: 12px;
-
-	&:global {
-		> .item {
-			display: flex;
-			box-sizing: border-box;
-			padding: 12px;
-
-			> .icon {
-				display: grid;
-				place-items: center;
-				height: 100%;
-				aspect-ratio: 1;
-				margin-right: 12px;
-				background: var(--accentedBg);
-				color: var(--accent);
-				border-radius: 10px;
-			}
-
-			&.users {
-				> .icon {
-					background: #0088d726;
-					color: #3d96c1;
-				}
-			}
-
-			&.notes {
-				> .icon {
-					background: #86b30026;
-					color: #86b300;
-				}
-			}
-
-			&.instances {
-				> .icon {
-					background: #e96b0026;
-					color: #d76d00;
-				}
-			}
-
-			&.online {
-				> .icon {
-					background: #8a00d126;
-					color: #c01ac3;
-				}
-			}
-
-			> .body {
-				padding: 2px 0;
-
-				> .value {
-					font-size: 1.25em;
-					font-weight: bold;
-
-					> .diff {
-						font-size: 0.65em;
-						font-weight: normal;
-					}
-				}
-
-				> .label {
-					font-size: 0.8em;
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.users.vue b/packages/client/src/pages/admin/overview.users.vue
deleted file mode 100644
index 5d4be11742..0000000000
--- a/packages/client/src/pages/admin/overview.users.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<template>
-<div :class="$style.root">
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<MkLoading v-if="fetching"/>
-		<div v-else class="users">
-			<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user">
-				<MkUserCardMini :user="user"/>
-			</MkA>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import MkUserCardMini from '@/components/MkUserCardMini.vue';
-
-let newUsers = $ref(null);
-let fetching = $ref(true);
-
-const fetch = async () => {
-	const _newUsers = await os.api('admin/show-users', {
-		limit: 5,
-		sort: '+createdAt',
-		origin: 'local',
-	});
-	newUsers = _newUsers;
-	fetching = false;
-};
-
-useInterval(fetch, 1000 * 60, {
-	immediate: true,
-	afterMounted: true,
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	&:global {
-		> .users {
-			.chart-move {
-				transition: transform 1s ease;
-			}
-
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-			grid-gap: 12px;
-
-			> .user:hover {
-				text-decoration: none;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
deleted file mode 100644
index d656e55200..0000000000
--- a/packages/client/src/pages/admin/overview.vue
+++ /dev/null
@@ -1,190 +0,0 @@
-<template>
-<MkSpacer :content-max="1000">
-	<div ref="rootEl" class="edbbcaef">
-		<MkFolder class="item">
-			<template #header>Stats</template>
-			<XStats/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Active users</template>
-			<XActiveUsers/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Heatmap</template>
-			<XHeatmap/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Retention rate</template>
-			<XRetention/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Moderators</template>
-			<XModerators/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Federation</template>
-			<XFederation/>
-		</MkFolder>
-		
-		<MkFolder class="item">
-			<template #header>Instances</template>
-			<XInstances/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Ap requests</template>
-			<XApRequests/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>New users</template>
-			<XUsers/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Deliver queue</template>
-			<XQueue domain="deliver"/>
-		</MkFolder>
-
-		<MkFolder class="item">
-			<template #header>Inbox queue</template>
-			<XQueue domain="inbox"/>
-		</MkFolder>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import XFederation from './overview.federation.vue';
-import XInstances from './overview.instances.vue';
-import XQueue from './overview.queue.vue';
-import XApRequests from './overview.ap-requests.vue';
-import XUsers from './overview.users.vue';
-import XActiveUsers from './overview.active-users.vue';
-import XStats from './overview.stats.vue';
-import XRetention from './overview.retention.vue';
-import XModerators from './overview.moderators.vue';
-import XHeatmap from './overview.heatmap.vue';
-import MkTagCloud from '@/components/MkTagCloud.vue';
-import { version, url } from '@/config';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import 'chartjs-adapter-date-fns';
-import { defaultStore } from '@/store';
-import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
-import MkFolder from '@/components/MkFolder.vue';
-
-const rootEl = $ref<HTMLElement>();
-let serverInfo: any = $ref(null);
-let topSubInstancesForPie: any = $ref(null);
-let topPubInstancesForPie: any = $ref(null);
-let federationPubActive = $ref<number | null>(null);
-let federationPubActiveDiff = $ref<number | null>(null);
-let federationSubActive = $ref<number | null>(null);
-let federationSubActiveDiff = $ref<number | null>(null);
-let newUsers = $ref(null);
-let activeInstances = $shallowRef(null);
-const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
-const now = new Date();
-const filesPagination = {
-	endpoint: 'admin/drive/files' as const,
-	limit: 9,
-	noPaging: true,
-};
-
-function onInstanceClick(i) {
-	os.pageWindow(`/instance-info/${i.host}`);
-}
-
-onMounted(async () => {
-	/*
-	const magicGrid = new MagicGrid({
-		container: rootEl,
-		static: true,
-		animate: true,
-	});
-
-	magicGrid.listen();
-	*/
-
-	os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => {
-		federationPubActive = chart.pubActive[0];
-		federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
-		federationSubActive = chart.subActive[0];
-		federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
-	});
-
-	os.apiGet('federation/stats', { limit: 10 }).then(res => {
-		topSubInstancesForPie = res.topSubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followersCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
-		topPubInstancesForPie = res.topPubInstances.map(x => ({
-			name: x.host,
-			color: x.themeColor,
-			value: x.followingCount,
-			onClick: () => {
-				os.pageWindow(`/instance-info/${x.host}`);
-			},
-		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
-	});
-
-	os.api('admin/server-info').then(serverInfoResponse => {
-		serverInfo = serverInfoResponse;
-	});
-
-	os.api('admin/show-users', {
-		limit: 5,
-		sort: '+createdAt',
-	}).then(res => {
-		newUsers = res;
-	});
-
-	os.api('federation/instances', {
-		sort: '+lastCommunicatedAt',
-		limit: 25,
-	}).then(res => {
-		activeInstances = res;
-	});
-
-	nextTick(() => {
-		queueStatsConnection.send('requestLog', {
-			id: Math.random().toString().substr(2, 8),
-			length: 100,
-		});
-	});
-});
-
-onBeforeUnmount(() => {
-	queueStatsConnection.dispose();
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.dashboard,
-	icon: 'ti ti-dashboard',
-});
-</script>
-
-<style lang="scss" scoped>
-.edbbcaef {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
-	grid-gap: 16px;
-}
-</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
deleted file mode 100644
index 5d0d67980e..0000000000
--- a/packages/client/src/pages/admin/proxy-account.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<template><MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
-		<MkKeyValue class="_formBlock">
-			<template #key>{{ i18n.ts.proxyAccount }}</template>
-			<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
-		</MkKeyValue>
-
-		<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
-	</FormSuspense>
-</MkSpacer></MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import FormButton from '@/components/MkButton.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let proxyAccount: any = $ref(null);
-let proxyAccountId: any = $ref(null);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	proxyAccountId = meta.proxyAccountId;
-	if (proxyAccountId) {
-		proxyAccount = await os.api('users/show', { userId: proxyAccountId });
-	}
-}
-
-function chooseProxyAccount() {
-	os.selectUser().then(user => {
-		proxyAccount = user;
-		proxyAccountId = user.id;
-		save();
-	});
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		proxyAccountId: proxyAccountId,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.proxyAccount,
-	icon: 'ti ti-ghost',
-});
-</script>
diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue
deleted file mode 100644
index 5777674ae3..0000000000
--- a/packages/client/src/pages/admin/queue.chart.chart.vue
+++ /dev/null
@@ -1,186 +0,0 @@
-<template>
-<canvas ref="chartEl"></canvas>
-</template>
-
-<script lang="ts" setup>
-import { watch, onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip';
-import { chartVLine } from '@/scripts/chart-vline';
-
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
-
-const props = defineProps<{
-	type: string;
-}>();
-
-const alpha = (hex, a) => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
-
-const chartEl = ref<HTMLCanvasElement>(null);
-
-const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
-// フォントカラー
-Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
-
-const { handler: externalTooltipHandler } = useChartTooltip();
-
-let chartInstance: Chart;
-
-function setData(values) {
-	if (chartInstance == null) return;
-	for (const value of values) {
-		chartInstance.data.labels.push('');
-		chartInstance.data.datasets[0].data.push(value);
-		if (chartInstance.data.datasets[0].data.length > 200) {
-			chartInstance.data.labels.shift();
-			chartInstance.data.datasets[0].data.shift();
-		}
-	}
-	chartInstance.update();
-}
-
-function pushData(value) {
-	if (chartInstance == null) return;
-	chartInstance.data.labels.push('');
-	chartInstance.data.datasets[0].data.push(value);
-	if (chartInstance.data.datasets[0].data.length > 200) {
-		chartInstance.data.labels.shift();
-		chartInstance.data.datasets[0].data.shift();
-	}
-	chartInstance.update();
-}
-
-const label =
-	props.type === 'process' ? 'Process' :
-	props.type === 'active' ? 'Active' :
-	props.type === 'delayed' ? 'Delayed' :
-	props.type === 'waiting' ? 'Waiting' :
-	'?' as never;
-
-const color =
-	props.type === 'process' ? '#00E396' :
-	props.type === 'active' ? '#00BCD4' :
-	props.type === 'delayed' ? '#E53935' :
-	props.type === 'waiting' ? '#FFB300' :
-	'?' as never;
-
-onMounted(() => {
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-
-	chartInstance = new Chart(chartEl.value, {
-		type: 'line',
-		data: {
-			labels: [],
-			datasets: [{
-				label: label,
-				pointRadius: 0,
-				tension: 0.3,
-				borderWidth: 2,
-				borderJoinStyle: 'round',
-				borderColor: color,
-				backgroundColor: alpha(color, 0.2),
-				fill: true,
-				data: [],
-			}],
-		},
-		options: {
-			aspectRatio: 2.5,
-			layout: {
-				padding: {
-					left: 0,
-					right: 0,
-					top: 0,
-					bottom: 0,
-				},
-			},
-			scales: {
-				x: {
-					grid: {
-						display: true,
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-					ticks: {
-						display: false,
-						maxTicksLimit: 10,
-					},
-				},
-				y: {
-					min: 0,
-					grid: {
-						color: gridColor,
-						borderColor: 'rgb(0, 0, 0, 0)',
-					},
-				},
-			},
-			interaction: {
-				intersect: false,
-			},
-			plugins: {
-				legend: {
-					display: false,
-				},
-				tooltip: {
-					enabled: false,
-					mode: 'index',
-					animation: {
-						duration: 0,
-					},
-					external: externalTooltipHandler,
-				},
-			},
-		},
-		plugins: [chartVLine(vLineColor)],
-	});
-});
-
-defineExpose({
-	setData,
-	pushData,
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
deleted file mode 100644
index 186a22c43e..0000000000
--- a/packages/client/src/pages/admin/queue.chart.vue
+++ /dev/null
@@ -1,149 +0,0 @@
-<template>
-<div class="pumxzjhg">
-	<div class="_table status">
-		<div class="_row">
-			<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
-			<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
-			<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
-			<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
-		</div>
-	</div>
-	<div class="charts">
-		<div class="chart">
-			<div class="title">Process</div>
-			<XChart ref="chartProcess" type="process"/>
-		</div>
-		<div class="chart">
-			<div class="title">Active</div>
-			<XChart ref="chartActive" type="active"/>
-		</div>
-		<div class="chart">
-			<div class="title">Delayed</div>
-			<XChart ref="chartDelayed" type="delayed"/>
-		</div>
-		<div class="chart">
-			<div class="title">Waiting</div>
-			<XChart ref="chartWaiting" type="waiting"/>
-		</div>
-	</div>
-	<div class="jobs">
-		<div v-if="jobs.length > 0">
-			<div v-for="job in jobs" :key="job[0]">
-				<span>{{ job[0] }}</span>
-				<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
-			</div>
-		</div>
-		<span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref } from 'vue';
-import XChart from './queue.chart.chart.vue';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-const connection = markRaw(stream.useChannel('queueStats'));
-
-const activeSincePrevTick = ref(0);
-const active = ref(0);
-const delayed = ref(0);
-const waiting = ref(0);
-const jobs = ref([]);
-let chartProcess = $ref<InstanceType<typeof XChart>>();
-let chartActive = $ref<InstanceType<typeof XChart>>();
-let chartDelayed = $ref<InstanceType<typeof XChart>>();
-let chartWaiting = $ref<InstanceType<typeof XChart>>();
-
-const props = defineProps<{
-	domain: string;
-}>();
-
-const onStats = (stats) => {
-	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
-	active.value = stats[props.domain].active;
-	delayed.value = stats[props.domain].delayed;
-	waiting.value = stats[props.domain].waiting;
-
-	chartProcess.pushData(stats[props.domain].activeSincePrevTick);
-	chartActive.pushData(stats[props.domain].active);
-	chartDelayed.pushData(stats[props.domain].delayed);
-	chartWaiting.pushData(stats[props.domain].waiting);
-};
-
-const onStatsLog = (statsLog) => {
-	const dataProcess = [];
-	const dataActive = [];
-	const dataDelayed = [];
-	const dataWaiting = [];
-
-	for (const stats of [...statsLog].reverse()) {
-		dataProcess.push(stats[props.domain].activeSincePrevTick);
-		dataActive.push(stats[props.domain].active);
-		dataDelayed.push(stats[props.domain].delayed);
-		dataWaiting.push(stats[props.domain].waiting);
-	}
-
-	chartProcess.setData(dataProcess);
-	chartActive.setData(dataActive);
-	chartDelayed.setData(dataDelayed);
-	chartWaiting.setData(dataWaiting);
-};
-
-onMounted(() => {
-	os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
-		jobs.value = result;
-	});
-
-	connection.on('stats', onStats);
-	connection.on('statsLog', onStatsLog);
-	connection.send('requestLog', {
-		id: Math.random().toString().substr(2, 8),
-		length: 200,
-	});
-});
-
-onUnmounted(() => {
-	connection.off('stats', onStats);
-	connection.off('statsLog', onStatsLog);
-	connection.dispose();
-});
-</script>
-
-<style lang="scss" scoped>
-.pumxzjhg {
-	> .status {
-		padding: 16px;
-	}
-
-	> .charts {
-		display: grid;
-		grid-template-columns: 1fr 1fr;
-		gap: 16px;
-
-		> .chart {
-			min-width: 0;
-			padding: 16px;
-			background: var(--panel);
-			border-radius: var(--radius);
-
-			> .title {
-				margin-bottom: 8px;
-			}
-		}
-	}
-
-	> .jobs {
-		margin-top: 16px;
-		padding: 16px;
-		max-height: 180px;
-		overflow: auto;
-		background: var(--panel);
-		border-radius: var(--radius);
-	}
-
-}
-</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
deleted file mode 100644
index 8d19b49fc5..0000000000
--- a/packages/client/src/pages/admin/queue.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<XQueue v-if="tab === 'deliver'" domain="deliver"/>
-		<XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import XQueue from './queue.chart.vue';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import * as config from '@/config';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let tab = $ref('deliver');
-
-function clear() {
-	os.confirm({
-		type: 'warning',
-		title: i18n.ts.clearQueueConfirmTitle,
-		text: i18n.ts.clearQueueConfirmText,
-	}).then(({ canceled }) => {
-		if (canceled) return;
-
-		os.apiWithDialog('admin/queue/clear');
-	});
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-external-link',
-	text: i18n.ts.dashboard,
-	handler: () => {
-		window.open(config.url + '/queue', '_blank');
-	},
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'deliver',
-	title: 'Deliver',
-}, {
-	key: 'inbox',
-	title: 'Inbox',
-}]);
-
-definePageMetadata({
-	title: i18n.ts.jobQueue,
-	icon: 'ti ti-clock-play',
-});
-</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
deleted file mode 100644
index 4768ae67b1..0000000000
--- a/packages/client/src/pages/admin/relays.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
-			<div>{{ relay.inbox }}</div>
-			<div class="status">
-				<i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
-				<i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
-				<i v-else class="ti ti-clock icon requesting"></i>
-				<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
-			</div>
-			<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let relays: any[] = $ref([]);
-
-async function addRelay() {
-	const { canceled, result: inbox } = await os.inputText({
-		title: i18n.ts.addRelay,
-		type: 'url',
-		placeholder: i18n.ts.inboxUrl,
-	});
-	if (canceled) return;
-	os.api('admin/relays/add', {
-		inbox,
-	}).then((relay: any) => {
-		refresh();
-	}).catch((err: any) => {
-		os.alert({
-			type: 'error',
-			text: err.message || err,
-		});
-	});
-}
-
-function remove(inbox: string) {
-	os.api('admin/relays/remove', {
-		inbox,
-	}).then(() => {
-		refresh();
-	}).catch((err: any) => {
-		os.alert({
-			type: 'error',
-			text: err.message || err,
-		});
-	});
-}
-
-function refresh() {
-	os.api('admin/relays/list').then((relayList: any) => {
-		relays = relayList;
-	});
-}
-
-refresh();
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-plus',
-	text: i18n.ts.addRelay,
-	handler: addRelay,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.relays,
-	icon: 'ti ti-planet',
-});
-</script>
-
-<style lang="scss" scoped>
-.relaycxt {
-	> .status {
-		margin: 8px 0;
-
-		> .icon {
-			width: 1em;
-			margin-right: 0.75em;
-
-			&.accepted {
-				color: var(--success);
-			}
-
-			&.rejected {
-				color: var(--error);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
deleted file mode 100644
index 2682bda337..0000000000
--- a/packages/client/src/pages/admin/security.vue
+++ /dev/null
@@ -1,179 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<div class="_formRoot">
-				<FormFolder class="_formBlock">
-					<template #icon><i class="ti ti-shield"></i></template>
-					<template #label>{{ i18n.ts.botProtection }}</template>
-					<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
-					<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
-					<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
-					<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
-
-					<XBotProtection/>
-				</FormFolder>
-
-				<FormFolder class="_formBlock">
-					<template #icon><i class="ti ti-eye-off"></i></template>
-					<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
-					<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
-					<template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
-					<template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
-					<template v-else #suffix>{{ i18n.ts.none }}</template>
-
-					<div class="_formRoot">
-						<span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span>
-
-						<FormRadios v-model="sensitiveMediaDetection" class="_formBlock">
-							<option value="none">{{ i18n.ts.none }}</option>
-							<option value="all">{{ i18n.ts.all }}</option>
-							<option value="local">{{ i18n.ts.localOnly }}</option>
-							<option value="remote">{{ i18n.ts.remoteOnly }}</option>
-						</FormRadios>
-
-						<FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock">
-							<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
-							<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
-						</FormRange>
-
-						<FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock">
-							<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
-							<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
-						</FormSwitch>
-
-						<FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock">
-							<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
-							<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
-						</FormSwitch>
-
-						<!-- 現状 false positive が多すぎて実用に耐えない
-						<FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock">
-							<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
-						</FormSwitch>
-						-->
-
-						<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-					</div>
-				</FormFolder>
-
-				<FormFolder class="_formBlock">
-					<template #label>Active Email Validation</template>
-					<template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
-					<template v-else #suffix>Disabled</template>
-
-					<div class="_formRoot">
-						<span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span>
-						<FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save">
-							<template #label>Enable</template>
-						</FormSwitch>
-					</div>
-				</FormFolder>
-
-				<FormFolder class="_formBlock">
-					<template #label>Log IP address</template>
-					<template v-if="enableIpLogging" #suffix>Enabled</template>
-					<template v-else #suffix>Disabled</template>
-
-					<div class="_formRoot">
-						<FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save">
-							<template #label>Enable</template>
-						</FormSwitch>
-					</div>
-				</FormFolder>
-
-				<FormFolder class="_formBlock">
-					<template #label>Summaly Proxy</template>
-
-					<div class="_formRoot">
-						<FormInput v-model="summalyProxy" class="_formBlock">
-							<template #prefix><i class="ti ti-link"></i></template>
-							<template #label>Summaly Proxy URL</template>
-						</FormInput>
-
-						<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-					</div>
-				</FormFolder>
-			</div>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XBotProtection from './bot-protection.vue';
-import XHeader from './_header_.vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormRange from '@/components/form/range.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let summalyProxy: string = $ref('');
-let enableHcaptcha: boolean = $ref(false);
-let enableRecaptcha: boolean = $ref(false);
-let enableTurnstile: boolean = $ref(false);
-let sensitiveMediaDetection: string = $ref('none');
-let sensitiveMediaDetectionSensitivity: number = $ref(0);
-let setSensitiveFlagAutomatically: boolean = $ref(false);
-let enableSensitiveMediaDetectionForVideos: boolean = $ref(false);
-let enableIpLogging: boolean = $ref(false);
-let enableActiveEmailValidation: boolean = $ref(false);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	summalyProxy = meta.summalyProxy;
-	enableHcaptcha = meta.enableHcaptcha;
-	enableRecaptcha = meta.enableRecaptcha;
-	enableTurnstile = meta.enableTurnstile;
-	sensitiveMediaDetection = meta.sensitiveMediaDetection;
-	sensitiveMediaDetectionSensitivity =
-		meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
-		meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
-		meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
-		meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
-		meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
-	setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically;
-	enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos;
-	enableIpLogging = meta.enableIpLogging;
-	enableActiveEmailValidation = meta.enableActiveEmailValidation;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		summalyProxy,
-		sensitiveMediaDetection,
-		sensitiveMediaDetectionSensitivity:
-			sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
-			sensitiveMediaDetectionSensitivity === 1 ? 'low' :
-			sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
-			sensitiveMediaDetectionSensitivity === 3 ? 'high' :
-			sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
-			0,
-		setSensitiveFlagAutomatically,
-		enableSensitiveMediaDetectionForVideos,
-		enableIpLogging,
-		enableActiveEmailValidation,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.security,
-	icon: 'ti ti-lock',
-});
-</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
deleted file mode 100644
index 460eb92694..0000000000
--- a/packages/client/src/pages/admin/settings.vue
+++ /dev/null
@@ -1,262 +0,0 @@
-<template>
-<div>
-	<MkStickyContainer>
-		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-			<FormSuspense :p="init">
-				<div class="_formRoot">
-					<FormInput v-model="name" class="_formBlock">
-						<template #label>{{ i18n.ts.instanceName }}</template>
-					</FormInput>
-
-					<FormTextarea v-model="description" class="_formBlock">
-						<template #label>{{ i18n.ts.instanceDescription }}</template>
-					</FormTextarea>
-
-					<FormInput v-model="tosUrl" class="_formBlock">
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #label>{{ i18n.ts.tosUrl }}</template>
-					</FormInput>
-
-					<FormSplit :min-width="300">
-						<FormInput v-model="maintainerName" class="_formBlock">
-							<template #label>{{ i18n.ts.maintainerName }}</template>
-						</FormInput>
-
-						<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
-							<template #prefix><i class="ti ti-mail"></i></template>
-							<template #label>{{ i18n.ts.maintainerEmail }}</template>
-						</FormInput>
-					</FormSplit>
-
-					<FormTextarea v-model="pinnedUsers" class="_formBlock">
-						<template #label>{{ i18n.ts.pinnedUsers }}</template>
-						<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
-					</FormTextarea>
-
-					<FormSection>
-						<FormSwitch v-model="enableRegistration" class="_formBlock">
-							<template #label>{{ i18n.ts.enableRegistration }}</template>
-						</FormSwitch>
-
-						<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
-							<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
-						</FormSwitch>
-					</FormSection>
-
-					<FormSection>
-						<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
-						<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
-						<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
-					</FormSection>
-
-					<FormSection>
-						<template #label>{{ i18n.ts.theme }}</template>
-
-						<FormInput v-model="iconUrl" class="_formBlock">
-							<template #prefix><i class="ti ti-link"></i></template>
-							<template #label>{{ i18n.ts.iconUrl }}</template>
-						</FormInput>
-
-						<FormInput v-model="bannerUrl" class="_formBlock">
-							<template #prefix><i class="ti ti-link"></i></template>
-							<template #label>{{ i18n.ts.bannerUrl }}</template>
-						</FormInput>
-
-						<FormInput v-model="backgroundImageUrl" class="_formBlock">
-							<template #prefix><i class="ti ti-link"></i></template>
-							<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
-						</FormInput>
-
-						<FormInput v-model="themeColor" class="_formBlock">
-							<template #prefix><i class="ti ti-palette"></i></template>
-							<template #label>{{ i18n.ts.themeColor }}</template>
-							<template #caption>#RRGGBB</template>
-						</FormInput>
-
-						<FormTextarea v-model="defaultLightTheme" class="_formBlock">
-							<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
-							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-						</FormTextarea>
-
-						<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
-							<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
-							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-						</FormTextarea>
-					</FormSection>
-
-					<FormSection>
-						<template #label>{{ i18n.ts.files }}</template>
-
-						<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
-							<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
-							<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
-						</FormSwitch>
-
-						<FormSplit :min-width="280">
-							<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
-								<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
-								<template #suffix>MB</template>
-								<template #caption>{{ i18n.ts.inMb }}</template>
-							</FormInput>
-
-							<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
-								<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
-								<template #suffix>MB</template>
-								<template #caption>{{ i18n.ts.inMb }}</template>
-							</FormInput>
-						</FormSplit>
-					</FormSection>
-
-					<FormSection>
-						<template #label>ServiceWorker</template>
-
-						<FormSwitch v-model="enableServiceWorker" class="_formBlock">
-							<template #label>{{ i18n.ts.enableServiceworker }}</template>
-							<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
-						</FormSwitch>
-
-						<template v-if="enableServiceWorker">
-							<FormInput v-model="swPublicKey" class="_formBlock">
-								<template #prefix><i class="ti ti-key"></i></template>
-								<template #label>Public key</template>
-							</FormInput>
-
-							<FormInput v-model="swPrivateKey" class="_formBlock">
-								<template #prefix><i class="ti ti-key"></i></template>
-								<template #label>Private key</template>
-							</FormInput>
-						</template>
-					</FormSection>
-
-					<FormSection>
-						<template #label>DeepL Translation</template>
-
-						<FormInput v-model="deeplAuthKey" class="_formBlock">
-							<template #prefix><i class="ti ti-key"></i></template>
-							<template #label>DeepL Auth Key</template>
-						</FormInput>
-						<FormSwitch v-model="deeplIsPro" class="_formBlock">
-							<template #label>Pro account</template>
-						</FormSwitch>
-					</FormSection>
-				</div>
-			</FormSuspense>
-		</MkSpacer>
-	</MkStickyContainer>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XHeader from './_header_.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormInput from '@/components/form/input.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import * as os from '@/os';
-import { fetchInstance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let name: string | null = $ref(null);
-let description: string | null = $ref(null);
-let tosUrl: string | null = $ref(null);
-let maintainerName: string | null = $ref(null);
-let maintainerEmail: string | null = $ref(null);
-let iconUrl: string | null = $ref(null);
-let bannerUrl: string | null = $ref(null);
-let backgroundImageUrl: string | null = $ref(null);
-let themeColor: any = $ref(null);
-let defaultLightTheme: any = $ref(null);
-let defaultDarkTheme: any = $ref(null);
-let enableLocalTimeline: boolean = $ref(false);
-let enableGlobalTimeline: boolean = $ref(false);
-let pinnedUsers: string = $ref('');
-let cacheRemoteFiles: boolean = $ref(false);
-let localDriveCapacityMb: any = $ref(0);
-let remoteDriveCapacityMb: any = $ref(0);
-let enableRegistration: boolean = $ref(false);
-let emailRequiredForSignup: boolean = $ref(false);
-let enableServiceWorker: boolean = $ref(false);
-let swPublicKey: any = $ref(null);
-let swPrivateKey: any = $ref(null);
-let deeplAuthKey: string = $ref('');
-let deeplIsPro: boolean = $ref(false);
-
-async function init() {
-	const meta = await os.api('admin/meta');
-	name = meta.name;
-	description = meta.description;
-	tosUrl = meta.tosUrl;
-	iconUrl = meta.iconUrl;
-	bannerUrl = meta.bannerUrl;
-	backgroundImageUrl = meta.backgroundImageUrl;
-	themeColor = meta.themeColor;
-	defaultLightTheme = meta.defaultLightTheme;
-	defaultDarkTheme = meta.defaultDarkTheme;
-	maintainerName = meta.maintainerName;
-	maintainerEmail = meta.maintainerEmail;
-	enableLocalTimeline = !meta.disableLocalTimeline;
-	enableGlobalTimeline = !meta.disableGlobalTimeline;
-	pinnedUsers = meta.pinnedUsers.join('\n');
-	cacheRemoteFiles = meta.cacheRemoteFiles;
-	localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
-	remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
-	enableRegistration = !meta.disableRegistration;
-	emailRequiredForSignup = meta.emailRequiredForSignup;
-	enableServiceWorker = meta.enableServiceWorker;
-	swPublicKey = meta.swPublickey;
-	swPrivateKey = meta.swPrivateKey;
-	deeplAuthKey = meta.deeplAuthKey;
-	deeplIsPro = meta.deeplIsPro;
-}
-
-function save() {
-	os.apiWithDialog('admin/update-meta', {
-		name,
-		description,
-		tosUrl,
-		iconUrl,
-		bannerUrl,
-		backgroundImageUrl,
-		themeColor: themeColor === '' ? null : themeColor,
-		defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme,
-		defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme,
-		maintainerName,
-		maintainerEmail,
-		disableLocalTimeline: !enableLocalTimeline,
-		disableGlobalTimeline: !enableGlobalTimeline,
-		pinnedUsers: pinnedUsers.split('\n'),
-		cacheRemoteFiles,
-		localDriveCapacityMb: parseInt(localDriveCapacityMb, 10),
-		remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10),
-		disableRegistration: !enableRegistration,
-		emailRequiredForSignup,
-		enableServiceWorker,
-		swPublicKey,
-		swPrivateKey,
-		deeplAuthKey,
-		deeplIsPro,
-	}).then(() => {
-		fetchInstance();
-	});
-}
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-check',
-	text: i18n.ts.save,
-	handler: save,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.general,
-	icon: 'ti ti-settings',
-});
-</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
deleted file mode 100644
index d466e21907..0000000000
--- a/packages/client/src/pages/admin/users.vue
+++ /dev/null
@@ -1,170 +0,0 @@
-<template>
-<div>
-	<MkStickyContainer>
-		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="900">
-			<div class="lknzcolw">
-				<div class="users">
-					<div class="inputs">
-						<MkSelect v-model="sort" style="flex: 1;">
-							<template #label>{{ i18n.ts.sort }}</template>
-							<option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
-							<option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
-							<option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
-							<option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
-						</MkSelect>
-						<MkSelect v-model="state" style="flex: 1;">
-							<template #label>{{ i18n.ts.state }}</template>
-							<option value="all">{{ i18n.ts.all }}</option>
-							<option value="available">{{ i18n.ts.normal }}</option>
-							<option value="admin">{{ i18n.ts.administrator }}</option>
-							<option value="moderator">{{ i18n.ts.moderator }}</option>
-							<option value="silenced">{{ i18n.ts.silence }}</option>
-							<option value="suspended">{{ i18n.ts.suspend }}</option>
-						</MkSelect>
-						<MkSelect v-model="origin" style="flex: 1;">
-							<template #label>{{ i18n.ts.instance }}</template>
-							<option value="combined">{{ i18n.ts.all }}</option>
-							<option value="local">{{ i18n.ts.local }}</option>
-							<option value="remote">{{ i18n.ts.remote }}</option>
-						</MkSelect>
-					</div>
-					<div class="inputs">
-						<MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
-							<template #prefix>@</template>
-							<template #label>{{ i18n.ts.username }}</template>
-						</MkInput>
-						<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
-							<template #prefix>@</template>
-							<template #label>{{ i18n.ts.host }}</template>
-						</MkInput>
-					</div>
-
-					<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
-						<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
-							<MkUserCardMini :user="user"/>
-						</MkA>
-					</MkPagination>
-				</div>
-			</div>
-		</MkSpacer>
-	</MkStickyContainer>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import XHeader from './_header_.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import * as os from '@/os';
-import { lookupUser } from '@/scripts/lookup-user';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import MkUserCardMini from '@/components/MkUserCardMini.vue';
-
-let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
-
-let sort = $ref('+createdAt');
-let state = $ref('all');
-let origin = $ref('local');
-let searchUsername = $ref('');
-let searchHost = $ref('');
-const pagination = {
-	endpoint: 'admin/show-users' as const,
-	limit: 10,
-	params: computed(() => ({
-		sort: sort,
-		state: state,
-		origin: origin,
-		username: searchUsername,
-		hostname: searchHost,
-	})),
-	offsetMode: true,
-};
-
-function searchUser() {
-	os.selectUser().then(user => {
-		show(user);
-	});
-}
-
-async function addUser() {
-	const { canceled: canceled1, result: username } = await os.inputText({
-		title: i18n.ts.username,
-	});
-	if (canceled1) return;
-
-	const { canceled: canceled2, result: password } = await os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	});
-	if (canceled2) return;
-
-	os.apiWithDialog('admin/accounts/create', {
-		username: username,
-		password: password,
-	}).then(res => {
-		paginationComponent.reload();
-	});
-}
-
-function show(user) {
-	os.pageWindow(`/user-info/${user.id}`);
-}
-
-const headerActions = $computed(() => [{
-	icon: 'ti ti-search',
-	text: i18n.ts.search,
-	handler: searchUser,
-}, {
-	asFullButton: true,
-	icon: 'ti ti-plus',
-	text: i18n.ts.addUser,
-	handler: addUser,
-}, {
-	asFullButton: true,
-	icon: 'ti ti-search',
-	text: i18n.ts.lookup,
-	handler: lookupUser,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.users,
-	icon: 'ti ti-users',
-})));
-</script>
-
-<style lang="scss" scoped>
-.lknzcolw {
-	> .users {
-
-		> .inputs {
-			display: flex;
-			margin-bottom: 16px;
-
-			> * {
-				margin-right: 16px;
-
-				&:last-child {
-					margin-right: 0;
-				}
-			}
-		}
-	
-		> .users {
-			margin-top: var(--margin);
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
-			grid-gap: 12px;
-
-			> .user:hover {
-				text-decoration: none;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
deleted file mode 100644
index 6a93b3b9fa..0000000000
--- a/packages/client/src/pages/announcements.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
-			<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
-				<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
-				<div class="_content">
-					<Mfm :text="announcement.text"/>
-					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
-				</div>
-				<div v-if="$i && !announcement.isRead" class="_footer">
-					<MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ $ts.gotIt }}</MkButton>
-				</div>
-			</section>
-		</MkPagination>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'announcements' as const,
-	limit: 10,
-};
-
-// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
-function read(items, announcement, i) {
-	items[i] = {
-		...announcement,
-		isRead: true,
-	};
-	os.api('i/read-announcement', { announcementId: announcement.id });
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.announcements,
-	icon: 'ti ti-speakerphone',
-});
-</script>
-
-<style lang="scss" scoped>
-.ruryvtyk {
-	> .announcement {
-		&:not(:last-child) {
-			margin-bottom: var(--margin);
-		}
-
-		> ._content {
-			> img {
-				display: block;
-				max-height: 300px;
-				max-width: 100%;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
deleted file mode 100644
index 0b2c284c99..0000000000
--- a/packages/client/src/pages/antenna-timeline.vue
+++ /dev/null
@@ -1,128 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
-		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
-		<div class="tl _block">
-			<XTimeline
-				ref="tlEl" :key="antennaId"
-				class="tl"
-				src="antenna"
-				:antenna="antennaId"
-				:sound="true"
-				@queue="queueUpdated"
-			/>
-		</div>
-	</div>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, watch } from 'vue';
-import XTimeline from '@/components/MkTimeline.vue';
-import { scroll } from '@/scripts/scroll';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const router = useRouter();
-
-const props = defineProps<{
-	antennaId: string;
-}>();
-
-let antenna = $ref(null);
-let queue = $ref(0);
-let rootEl = $ref<HTMLElement>();
-let tlEl = $ref<InstanceType<typeof XTimeline>>();
-const keymap = $computed(() => ({
-	't': focus,
-}));
-
-function queueUpdated(q) {
-	queue = q;
-}
-
-function top() {
-	scroll(rootEl, { top: 0 });
-}
-
-async function timetravel() {
-	const { canceled, result: date } = await os.inputDate({
-		title: i18n.ts.date,
-	});
-	if (canceled) return;
-
-	tlEl.timetravel(date);
-}
-
-function settings() {
-	router.push(`/my/antennas/${props.antennaId}`);
-}
-
-function focus() {
-	tlEl.focus();
-}
-
-watch(() => props.antennaId, async () => {
-	antenna = await os.api('antennas/show', {
-		antennaId: props.antennaId,
-	});
-}, { immediate: true });
-
-const headerActions = $computed(() => antenna ? [{
-	icon: 'fas fa-calendar-alt',
-	text: i18n.ts.jumpToSpecifiedDate,
-	handler: timetravel,
-}, {
-	icon: 'ti ti-settings',
-	text: i18n.ts.settings,
-	handler: settings,
-}] : []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => antenna ? {
-	title: antenna.name,
-	icon: 'ti ti-antenna',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.tqmomfks {
-	padding: var(--margin);
-
-	> .new {
-		position: sticky;
-		top: calc(var(--stickyTop, 0px) + 16px);
-		z-index: 1000;
-		width: 100%;
-
-		> button {
-			display: block;
-			margin: var(--margin) auto 0 auto;
-			padding: 8px 16px;
-			border-radius: 32px;
-		}
-	}
-
-	> .tl {
-		background: var(--bg);
-		border-radius: var(--radius);
-		overflow: clip;
-	}
-
-	&.min-width_800px {
-		max-width: 800px;
-		margin: 0 auto;
-	}
-}
-
-@container (min-width: 800px) {
-	.tqmomfks {
-		max-width: 800px;
-		margin: 0 auto;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
deleted file mode 100644
index 1d5339b44c..0000000000
--- a/packages/client/src/pages/api-console.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="_formRoot">
-			<div class="_formBlock">
-				<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:model-value="onEndpointChange()">
-					<template #label>Endpoint</template>
-				</MkInput>
-				<MkTextarea v-model="body" class="_formBlock" code>
-					<template #label>Params (JSON or JSON5)</template>
-				</MkTextarea>
-				<MkSwitch v-model="withCredential" class="_formBlock">
-					With credential
-				</MkSwitch>
-				<MkButton class="_formBlock" primary :disabled="sending" @click="send">
-					<template v-if="sending"><MkEllipsis/></template>
-					<template v-else><i class="ti ti-send"></i> Send</template>
-				</MkButton>
-			</div>
-			<div v-if="res" class="_formBlock">
-				<MkTextarea v-model="res" code readonly tall>
-					<template #label>Response</template>
-				</MkTextarea>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import JSON5 from 'json5';
-import { Endpoints } from 'misskey-js';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const body = ref('{}');
-const endpoint = ref('');
-const endpoints = ref<any[]>([]);
-const sending = ref(false);
-const res = ref('');
-const withCredential = ref(true);
-
-os.api('endpoints').then(endpointResponse => {
-	endpoints.value = endpointResponse;
-});
-
-function send() {
-	sending.value = true;
-	const requestBody = JSON5.parse(body.value);
-	os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => {
-		sending.value = false;
-		res.value = JSON5.stringify(resp, null, 2);
-	}, err => {
-		sending.value = false;
-		res.value = JSON5.stringify(err, null, 2);
-	});
-}
-
-function onEndpointChange() {
-	os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
-		const endpointBody = {};
-		for (const p of resp.params) {
-			endpointBody[p.name] =
-				p.type === 'String' ? '' :
-				p.type === 'Number' ? 0 :
-				p.type === 'Boolean' ? false :
-				p.type === 'Array' ? [] :
-				p.type === 'Object' ? {} :
-				null;
-		}
-		body.value = JSON5.stringify(endpointBody, null, 2);
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: 'API console',
-	icon: 'ti ti-terminal-2',
-});
-</script>
diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue
deleted file mode 100644
index 1546735266..0000000000
--- a/packages/client/src/pages/auth.form.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
-<section class="_section">
-	<div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div>
-	<div class="_content">
-		<h2>{{ app.name }}</h2>
-		<p class="id">{{ app.id }}</p>
-		<p class="description">{{ app.description }}</p>
-	</div>
-	<div class="_content">
-		<h2>{{ $ts._auth.permissionAsk }}</h2>
-		<ul>
-			<li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
-		</ul>
-	</div>
-	<div class="_footer">
-		<MkButton inline @click="cancel">{{ $ts.cancel }}</MkButton>
-		<MkButton inline primary @click="accept">{{ $ts.accept }}</MkButton>
-	</div>
-</section>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-	props: ['session'],
-	computed: {
-		name(): string {
-			const el = document.createElement('div');
-			el.textContent = this.app.name;
-			return el.innerHTML;
-		},
-		app(): any {
-			return this.session.app;
-		},
-	},
-	methods: {
-		cancel() {
-			os.api('auth/deny', {
-				token: this.session.token,
-			}).then(() => {
-				this.$emit('denied');
-			});
-		},
-
-		accept() {
-			os.api('auth/accept', {
-				token: this.session.token,
-			}).then(() => {
-				this.$emit('accepted');
-			});
-		},
-	},
-});
-</script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
deleted file mode 100644
index bb55881a22..0000000000
--- a/packages/client/src/pages/auth.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<template>
-<div v-if="$i && fetching" class="">
-	<MkLoading/>
-</div>
-<div v-else-if="$i">
-	<XForm
-		v-if="state == 'waiting'"
-		ref="form"
-		class="form"
-		:session="session"
-		@denied="state = 'denied'"
-		@accepted="accepted"
-	/>
-	<div v-if="state == 'denied'" class="denied">
-		<h1>{{ $ts._auth.denied }}</h1>
-	</div>
-	<div v-if="state == 'accepted'" class="accepted">
-		<h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1>
-		<p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
-		<p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
-	</div>
-	<div v-if="state == 'fetch-session-error'" class="error">
-		<p>{{ $ts.somethingHappened }}</p>
-	</div>
-</div>
-<div v-else class="signin">
-	<MkSignin @login="onLogin"/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XForm from './auth.form.vue';
-import MkSignin from '@/components/MkSignin.vue';
-import * as os from '@/os';
-import { login } from '@/account';
-
-export default defineComponent({
-	components: {
-		XForm,
-		MkSignin,
-	},
-	props: ['token'],
-	data() {
-		return {
-			state: null,
-			session: null,
-			fetching: true,
-		};
-	},
-	mounted() {
-		if (!this.$i) return;
-
-		// Fetch session
-		os.api('auth/session/show', {
-			token: this.token,
-		}).then(session => {
-			this.session = session;
-			this.fetching = false;
-
-			// 既に連携していた場合
-			if (this.session.app.isAuthorized) {
-				os.api('auth/accept', {
-					token: this.session.token,
-				}).then(() => {
-					this.accepted();
-				});
-			} else {
-				this.state = 'waiting';
-			}
-		}).catch(error => {
-			this.state = 'fetch-session-error';
-			this.fetching = false;
-		});
-	},
-	methods: {
-		accepted() {
-			this.state = 'accepted';
-			if (this.session.app.callbackUrl) {
-				location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
-			}
-		}, onLogin(res) {
-			login(res.i);
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
deleted file mode 100644
index 5ae7e63f99..0000000000
--- a/packages/client/src/pages/channel-editor.vue
+++ /dev/null
@@ -1,122 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="_formRoot">
-			<MkInput v-model="name" class="_formBlock">
-				<template #label>{{ i18n.ts.name }}</template>
-			</MkInput>
-
-			<MkTextarea v-model="description" class="_formBlock">
-				<template #label>{{ i18n.ts.description }}</template>
-			</MkTextarea>
-
-			<div class="banner">
-				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
-				<div v-else-if="bannerUrl">
-					<img :src="bannerUrl" style="width: 100%;"/>
-					<MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton>
-				</div>
-			</div>
-			<div class="_formBlock">
-				<MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, watch } from 'vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import { selectFile } from '@/scripts/select-file';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const router = useRouter();
-
-const props = defineProps<{
-	channelId?: string;
-}>();
-
-let channel = $ref(null);
-let name = $ref(null);
-let description = $ref(null);
-let bannerUrl = $ref<string | null>(null);
-let bannerId = $ref<string | null>(null);
-
-watch(() => bannerId, async () => {
-	if (bannerId == null) {
-		bannerUrl = null;
-	} else {
-		bannerUrl = (await os.api('drive/files/show', {
-			fileId: bannerId,
-		})).url;
-	}
-});
-
-async function fetchChannel() {
-	if (props.channelId == null) return;
-
-	channel = await os.api('channels/show', {
-		channelId: props.channelId,
-	});
-
-	name = channel.name;
-	description = channel.description;
-	bannerId = channel.bannerId;
-	bannerUrl = channel.bannerUrl;
-}
-
-fetchChannel();
-
-function save() {
-	const params = {
-		name: name,
-		description: description,
-		bannerId: bannerId,
-	};
-
-	if (props.channelId) {
-		params.channelId = props.channelId;
-		os.api('channels/update', params).then(() => {
-			os.success();
-		});
-	} else {
-		os.api('channels/create', params).then(created => {
-			os.success();
-			router.push(`/channels/${created.id}`);
-		});
-	}
-}
-
-function setBannerImage(evt) {
-	selectFile(evt.currentTarget ?? evt.target, null).then(file => {
-		bannerId = file.id;
-	});
-}
-
-function removeBannerImage() {
-	bannerId = null;
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => props.channelId ? {
-	title: i18n.ts._channel.edit,
-	icon: 'ti ti-device-tv',
-} : {
-	title: i18n.ts._channel.create,
-	icon: 'ti ti-device-tv',
-}));
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
deleted file mode 100644
index f271bb270f..0000000000
--- a/packages/client/src/pages/channel.vue
+++ /dev/null
@@ -1,184 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div v-if="channel">
-			<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
-				<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
-				<button class="_button toggle" @click="() => showBanner = !showBanner">
-					<template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
-					<template v-else><i class="ti ti-chevron-down"></i></template>
-				</button>
-				<div v-if="!showBanner" class="hideOverlay">
-				</div>
-				<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
-					<div class="status">
-						<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
-						<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
-					</div>
-					<div class="fade"></div>
-				</div>
-				<div v-if="channel.description" class="description">
-					<Mfm :text="channel.description" :is-note="false" :i="$i"/>
-				</div>
-			</div>
-
-			<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
-
-			<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, watch } from 'vue';
-import MkContainer from '@/components/MkContainer.vue';
-import XPostForm from '@/components/MkPostForm.vue';
-import XTimeline from '@/components/MkTimeline.vue';
-import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const router = useRouter();
-
-const props = defineProps<{
-	channelId: string;
-}>();
-
-let channel = $ref(null);
-let showBanner = $ref(true);
-const pagination = {
-	endpoint: 'channels/timeline' as const,
-	limit: 10,
-	params: computed(() => ({
-		channelId: props.channelId,
-	})),
-};
-
-watch(() => props.channelId, async () => {
-	channel = await os.api('channels/show', {
-		channelId: props.channelId,
-	});
-}, { immediate: true });
-
-function edit() {
-	router.push(`/channels/${channel.id}/edit`);
-}
-
-const headerActions = $computed(() => channel && channel.userId ? [{
-	icon: 'ti ti-settings',
-	text: i18n.ts.edit,
-	handler: edit,
-}] : null);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => channel ? {
-	title: channel.name,
-	icon: 'ti ti-device-tv',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.wpgynlbz {
-	position: relative;
-
-	> .subscribe {
-		position: absolute;
-		z-index: 1;
-		top: 16px;
-		left: 16px;
-	}
-
-	> .toggle {
-		position: absolute;
-		z-index: 2;
-    top: 8px;
-		right: 8px;
-		font-size: 1.2em;
-		width: 48px;
-		height: 48px;
-		color: #fff;
-		background: rgba(0, 0, 0, 0.5);
-		border-radius: 100%;
-		
-		> i {
-			vertical-align: middle;
-		}
-	}
-	
-	> .banner {
-		position: relative;
-		height: 200px;
-		background-position: center;
-		background-size: cover;
-
-		> .fade {
-			position: absolute;
-			bottom: 0;
-			left: 0;
-			width: 100%;
-			height: 64px;
-			background: linear-gradient(0deg, var(--panel), var(--X15));
-		}
-
-		> .status {
-			position: absolute;
-			z-index: 1;
-			bottom: 16px;
-			right: 16px;
-			padding: 8px 12px;
-			font-size: 80%;
-			background: rgba(0, 0, 0, 0.7);
-			border-radius: 6px;
-			color: #fff;
-		}
-	}
-
-	> .description {
-		padding: 16px;
-	}
-
-	> .hideOverlay {
-		position: absolute;
-		z-index: 1;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		-webkit-backdrop-filter: var(--blur, blur(16px));
-		backdrop-filter: var(--blur, blur(16px));
-		background: rgba(0, 0, 0, 0.3);
-	}
-
-	&.hide {
-		> .subscribe {
-			display: none;
-		}
-
-		> .toggle {
-			top: 0;
-			right: 0;
-			height: 100%;
-			background: transparent;
-		}
-
-		> .banner {
-			height: 42px;
-			filter: blur(8px);
-
-			> * {
-				display: none;
-			}
-		}
-
-		> .description {
-			display: none;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
deleted file mode 100644
index 34e9dac196..0000000000
--- a/packages/client/src/pages/channels.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div v-if="tab === 'featured'" class="_content grwlizim featured">
-			<MkPagination v-slot="{items}" :pagination="featuredPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'following'" class="_content grwlizim following">
-			<MkPagination v-slot="{items}" :pagination="followingPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
-			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
-			<MkPagination v-slot="{items}" :pagination="ownedPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-			</MkPagination>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineComponent, inject } from 'vue';
-import MkChannelPreview from '@/components/MkChannelPreview.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const router = useRouter();
-
-let tab = $ref('featured');
-
-const featuredPagination = {
-	endpoint: 'channels/featured' as const,
-	noPaging: true,
-};
-const followingPagination = {
-	endpoint: 'channels/followed' as const,
-	limit: 5,
-};
-const ownedPagination = {
-	endpoint: 'channels/owned' as const,
-	limit: 5,
-};
-
-function create() {
-	router.push('/channels/new');
-}
-
-const headerActions = $computed(() => [{
-	icon: 'ti ti-plus',
-	text: i18n.ts.create,
-	handler: create,
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'featured',
-	title: i18n.ts._channel.featured,
-	icon: 'ti ti-comet',
-}, {
-	key: 'following',
-	title: i18n.ts._channel.following,
-	icon: 'ti ti-heart',
-}, {
-	key: 'owned',
-	title: i18n.ts._channel.owned,
-	icon: 'ti ti-edit',
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.channel,
-	icon: 'ti ti-device-tv',
-})));
-</script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
deleted file mode 100644
index e0fbcb6bed..0000000000
--- a/packages/client/src/pages/clip.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions"/></template>
-		<MkSpacer :content-max="800">
-		<div v-if="clip">
-			<div class="okzinsic _panel">
-				<div v-if="clip.description" class="description">
-					<Mfm :text="clip.description" :is-note="false" :i="$i"/>
-				</div>
-				<div class="user">
-					<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
-				</div>
-			</div>
-
-			<XNotes :pagination="pagination" :detail="true"/>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch, provide } from 'vue';
-import * as misskey from 'misskey-js';
-import XNotes from '@/components/MkNotes.vue';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	clipId: string,
-}>();
-
-let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>();
-const pagination = {
-	endpoint: 'clips/notes' as const,
-	limit: 10,
-	params: computed(() => ({
-		clipId: props.clipId,
-	})),
-};
-
-const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId));
-
-watch(() => props.clipId, async () => {
-	clip = await os.api('clips/show', {
-		clipId: props.clipId,
-	});
-}, {
-	immediate: true,
-}); 
-
-provide('currentClipPage', $$(clip));
-
-const headerActions = $computed(() => clip && isOwned ? [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.edit,
-	handler: async (): Promise<void> => {
-		const { canceled, result } = await os.form(clip.name, {
-			name: {
-				type: 'string',
-				label: i18n.ts.name,
-				default: clip.name,
-			},
-			description: {
-				type: 'string',
-				required: false,
-				multiline: true,
-				label: i18n.ts.description,
-				default: clip.description,
-			},
-			isPublic: {
-				type: 'boolean',
-				label: i18n.ts.public,
-				default: clip.isPublic,
-			},
-		});
-		if (canceled) return;
-
-		os.apiWithDialog('clips/update', {
-			clipId: clip.id,
-			...result,
-		});
-	},
-}, {
-	icon: 'ti ti-trash',
-	text: i18n.ts.delete,
-	danger: true,
-	handler: async (): Promise<void> => {
-		const { canceled } = await os.confirm({
-			type: 'warning',
-			text: i18n.t('deleteAreYouSure', { x: clip.name }),
-		});
-		if (canceled) return;
-
-		await os.apiWithDialog('clips/delete', {
-			clipId: clip.id,
-		});
-	},
-}] : null);
-
-definePageMetadata(computed(() => clip ? {
-	title: clip.name,
-	icon: 'ti ti-paperclip',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.okzinsic {
-	position: relative;
-	margin-bottom: var(--margin);
-
-	> .description {
-		padding: 16px;
-	}
-
-	> .user {
-		$height: 32px;
-		padding: 16px;
-		border-top: solid 0.5px var(--divider);
-		line-height: $height;
-
-		> .avatar {
-			width: $height;
-			height: $height;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
deleted file mode 100644
index 04ade5c207..0000000000
--- a/packages/client/src/pages/drive.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<template>
-<div>
-	<XDrive ref="drive" @cd="x => folder = x"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import XDrive from '@/components/MkDrive.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let folder = $ref(null);
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: folder ? folder.name : i18n.ts.drive,
-	icon: 'ti ti-cloud',
-	hideHeader: true,
-})));
-</script>
diff --git a/packages/client/src/pages/emojis.emoji.vue b/packages/client/src/pages/emojis.emoji.vue
deleted file mode 100644
index 40fe496520..0000000000
--- a/packages/client/src/pages/emojis.emoji.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-<template>
-<button class="zuvgdzyu _button" @click="menu">
-	<img :src="emoji.url" class="img" :alt="emoji.name"/>
-	<div class="body">
-		<div class="name _monospace">{{ emoji.name }}</div>
-		<div class="info">{{ emoji.aliases.join(' ') }}</div>
-	</div>
-</button>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as os from '@/os';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	emoji: Record<string, unknown>; // TODO
-}>();
-
-function menu(ev) {
-	os.popupMenu([{
-		type: 'label',
-		text: ':' + props.emoji.name + ':',
-	}, {
-		text: i18n.ts.copy,
-		icon: 'ti ti-copy',
-		action: () => {
-			copyToClipboard(`:${props.emoji.name}:`);
-			os.success();
-		},
-	}], ev.currentTarget ?? ev.target);
-}
-</script>
-
-<style lang="scss" scoped>
-.zuvgdzyu {
-	display: flex;
-	align-items: center;
-	padding: 12px;
-	text-align: left;
-	background: var(--panel);
-	border-radius: 8px;
-
-	&:hover {
-		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;
-			font-size: 0.9em;
-			text-overflow: ellipsis;
-			overflow: hidden;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/explore.featured.vue b/packages/client/src/pages/explore.featured.vue
deleted file mode 100644
index 18a371a086..0000000000
--- a/packages/client/src/pages/explore.featured.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-<MkSpacer :content-max="800">
-	<MkTab v-model="tab" style="margin-bottom: var(--margin);">
-		<option value="notes">{{ i18n.ts.notes }}</option>
-		<option value="polls">{{ i18n.ts.poll }}</option>
-	</MkTab>
-	<XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
-	<XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import XNotes from '@/components/MkNotes.vue';
-import MkTab from '@/components/MkTab.vue';
-import { i18n } from '@/i18n';
-
-const paginationForNotes = {
-	endpoint: 'notes/featured' as const,
-	limit: 10,
-	offsetMode: true,
-};
-
-const paginationForPolls = {
-	endpoint: 'notes/polls/recommendation' as const,
-	limit: 10,
-	offsetMode: true,
-};
-
-let tab = $ref('notes');
-</script>
diff --git a/packages/client/src/pages/explore.users.vue b/packages/client/src/pages/explore.users.vue
deleted file mode 100644
index bfee0a6c07..0000000000
--- a/packages/client/src/pages/explore.users.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<template>
-<MkSpacer :content-max="1200">
-	<MkTab v-model="origin" style="margin-bottom: var(--margin);">
-		<option value="local">{{ i18n.ts.local }}</option>
-		<option value="remote">{{ i18n.ts.remote }}</option>
-	</MkTab>
-	<div v-if="origin === 'local'">
-		<template v-if="tag == null">
-			<MkFolder class="_gap" persist-key="explore-pinned-users">
-				<template #header><i class="fas fa-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
-				<XUserList :pagination="pinnedUsers"/>
-			</MkFolder>
-			<MkFolder class="_gap" persist-key="explore-popular-users">
-				<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
-				<XUserList :pagination="popularUsers"/>
-			</MkFolder>
-			<MkFolder class="_gap" persist-key="explore-recently-updated-users">
-				<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
-				<XUserList :pagination="recentlyUpdatedUsers"/>
-			</MkFolder>
-			<MkFolder class="_gap" persist-key="explore-recently-registered-users">
-				<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
-				<XUserList :pagination="recentlyRegisteredUsers"/>
-			</MkFolder>
-		</template>
-	</div>
-	<div v-else>
-		<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
-			<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
-
-			<div class="vxjfqztj">
-				<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
-				<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
-			</div>
-		</MkFolder>
-
-		<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
-			<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
-			<XUserList :pagination="tagUsers"/>
-		</MkFolder>
-
-		<template v-if="tag == null">
-			<MkFolder class="_gap">
-				<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
-				<XUserList :pagination="popularUsersF"/>
-			</MkFolder>
-			<MkFolder class="_gap">
-				<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
-				<XUserList :pagination="recentlyUpdatedUsersF"/>
-			</MkFolder>
-			<MkFolder class="_gap">
-				<template #header><i class="fas fa-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
-				<XUserList :pagination="recentlyRegisteredUsersF"/>
-			</MkFolder>
-		</template>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import XUserList from '@/components/MkUserList.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkTab from '@/components/MkTab.vue';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-
-const props = defineProps<{
-	tag?: string;
-}>();
-
-let origin = $ref('local');
-let tagsEl = $ref<InstanceType<typeof MkFolder>>();
-let tagsLocal = $ref([]);
-let tagsRemote = $ref([]);
-
-watch(() => props.tag, () => {
-	if (tagsEl) tagsEl.toggleContent(props.tag == null);
-});
-
-const tagUsers = $computed(() => ({
-	endpoint: 'hashtags/users' as const,
-	limit: 30,
-	params: {
-		tag: props.tag,
-		origin: 'combined',
-		sort: '+follower',
-	},
-}));
-
-const pinnedUsers = { endpoint: 'pinned-users' };
-const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	state: 'alive',
-	origin: 'local',
-	sort: '+follower',
-} };
-const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	origin: 'local',
-	sort: '+updatedAt',
-} };
-const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	origin: 'local',
-	state: 'alive',
-	sort: '+createdAt',
-} };
-const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	state: 'alive',
-	origin: 'remote',
-	sort: '+follower',
-} };
-const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	origin: 'combined',
-	sort: '+updatedAt',
-} };
-const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
-	origin: 'combined',
-	sort: '+createdAt',
-} };
-
-os.api('hashtags/list', {
-	sort: '+attachedLocalUsers',
-	attachedToLocalUserOnly: true,
-	limit: 30,
-}).then(tags => {
-	tagsLocal = tags;
-});
-os.api('hashtags/list', {
-	sort: '+attachedRemoteUsers',
-	attachedToRemoteUserOnly: true,
-	limit: 30,
-}).then(tags => {
-	tagsRemote = tags;
-});
-</script>
-
-<style lang="scss" scoped>
-.vxjfqztj {
-	> * {
-		margin-right: 16px;
-
-		&.local {
-			font-weight: bold;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
deleted file mode 100644
index 6b0bcdaf62..0000000000
--- a/packages/client/src/pages/explore.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<div class="lznhrdub">
-		<div v-if="tab === 'featured'">
-			<XFeatured/>
-		</div>
-		<div v-else-if="tab === 'users'">
-			<XUsers/>
-		</div>
-		<div v-else-if="tab === 'search'">
-			<MkSpacer :content-max="1200">
-				<div>
-					<MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock">
-						<template #prefix><i class="ti ti-search"></i></template>
-						<template #label>{{ i18n.ts.searchUser }}</template>
-					</MkInput>
-					<MkRadios v-model="searchOrigin" class="_formBlock">
-						<option value="combined">{{ i18n.ts.all }}</option>
-						<option value="local">{{ i18n.ts.local }}</option>
-						<option value="remote">{{ i18n.ts.remote }}</option>
-					</MkRadios>
-				</div>
-
-				<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
-			</MkSpacer>
-		</div>
-	</div>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import XFeatured from './explore.featured.vue';
-import XUsers from './explore.users.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkInput from '@/components/form/input.vue';
-import MkRadios from '@/components/form/radios.vue';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import XUserList from '@/components/MkUserList.vue';
-
-const props = defineProps<{
-	tag?: string;
-}>();
-
-let tab = $ref('featured');
-let tagsEl = $ref<InstanceType<typeof MkFolder>>();
-let searchQuery = $ref(null);
-let searchOrigin = $ref('combined');
-
-watch(() => props.tag, () => {
-	if (tagsEl) tagsEl.toggleContent(props.tag == null);
-});
-
-const searchPagination = {
-	endpoint: 'users/search' as const,
-	limit: 10,
-	params: computed(() => (searchQuery && searchQuery !== '') ? {
-		query: searchQuery,
-		origin: searchOrigin,
-	} : null),
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => [{
-	key: 'featured',
-	icon: 'ti ti-bolt',
-	title: i18n.ts.featured,
-}, {
-	key: 'users',
-	icon: 'ti ti-users',
-	title: i18n.ts.users,
-}, {
-	key: 'search',
-	title: i18n.ts.search,
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.explore,
-	icon: 'ti ti-hash',
-})));
-</script>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
deleted file mode 100644
index ab47efec71..0000000000
--- a/packages/client/src/pages/favorites.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader/></template>
-	<MkSpacer :content-max="800">
-		<MkPagination ref="pagingComponent" :pagination="pagination">
-			<template #empty>
-				<div class="_fullinfo">
-					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-					<div>{{ i18n.ts.noNotes }}</div>
-				</div>
-			</template>
-
-			<template #default="{ items }">
-				<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
-					<XNote :key="item.id" :note="item.note" :class="$style.note"/>
-				</XList>
-			</template>
-		</MkPagination>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import XNote from '@/components/MkNote.vue';
-import XList from '@/components/MkDateSeparatedList.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'i/favorites' as const,
-	limit: 10,
-};
-
-const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-
-definePageMetadata({
-	title: i18n.ts.favorites,
-	icon: 'ti ti-star',
-});
-</script>
-
-<style lang="scss" module>
-.note {
-	background: var(--panel);
-	border-radius: var(--radius);
-}
-</style>
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
deleted file mode 100644
index b20679ccc1..0000000000
--- a/packages/client/src/pages/follow-requests.vue
+++ /dev/null
@@ -1,153 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader/></template>
-	<MkSpacer :content-max="800">
-		<MkPagination ref="paginationComponent" :pagination="pagination">
-			<template #empty>
-				<div class="_fullinfo">
-					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-					<div>{{ i18n.ts.noFollowRequests }}</div>
-				</div>
-			</template>
-			<template #default="{items}">
-				<div class="mk-follow-requests">
-					<div v-for="req in items" :key="req.id" class="user _panel">
-						<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
-						<div class="body">
-							<div class="name">
-								<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
-								<p class="acct">@{{ acct(req.follower) }}</p>
-							</div>
-							<div v-if="req.follower.description" class="description" :title="req.follower.description">
-								<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
-							</div>
-							<div class="actions">
-								<button class="_button" @click="accept(req.follower)"><i class="ti ti-check"></i></button>
-								<button class="_button" @click="reject(req.follower)"><i class="ti ti-x"></i></button>
-							</div>
-						</div>
-					</div>
-				</div>
-			</template>
-		</MkPagination>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import { userPage, acct } from '@/filters/user';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const paginationComponent = ref<InstanceType<typeof MkPagination>>();
-
-const pagination = {
-	endpoint: 'following/requests/list' as const,
-	limit: 10,
-};
-
-function accept(user) {
-	os.api('following/requests/accept', { userId: user.id }).then(() => {
-		paginationComponent.value.reload();
-	});
-}
-
-function reject(user) {
-	os.api('following/requests/reject', { userId: user.id }).then(() => {
-		paginationComponent.value.reload();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.followRequests,
-	icon: 'ti ti-user-plus',
-})));
-</script>
-
-<style lang="scss" scoped>
-.mk-follow-requests {
-	> .user {
-		display: flex;
-		padding: 16px;
-
-		> .avatar {
-			display: block;
-			flex-shrink: 0;
-			margin: 0 12px 0 0;
-			width: 42px;
-			height: 42px;
-			border-radius: 8px;
-		}
-
-		> .body {
-			display: flex;
-			width: calc(100% - 54px);
-			position: relative;
-
-			> .name {
-				width: 45%;
-
-				@media (max-width: 500px) {
-					width: 100%;
-				}
-
-				> .name,
-				> .acct {
-					display: block;
-					white-space: nowrap;
-					text-overflow: ellipsis;
-					overflow: hidden;
-					margin: 0;
-				}
-
-				> .name {
-					font-size: 16px;
-					line-height: 24px;
-				}
-
-				> .acct {
-					font-size: 15px;
-					line-height: 16px;
-					opacity: 0.7;
-				}
-			}
-
-			> .description {
-				width: 55%;
-				line-height: 42px;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				opacity: 0.7;
-				font-size: 14px;
-				padding-right: 40px;
-				padding-left: 8px;
-				box-sizing: border-box;
-
-				@media (max-width: 500px) {
-					display: none;
-				}
-			}
-
-			> .actions {
-				position: absolute;
-				top: 0;
-				bottom: 0;
-				right: 0;
-				margin: auto 0;
-
-				> button {
-					padding: 12px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
deleted file mode 100644
index 828246d678..0000000000
--- a/packages/client/src/pages/follow.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<template>
-<div class="mk-follow-page">
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import * as os from '@/os';
-import { mainRouter } from '@/router';
-import { i18n } from '@/i18n';
-
-async function follow(user): Promise<void> {
-	const { canceled } = await os.confirm({
-		type: 'question',
-		text: i18n.t('followConfirm', { name: user.name || user.username }),
-	});
-
-	if (canceled) {
-		window.close();
-		return;
-	}
-	
-	os.apiWithDialog('following/create', {
-		userId: user.id,
-	});
-}
-
-const acct = new URL(location.href).searchParams.get('acct');
-if (acct == null) {
-	throw new Error('acct required');
-}
-
-let promise;
-
-if (acct.startsWith('https://')) {
-	promise = os.api('ap/show', {
-		uri: acct,
-	});
-	promise.then(res => {
-		if (res.type === 'User') {
-			follow(res.object);
-		} else if (res.type === 'Note') {
-			mainRouter.push(`/notes/${res.object.id}`);
-		} else {
-			os.alert({
-				type: 'error',
-				text: 'Not a user',
-			}).then(() => {
-				window.close();
-			});
-		}
-	});
-} else {
-	promise = os.api('users/show', Acct.parse(acct));
-	promise.then(user => {
-		follow(user);
-	});
-}
-
-os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
-</script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
deleted file mode 100644
index c8111d7890..0000000000
--- a/packages/client/src/pages/gallery/edit.vue
+++ /dev/null
@@ -1,149 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<FormInput v-model="title">
-				<template #label>{{ i18n.ts.title }}</template>
-			</FormInput>
-
-			<FormTextarea v-model="description" :max="500">
-				<template #label>{{ i18n.ts.description }}</template>
-			</FormTextarea>
-
-			<div class="">
-				<div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
-					<div class="name">{{ file.name }}</div>
-					<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button>
-				</div>
-				<FormButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</FormButton>
-			</div>
-
-			<FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch>
-
-			<FormButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
-			<FormButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</FormButton>
-
-			<FormButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</FormButton>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject, watch } from 'vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInput from '@/components/form/input.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import { selectFiles } from '@/scripts/select-file';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const router = useRouter();
-
-const props = defineProps<{
-	postId?: string;
-}>();
-
-let init = $ref(null);
-let files = $ref([]);
-let description = $ref(null);
-let title = $ref(null);
-let isSensitive = $ref(false);
-
-function selectFile(evt) {
-	selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
-		files = files.concat(selected);
-	});
-}
-
-function remove(file) {
-	files = files.filter(f => f.id !== file.id);
-}
-
-async function save() {
-	if (props.postId) {
-		await os.apiWithDialog('gallery/posts/update', {
-			postId: props.postId,
-			title: title,
-			description: description,
-			fileIds: files.map(file => file.id),
-			isSensitive: isSensitive,
-		});
-		router.push(`/gallery/${props.postId}`);
-	} else {
-		const created = await os.apiWithDialog('gallery/posts/create', {
-			title: title,
-			description: description,
-			fileIds: files.map(file => file.id),
-			isSensitive: isSensitive,
-		});
-		router.push(`/gallery/${created.id}`);
-	}
-}
-
-async function del() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.deleteConfirm,
-	});
-	if (canceled) return;
-	await os.apiWithDialog('gallery/posts/delete', {
-		postId: props.postId,
-	});
-	router.push('/gallery');
-}
-
-watch(() => props.postId, () => {
-	init = () => props.postId ? os.api('gallery/posts/show', {
-		postId: props.postId,
-	}).then(post => {
-		files = post.files;
-		title = post.title;
-		description = post.description;
-		isSensitive = post.isSensitive;
-	}) : Promise.resolve(null);
-}, { immediate: true });
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => props.postId ? {
-	title: i18n.ts.edit,
-	icon: 'ti ti-pencil',
-} : {
-	title: i18n.ts.postToGallery,
-	icon: 'ti ti-pencil',
-}));
-</script>
-
-<style lang="scss" scoped>
-.wqugxsfx {
-	height: 200px;
-	background-size: contain;
-	background-position: center;
-	background-repeat: no-repeat;
-	position: relative;
-
-	> .name {
-		position: absolute;
-		top: 8px;
-		left: 9px;
-		padding: 8px;
-		background: var(--panel);
-	}
-
-	> .remove {
-		position: absolute;
-		top: 8px;
-		right: 9px;
-		padding: 8px;
-		background: var(--panel);
-	}
-}
-</style>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
deleted file mode 100644
index 24a634bab5..0000000000
--- a/packages/client/src/pages/gallery/index.vue
+++ /dev/null
@@ -1,139 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="1400">
-		<div class="_root">
-			<div v-if="tab === 'explore'">
-				<MkFolder class="_gap">
-					<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
-					<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
-						<div class="vfpdbgtk">
-							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-						</div>
-					</MkPagination>
-				</MkFolder>
-				<MkFolder class="_gap">
-					<template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template>
-					<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
-						<div class="vfpdbgtk">
-							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-						</div>
-					</MkPagination>
-				</MkFolder>
-			</div>
-			<div v-else-if="tab === 'liked'">
-				<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
-					<div class="vfpdbgtk">
-						<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
-					</div>
-				</MkPagination>
-			</div>
-			<div v-else-if="tab === 'my'">
-				<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA>
-				<MkPagination v-slot="{items}" :pagination="myPostsPagination">
-					<div class="vfpdbgtk">
-						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-					</div>
-				</MkPagination>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineComponent, watch } from 'vue';
-import XUserList from '@/components/MkUserList.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkInput from '@/components/form/input.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkTab from '@/components/MkTab.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
-import number from '@/filters/number';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import { useRouter } from '@/router';
-
-const router = useRouter();
-
-const props = defineProps<{
-	tag?: string;
-}>();
-
-let tab = $ref('explore');
-let tags = $ref([]);
-let tagsRef = $ref();
-
-const recentPostsPagination = {
-	endpoint: 'gallery/posts' as const,
-	limit: 6,
-};
-const popularPostsPagination = {
-	endpoint: 'gallery/featured' as const,
-	limit: 5,
-};
-const myPostsPagination = {
-	endpoint: 'i/gallery/posts' as const,
-	limit: 5,
-};
-const likedPostsPagination = {
-	endpoint: 'i/gallery/likes' as const,
-	limit: 5,
-};
-
-const tagUsersPagination = $computed(() => ({
-	endpoint: 'hashtags/users' as const,
-	limit: 30,
-	params: {
-		tag: this.tag,
-		origin: 'combined',
-		sort: '+follower',
-	},
-}));
-
-watch(() => props.tag, () => {
-	if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
-});
-
-const headerActions = $computed(() => [{
-	icon: 'ti ti-plus',
-	text: i18n.ts.create,
-	handler: () => {
-		router.push('/gallery/new');
-	},
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'explore',
-	title: i18n.ts.gallery,
-	icon: 'ti ti-icons',
-}, {
-	key: 'liked',
-	title: i18n.ts._gallery.liked,
-	icon: 'ti ti-heart',
-}, {
-	key: 'my',
-	title: i18n.ts._gallery.my,
-	icon: 'ti ti-edit',
-}]);
-
-definePageMetadata({
-	title: i18n.ts.gallery,
-	icon: 'ti ti-icons',
-});
-</script>
-
-<style lang="scss" scoped>
-.vfpdbgtk {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-	grid-gap: 12px;
-	margin: 0 var(--margin);
-
-	> .post {
-
-	}
-}
-</style>
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
deleted file mode 100644
index 85ab1048be..0000000000
--- a/packages/client/src/pages/gallery/post.vue
+++ /dev/null
@@ -1,265 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
-		<div class="_root">
-			<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-				<div v-if="post" class="rkxwuolj">
-					<div class="files">
-						<div v-for="file in post.files" :key="file.id" class="file">
-							<img :src="file.url"/>
-						</div>
-					</div>
-					<div class="body _block">
-						<div class="title">{{ post.title }}</div>
-						<div class="description"><Mfm :text="post.description"/></div>
-						<div class="info">
-							<i class="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
-						</div>
-						<div class="actions">
-							<div class="like">
-								<MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
-								<MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
-							</div>
-							<div class="other">
-								<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
-								<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
-								<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
-							</div>
-						</div>
-						<div class="user">
-							<MkAvatar :user="post.user" class="avatar"/>
-							<div class="name">
-								<MkUserName :user="post.user" style="display: block;"/>
-								<MkAcct :user="post.user"/>
-							</div>
-							<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
-						</div>
-					</div>
-					<MkAd :prefer="['horizontal', 'horizontal-big']"/>
-					<MkContainer :max-height="300" :foldable="true" class="other">
-						<template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template>
-						<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
-							<div class="sdrarzaf">
-								<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-							</div>
-						</MkPagination>
-					</MkContainer>
-				</div>
-				<MkError v-else-if="error" @retry="fetch()"/>
-				<MkLoading v-else/>
-			</transition>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineComponent, inject, watch } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import { url } from '@/config';
-import { useRouter } from '@/router';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const router = useRouter();
-
-const props = defineProps<{
-	postId: string;
-}>();
-
-let post = $ref(null);
-let error = $ref(null);
-const otherPostsPagination = {
-	endpoint: 'users/gallery/posts' as const,
-	limit: 6,
-	params: computed(() => ({
-		userId: post.user.id,
-	})),
-};
-
-function fetchPost() {
-	post = null;
-	os.api('gallery/posts/show', {
-		postId: props.postId,
-	}).then(_post => {
-		post = _post;
-	}).catch(_error => {
-		error = _error;
-	});
-}
-
-function share() {
-	navigator.share({
-		title: post.title,
-		text: post.description,
-		url: `${url}/gallery/${post.id}`,
-	});
-}
-
-function shareWithNote() {
-	os.post({
-		initialText: `${post.title} ${url}/gallery/${post.id}`,
-	});
-}
-
-function like() {
-	os.apiWithDialog('gallery/posts/like', {
-		postId: props.postId,
-	}).then(() => {
-		post.isLiked = true;
-		post.likedCount++;
-	});
-}
-
-async function unlike() {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.unlikeConfirm,
-	});
-	if (confirm.canceled) return;
-	os.apiWithDialog('gallery/posts/unlike', {
-		postId: props.postId,
-	}).then(() => {
-		post.isLiked = false;
-		post.likedCount--;
-	});
-}
-
-function edit() {
-	router.push(`/gallery/${post.id}/edit`);
-}
-
-watch(() => props.postId, fetchPost, { immediate: true });
-
-const headerActions = $computed(() => [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.edit,
-	handler: edit,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => post ? {
-	title: post.title,
-	avatar: post.user,
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.rkxwuolj {
-	> .files {
-		> .file {
-			> img {
-				display: block;
-				max-width: 100%;
-				max-height: 500px;
-				margin: 0 auto;
-			}
-
-			& + .file {
-				margin-top: 16px;
-			}
-		}
-	}
-
-	> .body {
-		padding: 32px;
-
-		> .title {
-			font-weight: bold;
-			font-size: 1.2em;
-			margin-bottom: 16px;
-		}
-
-		> .info {
-			margin-top: 16px;
-			font-size: 90%;
-			opacity: 0.7;
-		}
-
-		> .actions {
-			display: flex;
-			align-items: center;
-			margin-top: 16px;
-			padding: 16px 0 0 0;
-			border-top: solid 0.5px var(--divider);
-
-			> .like {
-				> .button {
-					--accent: rgb(241 97 132);
-					--X8: rgb(241 92 128);
-					--buttonBg: rgb(216 71 106 / 5%);
-					--buttonHoverBg: rgb(216 71 106 / 10%);
-					color: #ff002f;
-
-					::v-deep(.count) {
-						margin-left: 0.5em;
-					}
-				}
-			}
-
-			> .other {
-				margin-left: auto;
-
-				> button {
-					padding: 8px;
-					margin: 0 8px;
-
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-				}
-			}
-		}
-
-		> .user {
-			margin-top: 16px;
-			padding: 16px 0 0 0;
-			border-top: solid 0.5px var(--divider);
-			display: flex;
-			align-items: center;
-
-			> .avatar {
-				width: 52px;
-				height: 52px;
-			}
-
-			> .name {
-				margin: 0 0 0 12px;
-				font-size: 90%;
-			}
-
-			> .koudoku {
-				margin-left: auto;
-			}
-		}
-	}
-}
-
-.sdrarzaf {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-	grid-gap: 12px;
-	margin: var(--margin);
-
-	> .post {
-
-	}
-}
-</style>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
deleted file mode 100644
index a2a1254360..0000000000
--- a/packages/client/src/pages/instance-info.vue
+++ /dev/null
@@ -1,258 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32">
-		<div v-if="tab === 'overview'" class="_formRoot">
-			<div class="fnfelxur">
-				<img :src="faviconUrl" alt="" class="icon"/>
-				<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
-			</div>
-			<MkKeyValue :copy="host" oneline style="margin: 1em 0;">
-				<template #key>Host</template>
-				<template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
-			</MkKeyValue>
-			<MkKeyValue oneline style="margin: 1em 0;">
-				<template #key>{{ i18n.ts.software }}</template>
-				<template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template>
-			</MkKeyValue>
-			<MkKeyValue oneline style="margin: 1em 0;">
-				<template #key>{{ i18n.ts.administrator }}</template>
-				<template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template>
-			</MkKeyValue>
-			<MkKeyValue>
-				<template #key>{{ i18n.ts.description }}</template>
-				<template #value>{{ instance.description }}</template>
-			</MkKeyValue>
-
-			<FormSection v-if="iAmModerator">
-				<template #label>Moderation</template>
-				<FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch>
-				<FormSwitch v-model="isBlocked" class="_formBlock" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch>
-				<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
-			</FormSection>
-
-			<FormSection>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.registeredAt }}</template>
-					<template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.updatedAt }}</template>
-					<template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.latestRequestSentAt }}</template>
-					<template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.latestStatus }}</template>
-					<template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.latestRequestReceivedAt }}</template>
-					<template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
-				</MkKeyValue>
-			</FormSection>
-	
-			<FormSection>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>Following (Pub)</template>
-					<template #value>{{ number(instance.followingCount) }}</template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>Followers (Sub)</template>
-					<template #value>{{ number(instance.followersCount) }}</template>
-				</MkKeyValue>
-			</FormSection>
-
-			<FormSection>
-				<template #label>Well-known resources</template>
-				<FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
-				<FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
-				<FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
-				<FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
-				<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
-			</FormSection>
-		</div>
-		<div v-else-if="tab === 'chart'" class="_formRoot">
-			<div class="cmhjzshl">
-				<div class="selects">
-					<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
-						<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
-						<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
-						<option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option>
-						<option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option>
-						<option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option>
-						<option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option>
-						<option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option>
-						<option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option>
-						<option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option>
-						<option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option>
-						<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
-					</MkSelect>
-				</div>
-				<div class="charts">
-					<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
-					<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
-					<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
-					<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
-				</div>
-			</div>
-		</div>
-		<div v-else-if="tab === 'users'" class="_formRoot">
-			<MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
-				<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
-					<MkUserCardMini :user="user"/>
-				</MkA>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'raw'" class="_formRoot">
-			<MkObjectView tall :value="instance">
-			</MkObjectView>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import MkChart from '@/components/MkChart.vue';
-import MkObjectView from '@/components/MkObjectView.vue';
-import FormLink from '@/components/form/link.vue';
-import MkLink from '@/components/MkLink.vue';
-import MkButton from '@/components/MkButton.vue';
-import FormSection from '@/components/form/section.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkSelect from '@/components/form/select.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import bytes from '@/filters/bytes';
-import { iAmModerator } from '@/account';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import MkUserCardMini from '@/components/MkUserCardMini.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const props = defineProps<{
-	host: string;
-}>();
-
-let tab = $ref('overview');
-let chartSrc = $ref('instance-requests');
-let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null);
-let instance = $ref<misskey.entities.Instance | null>(null);
-let suspended = $ref(false);
-let isBlocked = $ref(false);
-let faviconUrl = $ref(null);
-
-const usersPagination = {
-	endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
-	limit: 10,
-	params: {
-		sort: '+updatedAt',
-		state: 'all',
-		hostname: props.host,
-	},
-	offsetMode: true,
-};
-
-async function fetch() {
-	instance = await os.api('federation/show-instance', {
-		host: props.host,
-	});
-	suspended = instance.isSuspended;
-	isBlocked = instance.isBlocked;
-	faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
-}
-
-async function toggleBlock(ev) {
-	if (meta == null) return;
-	await os.api('admin/update-meta', {
-		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
-	});
-}
-
-async function toggleSuspend(v) {
-	await os.api('admin/federation/update-instance', {
-		host: instance.host,
-		isSuspended: suspended,
-	});
-}
-
-function refreshMetadata() {
-	os.api('admin/federation/refresh-remote-instance-metadata', {
-		host: instance.host,
-	});
-	os.alert({
-		text: 'Refresh requested',
-	});
-}
-
-fetch();
-
-const headerActions = $computed(() => [{
-	text: `https://${props.host}`,
-	icon: 'ti ti-external-link',
-	handler: () => {
-		window.open(`https://${props.host}`, '_blank');
-	},
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'overview',
-	title: i18n.ts.overview,
-	icon: 'ti ti-info-circle',
-}, {
-	key: 'chart',
-	title: i18n.ts.charts,
-	icon: 'ti ti-chart-line',
-}, {
-	key: 'users',
-	title: i18n.ts.users,
-	icon: 'ti ti-users',
-}, {
-	key: 'raw',
-	title: 'Raw',
-	icon: 'ti ti-code',
-}]);
-
-definePageMetadata({
-	title: props.host,
-	icon: 'ti ti-server',
-});
-</script>
-
-<style lang="scss" scoped>
-.fnfelxur {
-	display: flex;
-	align-items: center;
-
-	> .icon {
-		display: block;
-		margin: 0 16px 0 0;
-		height: 64px;
-		border-radius: 8px;
-	}
-
-	> .name {
-		word-break: break-all;
-	}
-}
-
-.cmhjzshl {
-	> .selects {
-		display: flex;
-		margin: 0 0 16px 0;
-	}
-
-	> .charts {
-		> .label {
-			margin-bottom: 12px;
-			font-weight: bold;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
deleted file mode 100644
index 0d30998330..0000000000
--- a/packages/client/src/pages/messaging/index.vue
+++ /dev/null
@@ -1,327 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<div v-size="{ max: [400] }" class="yweeujhr">
-			<MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton>
-
-			<div v-if="messages.length > 0" class="history">
-				<MkA
-					v-for="(message, i) in messages"
-					:key="message.id"
-					v-anim="i"
-					class="message _block"
-					:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
-					:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
-					:data-index="i"
-				>
-					<div>
-						<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
-						<header v-if="message.groupId">
-							<span class="name">{{ message.group.name }}</span>
-							<MkTime :time="message.createdAt" class="time"/>
-						</header>
-						<header v-else>
-							<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
-							<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
-							<MkTime :time="message.createdAt" class="time"/>
-						</header>
-						<div class="body">
-							<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
-						</div>
-					</div>
-				</MkA>
-			</div>
-			<div v-if="!fetching && messages.length == 0" class="_fullinfo">
-				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-				<div>{{ $ts.noHistory }}</div>
-			</div>
-			<MkLoading v-if="fetching"/>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import MkButton from '@/components/MkButton.vue';
-import { acct } from '@/filters/user';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { useRouter } from '@/router';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { $i } from '@/account';
-
-const router = useRouter();
-
-let fetching = $ref(true);
-let moreFetching = $ref(false);
-let messages = $ref([]);
-let connection = $ref(null);
-
-const getAcct = Acct.toString;
-
-function isMe(message) {
-	return message.userId === $i.id;
-}
-
-function onMessage(message) {
-	if (message.recipientId) {
-		messages = messages.filter(m => !(
-			(m.recipientId === message.recipientId && m.userId === message.userId) ||
-			(m.recipientId === message.userId && m.userId === message.recipientId)));
-
-		messages.unshift(message);
-	} else if (message.groupId) {
-		messages = messages.filter(m => m.groupId !== message.groupId);
-		messages.unshift(message);
-	}
-}
-
-function onRead(ids) {
-	for (const id of ids) {
-		const found = messages.find(m => m.id === id);
-		if (found) {
-			if (found.recipientId) {
-				found.isRead = true;
-			} else if (found.groupId) {
-				found.reads.push($i.id);
-			}
-		}
-	}
-}
-
-function start(ev) {
-	os.popupMenu([{
-		text: i18n.ts.messagingWithUser,
-		icon: 'ti ti-user',
-		action: () => { startUser(); },
-	}, {
-		text: i18n.ts.messagingWithGroup,
-		icon: 'ti ti-users',
-		action: () => { startGroup(); },
-	}], ev.currentTarget ?? ev.target);
-}
-
-async function startUser() {
-	os.selectUser().then(user => {
-		router.push(`/my/messaging/${Acct.toString(user)}`);
-	});
-}
-
-async function startGroup() {
-	const groups1 = await os.api('users/groups/owned');
-	const groups2 = await os.api('users/groups/joined');
-	if (groups1.length === 0 && groups2.length === 0) {
-		os.alert({
-			type: 'warning',
-			title: i18n.ts.youHaveNoGroups,
-			text: i18n.ts.joinOrCreateGroup,
-		});
-		return;
-	}
-	const { canceled, result: group } = await os.select({
-		title: i18n.ts.group,
-		items: groups1.concat(groups2).map(group => ({
-			value: group, text: group.name,
-		})),
-	});
-	if (canceled) return;
-	router.push(`/my/messaging/group/${group.id}`);
-}
-
-onMounted(() => {
-	connection = markRaw(stream.useChannel('messagingIndex'));
-
-	connection.on('message', onMessage);
-	connection.on('read', onRead);
-
-	os.api('messaging/history', { group: false }).then(userMessages => {
-		os.api('messaging/history', { group: true }).then(groupMessages => {
-			const _messages = userMessages.concat(groupMessages);
-			_messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-			messages = _messages;
-			fetching = false;
-		});
-	});
-});
-
-onUnmounted(() => {
-	if (connection) connection.dispose();
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.messaging,
-	icon: 'ti ti-messages',
-});
-</script>
-
-<style lang="scss" scoped>
-.yweeujhr {
-
-	> .start {
-		margin: 0 auto var(--margin) auto;
-	}
-
-	> .history {
-		> .message {
-			display: block;
-			text-decoration: none;
-			margin-bottom: var(--margin);
-
-			* {
-				pointer-events: none;
-				user-select: none;
-			}
-
-			&:hover {
-				.avatar {
-					filter: saturate(200%);
-				}
-			}
-
-			&:active {
-			}
-
-			&.isRead,
-			&.isMe {
-				opacity: 0.8;
-			}
-
-			&:not(.isMe):not(.isRead) {
-				> div {
-					background-image: url("/client-assets/unread.svg");
-					background-repeat: no-repeat;
-					background-position: 0 center;
-				}
-			}
-
-			&:after {
-				content: "";
-				display: block;
-				clear: both;
-			}
-
-			> div {
-				padding: 20px 30px;
-
-				&:after {
-					content: "";
-					display: block;
-					clear: both;
-				}
-
-				> header {
-					display: flex;
-					align-items: center;
-					margin-bottom: 2px;
-					white-space: nowrap;
-					overflow: hidden;
-
-					> .name {
-						margin: 0;
-						padding: 0;
-						overflow: hidden;
-						text-overflow: ellipsis;
-						font-size: 1em;
-						font-weight: bold;
-						transition: all 0.1s ease;
-					}
-
-					> .username {
-						margin: 0 8px;
-					}
-
-					> .time {
-						margin: 0 0 0 auto;
-					}
-				}
-
-				> .avatar {
-					float: left;
-					width: 54px;
-					height: 54px;
-					margin: 0 16px 0 0;
-					border-radius: 8px;
-					transition: all 0.1s ease;
-				}
-
-				> .body {
-
-					> .text {
-						display: block;
-						margin: 0 0 0 0;
-						padding: 0;
-						overflow: hidden;
-						overflow-wrap: break-word;
-						font-size: 1.1em;
-						color: var(--faceText);
-
-						.me {
-							opacity: 0.7;
-						}
-					}
-
-					> .image {
-						display: block;
-						max-width: 100%;
-						max-height: 512px;
-					}
-				}
-			}
-		}
-	}
-
-	&.max-width_400px {
-		> .history {
-			> .message {
-				&:not(.isMe):not(.isRead) {
-					> div {
-						background-image: none;
-						border-left: solid 4px #3aa2dc;
-					}
-				}
-
-				> div {
-					padding: 16px;
-					font-size: 0.9em;
-
-					> .avatar {
-						margin: 0 12px 0 0;
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 400px) {
-	.yweeujhr {
-		> .history {
-			> .message {
-				&:not(.isMe):not(.isRead) {
-					> div {
-						background-image: none;
-						border-left: solid 4px #3aa2dc;
-					}
-				}
-
-				> div {
-					padding: 16px;
-					font-size: 0.9em;
-
-					> .avatar {
-						margin: 0 12px 0 0;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
deleted file mode 100644
index 84572815c0..0000000000
--- a/packages/client/src/pages/messaging/messaging-room.form.vue
+++ /dev/null
@@ -1,364 +0,0 @@
-<template>
-<div
-	class="pemppnzi _block"
-	@dragover.stop="onDragover"
-	@drop.stop="onDrop"
->
-	<textarea
-		ref="textEl"
-		v-model="text"
-		:placeholder="i18n.ts.inputMessageHere"
-		@keydown="onKeydown"
-		@compositionupdate="onCompositionUpdate"
-		@paste="onPaste"
-	></textarea>
-	<footer>
-		<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
-		<div class="buttons">
-			<button class="_button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
-			<button class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
-			<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
-				<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
-			</button>
-		</div>
-	</footer>
-	<input ref="fileEl" type="file" @change="onChangeFile"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import autosize from 'autosize';
-//import insertTextAtCursor from 'insert-text-at-cursor';
-import { throttle } from 'throttle-debounce';
-import { formatTimeString } from '@/scripts/format-time-string';
-import { selectFile } from '@/scripts/select-file';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-//import { Autocomplete } from '@/scripts/autocomplete';
-import { uploadFile } from '@/scripts/upload';
-
-const props = defineProps<{
-	user?: Misskey.entities.UserDetailed | null;
-	group?: Misskey.entities.UserGroup | null;
-}>();
-
-let textEl = $ref<HTMLTextAreaElement>();
-let fileEl = $ref<HTMLInputElement>();
-
-let text = $ref<string>('');
-let file = $ref<Misskey.entities.DriveFile | null>(null);
-let sending = $ref(false);
-const typing = throttle(3000, () => {
-	stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
-});
-
-let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
-let canSend = $computed(() => (text != null && text !== '') || file != null);
-
-watch([$$(text), $$(file)], saveDraft);
-
-async function onPaste(ev: ClipboardEvent) {
-	if (!ev.clipboardData) return;
-
-	const clipboardData = ev.clipboardData;
-	const items = clipboardData.items;
-
-	if (items.length === 1) {
-		if (items[0].kind === 'file') {
-			const pastedFile = items[0].getAsFile();
-			if (!pastedFile) return;
-			const lio = pastedFile.name.lastIndexOf('.');
-			const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
-			const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
-			if (formatted) upload(pastedFile, formatted);
-		}
-	} else {
-		if (items[0].kind === 'file') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.onlyOneFileCanBeAttached,
-			});
-		}
-	}
-}
-
-function onDragover(ev: DragEvent) {
-	if (!ev.dataTransfer) return;
-
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-	if (isFile || isDriveFile) {
-		ev.preventDefault();
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	}
-}
-
-function onDrop(ev: DragEvent): void {
-	if (!ev.dataTransfer) return;
-
-	// ファイルだったら
-	if (ev.dataTransfer.files.length === 1) {
-		ev.preventDefault();
-		upload(ev.dataTransfer.files[0]);
-		return;
-	} else if (ev.dataTransfer.files.length > 1) {
-		ev.preventDefault();
-		os.alert({
-			type: 'error',
-			text: i18n.ts.onlyOneFileCanBeAttached,
-		});
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		file = JSON.parse(driveFile);
-		ev.preventDefault();
-	}
-	//#endregion
-}
-
-function onKeydown(ev: KeyboardEvent) {
-	typing();
-	if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
-		send();
-	}
-}
-
-function onCompositionUpdate() {
-	typing();
-}
-
-function chooseFile(ev: MouseEvent) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
-		file = selectedFile;
-	});
-}
-
-function onChangeFile() {
-	if (fileEl.files![0]) upload(fileEl.files[0]);
-}
-
-function upload(fileToUpload: File, name?: string) {
-	uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
-		file = res;
-	});
-}
-
-function send() {
-	sending = true;
-	os.api('messaging/messages/create', {
-		userId: props.user ? props.user.id : undefined,
-		groupId: props.group ? props.group.id : undefined,
-		text: text ? text : undefined,
-		fileId: file ? file.id : undefined,
-	}).then(message => {
-		clear();
-	}).catch(err => {
-		console.error(err);
-	}).then(() => {
-		sending = false;
-	});
-}
-
-function clear() {
-	text = '';
-	file = null;
-	deleteDraft();
-}
-
-function saveDraft() {
-	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
-
-	drafts[draftKey] = {
-		updatedAt: new Date(),
-		// eslint-disable-next-line id-denylist
-		data: {
-			text: text,
-			file: file,
-		},
-	};
-
-	localStorage.setItem('message_drafts', JSON.stringify(drafts));
-}
-
-function deleteDraft() {
-	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
-
-	delete drafts[draftKey];
-
-	localStorage.setItem('message_drafts', JSON.stringify(drafts));
-}
-
-async function insertEmoji(ev: MouseEvent) {
-	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
-}
-
-onMounted(() => {
-	autosize(textEl);
-
-	// TODO: detach when unmount
-	// TODO
-	//new Autocomplete(textEl, this, { model: 'text' });
-
-	// 書きかけの投稿を復元
-	const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
-	if (draft) {
-		text = draft.data.text;
-		file = draft.data.file;
-	}
-});
-
-defineExpose({
-	file,
-	upload,
-});
-</script>
-
-<style lang="scss" scoped>
-.pemppnzi {
-	position: relative;
-
-	> textarea {
-		cursor: auto;
-		display: block;
-		width: 100%;
-		min-width: 100%;
-		max-width: 100%;
-		min-height: 80px;
-		margin: 0;
-		padding: 16px 16px 0 16px;
-		resize: none;
-		font-size: 1em;
-		font-family: inherit;
-		outline: none;
-		border: none;
-		border-radius: 0;
-		box-shadow: none;
-		background: transparent;
-		box-sizing: border-box;
-		color: var(--fg);
-	}
-
-	footer {
-		position: sticky;
-		bottom: 0;
-		background: var(--panel);
-
-		> .file {
-			padding: 8px;
-			color: var(--fg);
-			background: transparent;
-			cursor: pointer;
-		}
-	}
-
-	.files {
-		display: block;
-		margin: 0;
-		padding: 0 8px;
-		list-style: none;
-
-		&:after {
-			content: '';
-			display: block;
-			clear: both;
-		}
-
-		> li {
-			display: block;
-			float: left;
-			margin: 4px;
-			padding: 0;
-			width: 64px;
-			height: 64px;
-			background-color: #eee;
-			background-repeat: no-repeat;
-			background-position: center center;
-			background-size: cover;
-			cursor: move;
-
-			&:hover {
-				> .remove {
-					display: block;
-				}
-			}
-
-			> .remove {
-				display: none;
-				position: absolute;
-				right: -6px;
-				top: -6px;
-				margin: 0;
-				padding: 0;
-				background: transparent;
-				outline: none;
-				border: none;
-				border-radius: 0;
-				box-shadow: none;
-				cursor: pointer;
-			}
-		}
-	}
-
-	.buttons {
-		display: flex;
-
-		._button {
-			margin: 0;
-			padding: 16px;
-			font-size: 1em;
-			font-weight: normal;
-			text-decoration: none;
-			transition: color 0.1s ease;
-
-			&:hover {
-				color: var(--accent);
-			}
-
-			&:active {
-				color: var(--accentDarken);
-				transition: color 0s ease;
-			}
-		}
-
-		> .send {
-			margin-left: auto;
-			color: var(--accent);
-
-			&:hover {
-				color: var(--accentLighten);
-			}
-
-			&:active {
-				color: var(--accentDarken);
-				transition: color 0s ease;
-			}
-		}
-	}
-
-	input[type=file] {
-		display: none;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue
deleted file mode 100644
index dbf0e37b73..0000000000
--- a/packages/client/src/pages/messaging/messaging-room.message.vue
+++ /dev/null
@@ -1,367 +0,0 @@
-<template>
-<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }">
-	<MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
-	<div class="content">
-		<div class="balloon" :class="{ noText: message.text == null }">
-			<button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del">
-				<img src="/client-assets/remove.png" alt="Delete"/>
-			</button>
-			<div v-if="!message.isDeleted" class="content">
-				<Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/>
-				<div v-if="message.file" class="file">
-					<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
-						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
-						<p v-else>{{ message.file.name }}</p>
-					</a>
-				</div>
-			</div>
-			<div v-else class="content">
-				<p class="is-deleted">{{ $ts.deleted }}</p>
-			</div>
-		</div>
-		<div></div>
-		<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
-		<footer>
-			<template v-if="isGroup">
-				<span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span>
-			</template>
-			<template v-else>
-				<span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span>
-			</template>
-			<MkTime :time="message.createdAt"/>
-			<template v-if="message.is_edited"><i class="ti ti-pencil"></i></template>
-		</footer>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as mfm from 'mfm-js';
-import * as Misskey from 'misskey-js';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
-import MkUrlPreview from '@/components/MkUrlPreview.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-
-const props = defineProps<{
-	message: Misskey.entities.MessagingMessage;
-	isGroup?: boolean;
-}>();
-
-const isMe = $computed(() => props.message.userId === $i?.id);
-const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
-
-function del(): void {
-	os.api('messaging/messages/delete', {
-		messageId: props.message.id,
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.thvuemwp {
-	$me-balloon-color: var(--accent);
-
-	position: relative;
-	background-color: transparent;
-	display: flex;
-
-	> .avatar {
-		position: sticky;
-		top: calc(var(--stickyTop, 0px) + 16px);
-		display: block;
-		width: 54px;
-		height: 54px;
-		transition: all 0.1s ease;
-	}
-
-	> .content {
-		min-width: 0;
-
-		> .balloon {
-			position: relative;
-			display: inline-flex;
-			align-items: center;
-			padding: 0;
-			min-height: 38px;
-			border-radius: 16px;
-			max-width: 100%;
-
-			&:before {
-				content: "";
-				pointer-events: none;
-				display: block;
-				position: absolute;
-				top: 12px;
-			}
-
-			& + * {
-				clear: both;
-			}
-
-			&:hover {
-				> .delete-button {
-					display: block;
-				}
-			}
-
-			> .delete-button {
-				display: none;
-				position: absolute;
-				z-index: 1;
-				top: -4px;
-				right: -4px;
-				margin: 0;
-				padding: 0;
-				cursor: pointer;
-				outline: none;
-				border: none;
-				border-radius: 0;
-				box-shadow: none;
-				background: transparent;
-
-				> img {
-					vertical-align: bottom;
-					width: 16px;
-					height: 16px;
-					cursor: pointer;
-				}
-			}
-
-			> .content {
-				max-width: 100%;
-
-				> .is-deleted {
-					display: block;
-					margin: 0;
-					padding: 0;
-					overflow: hidden;
-					overflow-wrap: break-word;
-					font-size: 1em;
-					color: rgba(#000, 0.5);
-				}
-
-				> .text {
-					display: block;
-					margin: 0;
-					padding: 12px 18px;
-					overflow: hidden;
-					overflow-wrap: break-word;
-					word-break: break-word;
-					font-size: 1em;
-					color: rgba(#000, 0.8);
-
-					& + .file {
-						> a {
-							border-radius: 0 0 16px 16px;
-						}
-					}
-				}
-
-				> .file {
-					> a {
-						display: block;
-						max-width: 100%;
-						border-radius: 16px;
-						overflow: hidden;
-						text-decoration: none;
-
-						&:hover {
-							text-decoration: none;
-
-							> p {
-								background: #ccc;
-							}
-						}
-
-						> * {
-							display: block;
-							margin: 0;
-							width: 100%;
-							max-height: 512px;
-							object-fit: contain;
-							box-sizing: border-box;
-						}
-
-						> p {
-							padding: 30px;
-							text-align: center;
-							color: #555;
-							background: #ddd;
-						}
-					}
-				}
-			}
-		}
-
-		> footer {
-			display: block;
-			margin: 2px 0 0 0;
-			font-size: 0.65em;
-
-			> .read {
-				margin: 0 8px;
-			}
-
-			> i {
-				margin-left: 4px;
-			}
-		}
-	}
-
-	&:not(.isMe) {
-		padding-left: var(--margin);
-
-		> .content {
-			padding-left: 16px;
-			padding-right: 32px;
-
-			> .balloon {
-				$color: var(--messageBg);
-				background: $color;
-
-				&.noText {
-					background: transparent;
-				}
-
-				&:not(.noText):before {
-					left: -14px;
-					border-top: solid 8px transparent;
-					border-right: solid 8px $color;
-					border-bottom: solid 8px transparent;
-					border-left: solid 8px transparent;
-				}
-
-				> .content {
-					> .text {
-						color: var(--fg);
-					}
-				}
-			}
-
-			> footer {
-				text-align: left;
-			}
-		}
-	}
-
-	&.isMe {
-		flex-direction: row-reverse;
-		padding-right: var(--margin);
-		right: var(--margin); // 削除時にposition: absoluteになったときに使う
-
-		> .content {
-			padding-right: 16px;
-			padding-left: 32px;
-			text-align: right;
-
-			> .balloon {
-				background: $me-balloon-color;
-				text-align: left;
-
-				::selection {
-					color: var(--accent);
-					background-color: #fff;
-				} 
-
-				&.noText {
-					background: transparent;
-				}
-
-				&:not(.noText):before {
-					right: -14px;
-					left: auto;
-					border-top: solid 8px transparent;
-					border-right: solid 8px transparent;
-					border-bottom: solid 8px transparent;
-					border-left: solid 8px $me-balloon-color;
-				}
-
-				> .content {
-
-					> p.is-deleted {
-						color: rgba(#fff, 0.5);
-					}
-
-					> .text {
-						&, ::v-deep(*) {
-							color: var(--fgOnAccent) !important;
-						}
-					}
-				}
-			}
-
-			> footer {
-				text-align: right;
-
-				> .read {
-					user-select: none;
-				}
-			}
-		}
-	}
-
-	&.max-width_400px {
-		> .avatar {
-			width: 48px;
-			height: 48px;
-		}
-
-		> .content {
-			> .balloon {
-				> .content {
-					> .text {
-						font-size: 0.9em;
-					}
-				}
-			}
-		}
-	}
-
-	&.max-width_500px {
-		> .content {
-			> .balloon {
-				> .content {
-					> .text {
-						padding: 8px 16px;
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 400px) {
-	.thvuemwp {
-		> .avatar {
-			width: 48px;
-			height: 48px;
-		}
-
-		> .content {
-			> .balloon {
-				> .content {
-					> .text {
-						font-size: 0.9em;
-					}
-				}
-			}
-		}
-	}
-}
-
-@container (max-width: 500px) {
-	.thvuemwp {
-		> .content {
-			> .balloon {
-				> .content {
-					> .text {
-						padding: 8px 16px;
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
deleted file mode 100644
index b6eeb9260e..0000000000
--- a/packages/client/src/pages/messaging/messaging-room.vue
+++ /dev/null
@@ -1,411 +0,0 @@
-<template>
-<div
-	ref="rootEl"
-	class="_section"
-	@dragover.prevent.stop="onDragover"
-	@drop.prevent.stop="onDrop"
->
-	<div class="_content mk-messaging-room">
-		<div class="body">
-			<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
-				<template #empty>
-					<div class="_fullinfo">
-						<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-						<div>{{ i18n.ts.noMessagesYet }}</div>
-					</div>
-				</template>
-
-				<template #default="{ items: messages, fetching: pFetching }">
-					<XList
-						v-if="messages.length > 0"
-						v-slot="{ item: message }"
-						:class="{ messages: true, 'deny-move-transition': pFetching }"
-						:items="messages"
-						direction="up"
-						reversed
-					>
-						<XMessage :key="message.id" :message="message" :is-group="group != null"/>
-					</XList>
-				</template>
-			</MkPagination>
-		</div>
-		<footer>
-			<div v-if="typers.length > 0" class="typers">
-				<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
-					<template #users>
-						<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
-					</template>
-				</I18n>
-				<MkEllipsis/>
-			</div>
-			<transition :name="animation ? 'fade' : ''">
-				<div v-show="showIndicator" class="new-message">
-					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
-				</div>
-			</transition>
-			<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
-		</footer>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as Acct from 'misskey-js/built/acct';
-import XMessage from './messaging-room.message.vue';
-import XForm from './messaging-room.form.vue';
-import XList from '@/components/MkDateSeparatedList.vue';
-import MkPagination, { Paging } from '@/components/MkPagination.vue';
-import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import * as sound from '@/scripts/sound';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-import { defaultStore } from '@/store';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	userAcct?: string;
-	groupId?: string;
-}>();
-
-let rootEl = $ref<HTMLDivElement>();
-let formEl = $ref<InstanceType<typeof XForm>>();
-let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
-
-let fetching = $ref(true);
-let user: Misskey.entities.UserDetailed | null = $ref(null);
-let group: Misskey.entities.UserGroup | null = $ref(null);
-let typers: Misskey.entities.User[] = $ref([]);
-let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
-let showIndicator = $ref(false);
-const {
-	animation,
-} = defaultStore.reactiveState;
-
-let pagination: Paging | null = $ref(null);
-
-watch([() => props.userAcct, () => props.groupId], () => {
-	if (connection) connection.dispose();
-	fetch();
-});
-
-async function fetch() {
-	fetching = true;
-
-	if (props.userAcct) {
-		const acct = Acct.parse(props.userAcct);
-		user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
-		group = null;
-		
-		pagination = {
-			endpoint: 'messaging/messages',
-			limit: 20,
-			params: {
-				userId: user.id,
-			},
-			reversed: true,
-			pageEl: $$(rootEl).value,
-		};
-		connection = stream.useChannel('messaging', {
-			otherparty: user.id,
-		});
-	} else {
-		user = null;
-		group = await os.api('users/groups/show', { groupId: props.groupId });
-
-		pagination = {
-			endpoint: 'messaging/messages',
-			limit: 20,
-			params: {
-				groupId: group?.id,
-			},
-			reversed: true,
-			pageEl: $$(rootEl).value,
-		};
-		connection = stream.useChannel('messaging', {
-			group: group?.id,
-		});
-	}
-
-	connection.on('message', onMessage);
-	connection.on('read', onRead);
-	connection.on('deleted', onDeleted);
-	connection.on('typers', _typers => {
-		typers = _typers.filter(u => u.id !== $i?.id);
-	});
-
-	document.addEventListener('visibilitychange', onVisibilitychange);
-
-	nextTick(() => {
-		thisScrollToBottom();
-		window.setTimeout(() => {
-			fetching = false;
-		}, 300);
-	});
-}
-
-function onDragover(ev: DragEvent) {
-	if (!ev.dataTransfer) return;
-
-	const isFile = ev.dataTransfer.items[0].kind === 'file';
-	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-
-	if (isFile || isDriveFile) {
-		switch (ev.dataTransfer.effectAllowed) {
-			case 'all':
-			case 'uninitialized':
-			case 'copy': 
-			case 'copyLink': 
-			case 'copyMove': 
-				ev.dataTransfer.dropEffect = 'copy';
-				break;
-			case 'linkMove':
-			case 'move':
-				ev.dataTransfer.dropEffect = 'move';
-				break;
-			default:
-				ev.dataTransfer.dropEffect = 'none';
-				break;
-		}
-	} else {
-		ev.dataTransfer.dropEffect = 'none';
-	}
-}
-
-function onDrop(ev: DragEvent): void {
-	if (!ev.dataTransfer) return;
-
-	// ファイルだったら
-	if (ev.dataTransfer.files.length === 1) {
-		formEl.upload(ev.dataTransfer.files[0]);
-		return;
-	} else if (ev.dataTransfer.files.length > 1) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts.onlyOneFileCanBeAttached,
-		});
-		return;
-	}
-
-	//#region ドライブのファイル
-	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-	if (driveFile != null && driveFile !== '') {
-		const file = JSON.parse(driveFile);
-		formEl.file = file;
-	}
-	//#endregion
-}
-
-function onMessage(message) {
-	sound.play('chat');
-
-	const _isBottom = isBottomVisible(rootEl, 64);
-
-	pagingComponent.prepend(message);
-	if (message.userId !== $i?.id && !document.hidden) {
-		connection?.send('read', {
-			id: message.id,
-		});
-	}
-
-	if (_isBottom) {
-		// Scroll to bottom
-		nextTick(() => {
-			thisScrollToBottom();
-		});
-	} else if (message.userId !== $i?.id) {
-		// Notify
-		notifyNewMessage();
-	}
-}
-
-function onRead(x) {
-	if (user) {
-		if (!Array.isArray(x)) x = [x];
-		for (const id of x) {
-			if (pagingComponent.items.some(y => y.id === id)) {
-				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
-				pagingComponent.items[exist] = {
-					...pagingComponent.items[exist],
-					isRead: true,
-				};
-			}
-		}
-	} else if (group) {
-		for (const id of x.ids) {
-			if (pagingComponent.items.some(y => y.id === id)) {
-				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
-				pagingComponent.items[exist] = {
-					...pagingComponent.items[exist],
-					reads: [...pagingComponent.items[exist].reads, x.userId],
-				};
-			}
-		}
-	}
-}
-
-function onDeleted(id) {
-	const msg = pagingComponent.items.find(m => m.id === id);
-	if (msg) {
-		pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
-	}
-}
-
-function thisScrollToBottom() {
-	scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
-}
-
-function onIndicatorClick() {
-	showIndicator = false;
-	thisScrollToBottom();
-}
-
-let scrollRemove: (() => void) | null = $ref(null);
-
-function notifyNewMessage() {
-	showIndicator = true;
-
-	scrollRemove = onScrollBottom(rootEl, () => {
-		showIndicator = false;
-		scrollRemove = null;
-	});
-}
-
-function onVisibilitychange() {
-	if (document.hidden) return;
-	for (const message of pagingComponent.items) {
-		if (message.userId !== $i?.id && !message.isRead) {
-			connection?.send('read', {
-				id: message.id,
-			});
-		}
-	}
-}
-
-onMounted(() => {
-	fetch();
-});
-
-onBeforeUnmount(() => {
-	connection?.dispose();
-	document.removeEventListener('visibilitychange', onVisibilitychange);
-	if (scrollRemove) scrollRemove();
-});
-
-definePageMetadata(computed(() => !fetching ? user ? {
-	userName: user,
-	avatar: user,
-} : {
-	title: group?.name,
-	icon: 'ti ti-users',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.mk-messaging-room {
-	position: relative;
-	overflow: auto;
-
-	> .body {
-		.more {
-			display: block;
-			margin: 16px auto;
-			padding: 0 12px;
-			line-height: 24px;
-			color: #fff;
-			background: rgba(#000, 0.3);
-			border-radius: 12px;
-
-			&:hover {
-				background: rgba(#000, 0.4);
-			}
-
-			&:active {
-				background: rgba(#000, 0.5);
-			}
-
-			&.fetching {
-				cursor: wait;
-			}
-
-			> i {
-				margin-right: 4px;
-			}
-		}
-
-		.messages {
-			padding: 8px 0;
-
-			> ::v-deep(*) {
-				margin-bottom: 16px;
-			}
-		}
-	}
-
-	> footer {
-		width: 100%;
-		position: sticky;
-		z-index: 2;
-		bottom: 0;
-		padding-top: 8px;
-		bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
-
-		> .new-message {
-			width: 100%;
-			padding-bottom: 8px;
-			text-align: center;
-
-			> button {
-				display: inline-block;
-				margin: 0;
-				padding: 0 12px;
-				line-height: 32px;
-				font-size: 12px;
-				border-radius: 16px;
-
-				> i {
-					display: inline-block;
-					margin-right: 8px;
-				}
-			}
-		}
-
-		> .typers {
-			position: absolute;
-			bottom: 100%;
-			padding: 0 8px 0 8px;
-			font-size: 0.9em;
-			color: var(--fgTransparentWeak);
-
-			> .users {
-				> .user + .user:before {
-					content: ", ";
-					font-weight: normal;
-				}
-
-				> .user:last-of-type:after {
-					content: " ";
-				}
-			}
-		}
-
-		> .form {
-			max-height: 12em;
-			overflow-y: scroll;
-			border-top: solid 0.5px var(--divider);
-		}
-	}
-}
-
-.fade-enter-active, .fade-leave-active {
-	transition: opacity 0.1s;
-}
-
-.fade-enter-from, .fade-leave-to {
-	transition: opacity 0.5s;
-	opacity: 0;
-}
-</style>
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
deleted file mode 100644
index 7c85dfb7ad..0000000000
--- a/packages/client/src/pages/mfm-cheat-sheet.vue
+++ /dev/null
@@ -1,387 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader/></template>
-	<MkSpacer :content-max="800">
-		<div class="mwysmxbg">
-			<div>{{ i18n.ts._mfm.intro }}</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.mention }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.mentionDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_mention"/>
-						<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.hashtag }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.hashtagDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_hashtag"/>
-						<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.url }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.urlDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_url"/>
-						<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.link }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.linkDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_link"/>
-						<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.emoji }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.emojiDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_emoji"/>
-						<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.bold }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.boldDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_bold"/>
-						<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.small }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.smallDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_small"/>
-						<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.quote }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.quoteDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_quote"/>
-						<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.center }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.centerDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_center"/>
-						<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.inlineCode }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.inlineCodeDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_inlineCode"/>
-						<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.blockCode }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.blockCodeDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_blockCode"/>
-						<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.inlineMath }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.inlineMathDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_inlineMath"/>
-						<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<!-- deprecated
-		<div class="section _block">
-			<div class="title">{{ i18n.ts._mfm.search }}</div>
-			<div class="content">
-				<p>{{ i18n.ts._mfm.searchDescription }}</p>
-				<div class="preview">
-					<Mfm :text="preview_search"/>
-					<MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea>
-				</div>
-			</div>
-		</div>
-		-->
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.flip }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.flipDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_flip"/>
-						<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.font }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.fontDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_font"/>
-						<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.x2 }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.x2Description }}</p>
-					<div class="preview">
-						<Mfm :text="preview_x2"/>
-						<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.x3 }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.x3Description }}</p>
-					<div class="preview">
-						<Mfm :text="preview_x3"/>
-						<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.x4 }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.x4Description }}</p>
-					<div class="preview">
-						<Mfm :text="preview_x4"/>
-						<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.blur }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.blurDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_blur"/>
-						<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.jelly }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.jellyDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_jelly"/>
-						<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.tada }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.tadaDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_tada"/>
-						<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.jump }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.jumpDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_jump"/>
-						<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.bounce }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.bounceDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_bounce"/>
-						<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.spin }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.spinDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_spin"/>
-						<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.shake }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.shakeDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_shake"/>
-						<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.twitch }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.twitchDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_twitch"/>
-						<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.rainbow }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.rainbowDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_rainbow"/>
-						<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.sparkle }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.sparkleDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_sparkle"/>
-						<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.rotate }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.rotateDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_rotate"/>
-						<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
-					</div>
-				</div>
-			</div>
-			<div class="section _block">
-				<div class="title">{{ i18n.ts._mfm.plain }}</div>
-				<div class="content">
-					<p>{{ i18n.ts._mfm.plainDescription }}</p>
-					<div class="preview">
-						<Mfm :text="preview_plain"/>
-						<MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea>
-					</div>
-				</div>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineComponent } from 'vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-
-let preview_mention = $ref('@example');
-let preview_hashtag = $ref('#test');
-let preview_url = $ref('https://example.com');
-let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`);
-let preview_emoji = $ref(instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:');
-let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`);
-let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`);
-let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`);
-let preview_inlineCode = $ref('`<: "Hello, world!"`');
-let preview_blockCode = $ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```');
-let preview_inlineMath = $ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)');
-let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`);
-let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`);
-let preview_jelly = $ref('$[jelly 🍮] $[jelly.speed=5s 🍮]');
-let preview_tada = $ref('$[tada 🍮] $[tada.speed=5s 🍮]');
-let preview_jump = $ref('$[jump 🍮] $[jump.speed=5s 🍮]');
-let preview_bounce = $ref('$[bounce 🍮] $[bounce.speed=5s 🍮]');
-let preview_shake = $ref('$[shake 🍮] $[shake.speed=5s 🍮]');
-let preview_twitch = $ref('$[twitch 🍮] $[twitch.speed=5s 🍮]');
-let preview_spin = $ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]');
-let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`);
-let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`);
-let preview_x2 = $ref('$[x2 🍮]');
-let preview_x3 = $ref('$[x3 🍮]');
-let preview_x4 = $ref('$[x4 🍮]');
-let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`);
-let preview_rainbow = $ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]');
-let preview_sparkle = $ref('$[sparkle 🍮]');
-let preview_rotate = $ref('$[rotate 🍮]');
-let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>');
-
-definePageMetadata({
-	title: i18n.ts._mfm.cheatSheet,
-	icon: 'ti ti-question-circle',
-});
-</script>
-
-<style lang="scss" scoped>
-.mwysmxbg {
-	background: var(--bg);
-
-	> .section {
-		> .title {
-			position: sticky;
-			z-index: 1;
-			top: var(--stickyTop, 0px);
-			padding: 16px;
-			font-weight: bold;
-			-webkit-backdrop-filter: var(--blur, blur(10px));
-			backdrop-filter: var(--blur, blur(10px));
-			background-color: var(--X16);
-		}
-
-		> .content {
-			> p {
-				margin: 0;
-				padding: 16px;
-			}
-
-			> .preview {
-				border-top: solid 0.5px var(--divider);
-				padding: 16px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
deleted file mode 100644
index 5de072cbfa..0000000000
--- a/packages/client/src/pages/miauth.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<MkSpacer :content-max="800">
-	<div v-if="$i">
-		<div v-if="state == 'waiting'" class="waiting _section">
-			<div class="_content">
-				<MkLoading/>
-			</div>
-		</div>
-		<div v-if="state == 'denied'" class="denied _section">
-			<div class="_content">
-				<p>{{ i18n.ts._auth.denied }}</p>
-			</div>
-		</div>
-		<div v-else-if="state == 'accepted'" class="accepted _section">
-			<div class="_content">
-				<p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
-				<p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p>
-			</div>
-		</div>
-		<div v-else class="_section">
-			<div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div>
-			<div v-else class="_title">{{ i18n.ts._auth.shareAccessAsk }}</div>
-			<div class="_content">
-				<p>{{ i18n.ts._auth.permissionAsk }}</p>
-				<ul>
-					<li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li>
-				</ul>
-			</div>
-			<div class="_footer">
-				<MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton>
-				<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>
-			</div>
-		</div>
-	</div>
-	<div v-else class="signin">
-		<MkSignin @login="onLogin"/>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkSignin from '@/components/MkSignin.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { $i, login } from '@/account';
-import { appendQuery, query } from '@/scripts/url';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	session: string;
-	callback?: string;
-	name: string;
-	icon: string;
-	permission: string; // コンマ区切り
-}>();
-
-const _permissions = props.permission.split(',');
-
-let state = $ref<string | null>(null);
-
-async function accept(): Promise<void> {
-	state = 'waiting';
-	await os.api('miauth/gen-token', {
-		session: props.session,
-		name: props.name,
-		iconUrl: props.icon,
-		permission: _permissions,
-	});
-
-	state = 'accepted';
-	if (props.callback) {
-		location.href = appendQuery(props.callback, query({
-			session: props.session,
-		}));
-	}
-}
-
-function deny(): void {
-	state = 'denied';
-}
-
-function onLogin(res): void {
-	login(res.i);
-}
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
deleted file mode 100644
index 005b036696..0000000000
--- a/packages/client/src/pages/my-antennas/create.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<div class="geegznzt">
-	<XAntenna :antenna="draft" @created="onAntennaCreated"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { inject } from 'vue';
-import XAntenna from './editor.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { useRouter } from '@/router';
-
-const router = useRouter();
-
-let draft = $ref({
-	name: '',
-	src: 'all',
-	userListId: null,
-	userGroupId: null,
-	users: [],
-	keywords: [],
-	excludeKeywords: [],
-	withReplies: false,
-	caseSensitive: false,
-	withFile: false,
-	notify: false,
-});
-
-function onAntennaCreated() {
-	router.push('/my/antennas');
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.manageAntennas,
-	icon: 'ti ti-antenna',
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
deleted file mode 100644
index cb583faaeb..0000000000
--- a/packages/client/src/pages/my-antennas/edit.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<template>
-<div class="">
-	<XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { inject, watch } from 'vue';
-import XAntenna from './editor.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const router = useRouter();
-
-let antenna: any = $ref(null);
-
-const props = defineProps<{
-	antennaId: string
-}>();
-
-function onAntennaUpdated() {
-	router.push('/my/antennas');
-}
-
-os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => {
-	antenna = antennaResponse;
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.manageAntennas,
-	icon: 'ti ti-antenna',
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue
deleted file mode 100644
index a409a734b5..0000000000
--- a/packages/client/src/pages/my-antennas/editor.vue
+++ /dev/null
@@ -1,155 +0,0 @@
-<template>
-<div class="shaynizk">
-	<div class="form">
-		<MkInput v-model="name" class="_formBlock">
-			<template #label>{{ i18n.ts.name }}</template>
-		</MkInput>
-		<MkSelect v-model="src" class="_formBlock">
-			<template #label>{{ i18n.ts.antennaSource }}</template>
-			<option value="all">{{ i18n.ts._antennaSources.all }}</option>
-			<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
-			<option value="users">{{ i18n.ts._antennaSources.users }}</option>
-			<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
-			<!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>-->
-		</MkSelect>
-		<MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock">
-			<template #label>{{ i18n.ts.userList }}</template>
-			<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
-		</MkSelect>
-		<MkSelect v-else-if="src === 'group'" v-model="userGroupId" class="_formBlock">
-			<template #label>{{ i18n.ts.userGroup }}</template>
-			<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
-		</MkSelect>
-		<MkTextarea v-else-if="src === 'users'" v-model="users" class="_formBlock">
-			<template #label>{{ i18n.ts.users }}</template>
-			<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
-		</MkTextarea>
-		<MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch>
-		<MkTextarea v-model="keywords" class="_formBlock">
-			<template #label>{{ i18n.ts.antennaKeywords }}</template>
-			<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
-		</MkTextarea>
-		<MkTextarea v-model="excludeKeywords" class="_formBlock">
-			<template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
-			<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
-		</MkTextarea>
-		<MkSwitch v-model="caseSensitive" class="_formBlock">{{ i18n.ts.caseSensitive }}</MkSwitch>
-		<MkSwitch v-model="withFile" class="_formBlock">{{ i18n.ts.withFileAntenna }}</MkSwitch>
-		<MkSwitch v-model="notify" class="_formBlock">{{ i18n.ts.notifyAntenna }}</MkSwitch>
-	</div>
-	<div class="actions">
-		<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-		<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { watch } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	antenna: any
-}>();
-
-const emit = defineEmits<{
-	(ev: 'created'): void,
-	(ev: 'updated'): void,
-	(ev: 'deleted'): void,
-}>();
-
-let name: string = $ref(props.antenna.name);
-let src: string = $ref(props.antenna.src);
-let userListId: any = $ref(props.antenna.userListId);
-let userGroupId: any = $ref(props.antenna.userGroupId);
-let users: string = $ref(props.antenna.users.join('\n'));
-let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
-let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
-let caseSensitive: boolean = $ref(props.antenna.caseSensitive);
-let withReplies: boolean = $ref(props.antenna.withReplies);
-let withFile: boolean = $ref(props.antenna.withFile);
-let notify: boolean = $ref(props.antenna.notify);
-let userLists: any = $ref(null);
-let userGroups: any = $ref(null);
-
-watch(() => src, async () => {
-	if (src === 'list' && userLists === null) {
-		userLists = await os.api('users/lists/list');
-	}
-
-	if (src === 'group' && userGroups === null) {
-		const groups1 = await os.api('users/groups/owned');
-		const groups2 = await os.api('users/groups/joined');
-
-		userGroups = [...groups1, ...groups2];
-	}
-});
-
-async function saveAntenna() {
-	const antennaData = {
-		name,
-		src,
-		userListId,
-		userGroupId,
-		withReplies,
-		withFile,
-		notify,
-		caseSensitive,
-		users: users.trim().split('\n').map(x => x.trim()),
-		keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')),
-		excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
-	};
-
-	if (props.antenna.id == null) {
-		await os.apiWithDialog('antennas/create', antennaData);
-		emit('created');
-	} else {
-		antennaData['antennaId'] = props.antenna.id;
-		await os.apiWithDialog('antennas/update', antennaData);
-		emit('updated');
-	}
-}
-
-async function deleteAntenna() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: props.antenna.name }),
-	});
-	if (canceled) return;
-
-	await os.api('antennas/delete', {
-		antennaId: props.antenna.id,
-	});
-
-	os.success();
-	emit('deleted');
-}
-
-function addUser() {
-	os.selectUser().then(user => {
-		users = users.trim();
-		users += '\n@' + Acct.toString(user as any);
-		users = users.trim();
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.shaynizk {
-	> .form {
-		padding: 32px;
-	}
-
-	> .actions {
-		padding: 24px 32px;
-		border-top: solid 0.5px var(--divider);
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
deleted file mode 100644
index 9daf23f9b5..0000000000
--- a/packages/client/src/pages/my-antennas/index.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<template><MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-		<MkSpacer :content-max="700">
-	<div class="ieepwinx">
-		<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
-
-		<div class="">
-			<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
-				<MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`">
-					<div class="name">{{ antenna.name }}</div>
-				</MkA>
-			</MkPagination>
-		</div>
-	</div>
-</MkSpacer></MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'antennas/list' as const,
-	limit: 10,
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.manageAntennas,
-	icon: 'ti ti-antenna',
-});
-</script>
-
-<style lang="scss" scoped>
-.ieepwinx {
-
-	> .add {
-		margin: 0 auto 16px auto;
-	}
-
-	.ljoevbzj {
-		display: block;
-		padding: 16px;
-		margin-bottom: 8px;
-		border: solid 1px var(--divider);
-		border-radius: 6px;
-
-		&:hover {
-			border: solid 1px var(--accent);
-			text-decoration: none;
-		}
-
-		> .name {
-			font-weight: bold;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
deleted file mode 100644
index dd6b5b3a37..0000000000
--- a/packages/client/src/pages/my-clips/index.vue
+++ /dev/null
@@ -1,100 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="qtcaoidl">
-			<MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
-
-			<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list">
-				<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
-					<b>{{ item.name }}</b>
-					<div v-if="item.description" class="description">{{ item.description }}</div>
-				</MkA>
-			</MkPagination>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'clips/list' as const,
-	limit: 10,
-};
-
-const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
-
-async function create() {
-	const { canceled, result } = await os.form(i18n.ts.createNewClip, {
-		name: {
-			type: 'string',
-			label: i18n.ts.name,
-		},
-		description: {
-			type: 'string',
-			required: false,
-			multiline: true,
-			label: i18n.ts.description,
-		},
-		isPublic: {
-			type: 'boolean',
-			label: i18n.ts.public,
-			default: false,
-		},
-	});
-	if (canceled) return;
-
-	os.apiWithDialog('clips/create', result);
-
-	pagingComponent.reload();
-}
-
-function onClipCreated() {
-	pagingComponent.reload();
-}
-
-function onClipDeleted() {
-	pagingComponent.reload();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.clip,
-	icon: 'ti ti-paperclip',
-	action: {
-		icon: 'ti ti-plus',
-		handler: create,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.qtcaoidl {
-	> .add {
-		margin: 0 auto 16px auto;
-	}
-
-	> .list {
-		> .item {
-			display: block;
-			padding: 16px;
-
-			> .description {
-				margin-top: 8px;
-				padding-top: 8px;
-				border-top: solid 0.5px var(--divider);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
deleted file mode 100644
index 3476436b27..0000000000
--- a/packages/client/src/pages/my-lists/index.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="qkcjvfiv">
-			<MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
-
-			<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content">
-				<MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`">
-					<div class="name">{{ list.name }}</div>
-					<MkAvatars :user-ids="list.userIds"/>
-				</MkA>
-			</MkPagination>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkAvatars from '@/components/MkAvatars.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
-
-const pagination = {
-	endpoint: 'users/lists/list' as const,
-	limit: 10,
-};
-
-async function create() {
-	const { canceled, result: name } = await os.inputText({
-		title: i18n.ts.enterListName,
-	});
-	if (canceled) return;
-	await os.apiWithDialog('users/lists/create', { name: name });
-	pagingComponent.reload();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.manageLists,
-	icon: 'ti ti-list',
-	action: {
-		icon: 'ti ti-plus',
-		handler: create,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.qkcjvfiv {
-	> .add {
-		margin: 0 auto var(--margin) auto;
-	}
-
-	> .lists {
-		> .list {
-			display: block;
-			padding: 16px;
-			border: solid 1px var(--divider);
-			border-radius: 6px;
-
-			&:hover {
-				border: solid 1px var(--accent);
-				text-decoration: none;
-			}
-
-			> .name {
-				margin-bottom: 4px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
deleted file mode 100644
index f6234ffe44..0000000000
--- a/packages/client/src/pages/my-lists/list.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="mk-list-page">
-			<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-				<div v-if="list" class="_section">
-					<div class="_content">
-						<MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
-						<MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton>
-						<MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
-					</div>
-				</div>
-			</transition>
-
-			<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-				<div v-if="list" class="_section members _gap">
-					<div class="_title">{{ i18n.ts.members }}</div>
-					<div class="_content">
-						<div class="users">
-							<div v-for="user in users" :key="user.id" class="user _panel">
-								<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-								<div class="body">
-									<MkUserName :user="user" class="name"/>
-									<MkAcct :user="user" class="acct"/>
-								</div>
-								<div class="action">
-									<button class="_button" @click="removeUser(user)"><i class="ti ti-x"></i></button>
-								</div>
-							</div>
-						</div>
-					</div>
-				</div>
-			</transition>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { mainRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	listId: string;
-}>();
-
-let list = $ref(null);
-let users = $ref([]);
-
-function fetchList() {
-	os.api('users/lists/show', {
-		listId: props.listId,
-	}).then(_list => {
-		list = _list;
-		os.api('users/show', {
-			userIds: list.userIds,
-		}).then(_users => {
-			users = _users;
-		});
-	});
-}
-
-function addUser() {
-	os.selectUser().then(user => {
-		os.apiWithDialog('users/lists/push', {
-			listId: list.id,
-			userId: user.id,
-		}).then(() => {
-			users.push(user);
-		});
-	});
-}
-
-function removeUser(user) {
-	os.api('users/lists/pull', {
-		listId: list.id,
-		userId: user.id,
-	}).then(() => {
-		users = users.filter(x => x.id !== user.id);
-	});
-}
-
-async function renameList() {
-	const { canceled, result: name } = await os.inputText({
-		title: i18n.ts.enterListName,
-		default: list.name,
-	});
-	if (canceled) return;
-
-	await os.api('users/lists/update', {
-		listId: list.id,
-		name: name,
-	});
-
-	list.name = name;
-}
-
-async function deleteList() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: list.name }),
-	});
-	if (canceled) return;
-
-	await os.api('users/lists/delete', {
-		listId: list.id,
-	});
-	os.success();
-	mainRouter.push('/my/lists');
-}
-
-watch(() => props.listId, fetchList, { immediate: true });
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => list ? {
-	title: list.name,
-	icon: 'ti ti-list',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.mk-list-page {
-	> .members {
-		> ._content {
-			> .users {
-				> .user {
-					display: flex;
-					align-items: center;
-					padding: 16px;
-
-					> .avatar {
-						width: 50px;
-						height: 50px;
-					}
-
-					> .body {
-						flex: 1;
-						padding: 8px;
-
-						> .name {
-							display: block;
-							font-weight: bold;
-						}
-
-						> .acct {
-							opacity: 0.5;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
deleted file mode 100644
index e58e44ef79..0000000000
--- a/packages/client/src/pages/not-found.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-<template>
-<div class="ipledcug">
-	<div class="_fullinfo">
-		<img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
-		<div>{{ i18n.ts.notFoundDescription }}</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.notFound,
-	icon: 'ti ti-alert-triangle',
-});
-</script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
deleted file mode 100644
index ba2bb91239..0000000000
--- a/packages/client/src/pages/note.vue
+++ /dev/null
@@ -1,206 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<div class="fcuexfpr">
-			<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-				<div v-if="note" class="note">
-					<div v-if="showNext" class="_gap">
-						<XNotes class="_content" :pagination="nextPagination" :no-gap="true"/>
-					</div>
-
-					<div class="main _gap">
-						<MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton>
-						<div class="note _gap">
-							<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
-							<XNoteDetailed :key="note.id" v-model:note="note" class="note"/>
-						</div>
-						<div v-if="clips && clips.length > 0" class="_content clips _gap">
-							<div class="title">{{ i18n.ts.clip }}</div>
-							<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
-								<b>{{ item.name }}</b>
-								<div v-if="item.description" class="description">{{ item.description }}</div>
-								<div class="user">
-									<MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
-								</div>
-							</MkA>
-						</div>
-						<MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton>
-					</div>
-
-					<div v-if="showPrev" class="_gap">
-						<XNotes class="_content" :pagination="prevPagination" :no-gap="true"/>
-					</div>
-				</div>
-				<MkError v-else-if="error" @retry="fetch()"/>
-				<MkLoading v-else/>
-			</transition>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineComponent, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import XNote from '@/components/MkNote.vue';
-import XNoteDetailed from '@/components/MkNoteDetailed.vue';
-import XNotes from '@/components/MkNotes.vue';
-import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	noteId: string;
-}>();
-
-let note = $ref<null | misskey.entities.Note>();
-let clips = $ref();
-let hasPrev = $ref(false);
-let hasNext = $ref(false);
-let showPrev = $ref(false);
-let showNext = $ref(false);
-let error = $ref();
-
-const prevPagination = {
-	endpoint: 'users/notes' as const,
-	limit: 10,
-	params: computed(() => note ? ({
-		userId: note.userId,
-		untilId: note.id,
-	}) : null),
-};
-
-const nextPagination = {
-	reversed: true,
-	endpoint: 'users/notes' as const,
-	limit: 10,
-	params: computed(() => note ? ({
-		userId: note.userId,
-		sinceId: note.id,
-	}) : null),
-};
-
-function fetchNote() {
-	hasPrev = false;
-	hasNext = false;
-	showPrev = false;
-	showNext = false;
-	note = null;
-	os.api('notes/show', {
-		noteId: props.noteId,
-	}).then(res => {
-		note = res;
-		Promise.all([
-			os.api('notes/clips', {
-				noteId: note.id,
-			}),
-			os.api('users/notes', {
-				userId: note.userId,
-				untilId: note.id,
-				limit: 1,
-			}),
-			os.api('users/notes', {
-				userId: note.userId,
-				sinceId: note.id,
-				limit: 1,
-			}),
-		]).then(([_clips, prev, next]) => {
-			clips = _clips;
-			hasPrev = prev.length !== 0;
-			hasNext = next.length !== 0;
-		});
-	}).catch(err => {
-		error = err;
-	});
-}
-
-watch(() => props.noteId, fetchNote, {
-	immediate: true,
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => note ? {
-	title: i18n.ts.note,
-	subtitle: new Date(note.createdAt).toLocaleString(),
-	avatar: note.user,
-	path: `/notes/${note.id}`,
-	share: {
-		title: i18n.t('noteOf', { user: note.user.name }),
-		text: note.text,
-	},
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.fcuexfpr {
-	background: var(--bg);
-
-	> .note {
-		> .main {
-			> .load {
-				min-width: 0;
-				margin: 0 auto;
-				border-radius: 999px;
-
-				&.next {
-					margin-bottom: var(--margin);
-				}
-
-				&.prev {
-					margin-top: var(--margin);
-				}
-			}
-
-			> .note {
-				> .note {
-					border-radius: var(--radius);
-					background: var(--panel);
-				}
-			}
-
-			> .clips {
-				> .title {
-					font-weight: bold;
-					padding: 12px;
-				}
-
-				> .item {
-					display: block;
-					padding: 16px;
-
-					> .description {
-						padding: 8px 0;
-					}
-
-					> .user {
-						$height: 32px;
-						padding-top: 16px;
-						border-top: solid 0.5px var(--divider);
-						line-height: $height;
-
-						> .avatar {
-							width: $height;
-							height: $height;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
deleted file mode 100644
index 7106951de2..0000000000
--- a/packages/client/src/pages/notifications.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<div v-if="tab === 'all' || tab === 'unread'">
-			<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
-		</div>
-		<div v-else-if="tab === 'mentions'">
-			<XNotes :pagination="mentionsPagination"/>
-		</div>
-		<div v-else-if="tab === 'directNotes'">
-			<XNotes :pagination="directNotesPagination"/>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import XNotifications from '@/components/MkNotifications.vue';
-import XNotes from '@/components/MkNotes.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let tab = $ref('all');
-let includeTypes = $ref<string[] | null>(null);
-let unreadOnly = $computed(() => tab === 'unread');
-
-const mentionsPagination = {
-	endpoint: 'notes/mentions' as const,
-	limit: 10,
-};
-
-const directNotesPagination = {
-	endpoint: 'notes/mentions' as const,
-	limit: 10,
-	params: {
-		visibility: 'specified',
-	},
-};
-
-function setFilter(ev) {
-	const typeItems = notificationTypes.map(t => ({
-		text: i18n.t(`_notification._types.${t}`),
-		active: includeTypes && includeTypes.includes(t),
-		action: () => {
-			includeTypes = [t];
-		},
-	}));
-	const items = includeTypes != null ? [{
-		icon: 'ti ti-x',
-		text: i18n.ts.clear,
-		action: () => {
-			includeTypes = null;
-		},
-	}, null, ...typeItems] : typeItems;
-	os.popupMenu(items, ev.currentTarget ?? ev.target);
-}
-
-const headerActions = $computed(() => [tab === 'all' ? {
-	text: i18n.ts.filter,
-	icon: 'ti ti-filter',
-	highlighted: includeTypes != null,
-	handler: setFilter,
-} : undefined, tab === 'all' ? {
-	text: i18n.ts.markAllAsRead,
-	icon: 'ti ti-check',
-	handler: () => {
-		os.apiWithDialog('notifications/mark-all-as-read');
-	},
-} : undefined].filter(x => x !== undefined));
-
-const headerTabs = $computed(() => [{
-	key: 'all',
-	title: i18n.ts.all,
-}, {
-	key: 'unread',
-	title: i18n.ts.unread,
-}, {
-	key: 'mentions',
-	title: i18n.ts.mentions,
-	icon: 'ti ti-at',
-}, {
-	key: 'directNotes',
-	title: i18n.ts.directNotes,
-	icon: 'ti ti-mail',
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.notifications,
-	icon: 'ti ti-bell',
-})));
-</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
deleted file mode 100644
index a84cb1e80e..0000000000
--- a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<template>
-<!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => $emit('remove')">
-	<template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template>
-	<template #func>
-		<button @click="choose()">
-			<i class="fas fa-folder-open"></i>
-		</button>
-	</template>
-
-	<section class="oyyftmcf">
-		<MkDriveFileThumbnail v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/>
-	</section>
-</XContainer>
-</template>
-
-<script lang="ts" setup>
-/* eslint-disable vue/no-mutating-props */
-import { onMounted } from 'vue';
-import XContainer from '../page-editor.container.vue';
-import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
-import * as os from '@/os';
-
-const props = defineProps<{
-	modelValue: any
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any): void;
-}>();
-
-let file: any = $ref(null);
-
-async function choose() {
-	os.selectDriveFile(false).then((fileResponse: any) => {
-		file = fileResponse;
-		emit('update:modelValue', {
-			...props.modelValue,
-			fileId: fileResponse.id,
-		});
-	});
-}
-
-onMounted(async () => {
-	if (props.modelValue.fileId == null) {
-		await choose();
-	} else {
-		os.api('drive/files/show', {
-			fileId: props.modelValue.fileId,
-		}).then(fileResponse => {
-			file = fileResponse;
-		});
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.oyyftmcf {
-	> .preview {
-		height: 150px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
deleted file mode 100644
index dc2a620c09..0000000000
--- a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<template>
-<!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => $emit('remove')">
-	<template #header><i class="ti ti-note"></i> {{ $ts._pages.blocks.note }}</template>
-
-	<section style="padding: 0 16px 0 16px;">
-		<MkInput v-model="id">
-			<template #label>{{ $ts._pages.blocks._note.id }}</template>
-			<template #caption>{{ $ts._pages.blocks._note.idDescription }}</template>
-		</MkInput>
-		<MkSwitch v-model="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch>
-
-		<XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/>
-		<XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/>
-	</section>
-</XContainer>
-</template>
-
-<script lang="ts" setup>
-/* eslint-disable vue/no-mutating-props */
-import { watch } from 'vue';
-import XContainer from '../page-editor.container.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import XNote from '@/components/MkNote.vue';
-import XNoteDetailed from '@/components/MkNoteDetailed.vue';
-import * as os from '@/os';
-
-const props = defineProps<{
-	modelValue: any
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any): void;
-}>();
-
-let id: any = $ref(props.modelValue.note);
-let note: any = $ref(null);
-
-watch(id, async () => {
-	if (id && (id.startsWith('http://') || id.startsWith('https://'))) {
-		emit('update:modelValue', {
-			...props.modelValue,
-			note: (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop(),
-		});
-	} else {
-		emit('update:modelValue', {
-			...props.modelValue,
-			note: id,
-		});
-	}
-
-	note = await os.api('notes/show', { noteId: props.modelValue.note });
-}, {
-	immediate: true,
-});
-</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
deleted file mode 100644
index 27324bdaef..0000000000
--- a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
+++ /dev/null
@@ -1,97 +0,0 @@
-<template>
-<!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => $emit('remove')">
-	<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
-	<template #func>
-		<button class="_button" @click="rename()">
-			<i class="ti ti-pencil"></i>
-		</button>
-	</template>
-
-	<section class="ilrvjyvi">
-		<XBlocks v-model="children" class="children"/>
-		<MkButton rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
-	</section>
-</XContainer>
-</template>
-
-<script lang="ts" setup>
-/* eslint-disable vue/no-mutating-props */
-import { defineAsyncComponent, inject, onMounted, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
-import XContainer from '../page-editor.container.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { deepClone } from '@/scripts/clone';
-import MkButton from '@/components/MkButton.vue';
-
-const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue'));
-
-const props = withDefaults(defineProps<{
-	modelValue: any,
-}>(), {
-	modelValue: {},
-});
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any): void;
-}>();
-
-const children = $ref(deepClone(props.modelValue.children ?? []));
-
-watch($$(children), () => {
-	emit('update:modelValue', {
-		...props.modelValue,
-		children,
-	});
-}, {
-	deep: true,
-});
-
-const getPageBlockList = inject<(any) => any>('getPageBlockList');
-
-async function rename() {
-	const { canceled, result: title } = await os.inputText({
-		title: 'Enter title',
-		default: props.modelValue.title,
-	});
-	if (canceled) return;
-	emit('update:modelValue', {
-		...props.modelValue,
-		title,
-	});
-}
-
-async function add() {
-	const { canceled, result: type } = await os.select({
-		title: i18n.ts._pages.chooseBlock,
-		items: getPageBlockList(),
-	});
-	if (canceled) return;
-
-	const id = uuid();
-	children.push({ id, type });
-}
-
-onMounted(() => {
-	if (props.modelValue.title == null) {
-		rename();
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.ilrvjyvi {
-	> .children {
-		margin: 16px;
-
-		&:empty {
-			display: none;
-		}
-	}
-
-	> .add {
-		margin: 16px auto;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
deleted file mode 100644
index 6f11e2a08b..0000000000
--- a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<template>
-<!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => $emit('remove')">
-	<template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template>
-
-	<section class="vckmsadr">
-		<textarea v-model="text"></textarea>
-	</section>
-</XContainer>
-</template>
-
-<script lang="ts" setup>
-/* eslint-disable vue/no-mutating-props */
-import { watch } from 'vue';
-import XContainer from '../page-editor.container.vue';
-
-const props = defineProps<{
-	modelValue: any
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: any): void;
-}>();
-
-const text = $ref(props.modelValue.text ?? '');
-
-watch($$(text), () => {
-	emit('update:modelValue', {
-		...props.modelValue,
-		text,
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-.vckmsadr {
-	> textarea {
-		display: block;
-		-webkit-appearance: none;
-		-moz-appearance: none;
-		appearance: none;
-		width: 100%;
-		min-width: 100%;
-		min-height: 150px;
-		border: none;
-		box-shadow: none;
-		padding: 16px;
-		background: transparent;
-		color: var(--fg);
-		font-size: 14px;
-		box-sizing: border-box;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.blocks.vue b/packages/client/src/pages/page-editor/page-editor.blocks.vue
deleted file mode 100644
index f99fcb202f..0000000000
--- a/packages/client/src/pages/page-editor/page-editor.blocks.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<template>
-<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)">
-	<template #item="{element}">
-		<div :class="$style.item">
-			<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
-			<component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/>
-		</div>
-	</template>
-</Sortable>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import XSection from './els/page-editor.el.section.vue';
-import XText from './els/page-editor.el.text.vue';
-import XImage from './els/page-editor.el.image.vue';
-import XNote from './els/page-editor.el.note.vue';
-import * as os from '@/os';
-import { deepClone } from '@/scripts/clone';
-
-export default defineComponent({
-	components: {
-		Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
-		XSection, XText, XImage, XNote,
-	},
-
-	props: {
-		modelValue: {
-			type: Array,
-			required: true,
-		},
-	},
-
-	emits: ['update:modelValue'],
-
-	methods: {
-		updateItem(v) {
-			const i = this.modelValue.findIndex(x => x.id === v.id);
-			const newValue = [
-				...this.modelValue.slice(0, i),
-				v,
-				...this.modelValue.slice(i + 1),
-			];
-			this.$emit('update:modelValue', newValue);
-		},
-
-		removeItem(el) {
-			const i = this.modelValue.findIndex(x => x.id === el.id);
-			const newValue = [
-				...this.modelValue.slice(0, i),
-				...this.modelValue.slice(i + 1),
-			];
-			this.$emit('update:modelValue', newValue);
-		},
-	},
-});
-</script>
-
-<style lang="scss" module>
-.item {
-	& + .item {
-		margin-top: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.container.vue b/packages/client/src/pages/page-editor/page-editor.container.vue
deleted file mode 100644
index 15cdda5efb..0000000000
--- a/packages/client/src/pages/page-editor/page-editor.container.vue
+++ /dev/null
@@ -1,155 +0,0 @@
-<template>
-<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
-	<header>
-		<div class="title"><slot name="header"></slot></div>
-		<div class="buttons">
-			<slot name="func"></slot>
-			<button v-if="removable" class="_button" @click="remove()">
-				<i class="ti ti-trash"></i>
-			</button>
-			<button v-if="draggable" class="drag-handle _button">
-				<i class="ti ti-menu-2"></i>
-			</button>
-			<button class="_button" @click="toggleContent(!showBody)">
-				<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
-				<template v-else><i class="ti ti-chevron-down"></i></template>
-			</button>
-		</div>
-	</header>
-	<p v-show="showBody" v-if="error != null" class="error">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
-	<p v-show="showBody" v-if="warn != null" class="warn">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
-	<div v-show="showBody" class="body">
-		<slot></slot>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-
-export default defineComponent({
-	props: {
-		expanded: {
-			type: Boolean,
-			default: true,
-		},
-		removable: {
-			type: Boolean,
-			default: true,
-		},
-		draggable: {
-			type: Boolean,
-			default: false,
-		},
-		error: {
-			required: false,
-			default: null,
-		},
-		warn: {
-			required: false,
-			default: null,
-		},
-	},
-	emits: ['toggle', 'remove'],
-	data() {
-		return {
-			showBody: this.expanded,
-		};
-	},
-	methods: {
-		toggleContent(show: boolean) {
-			this.showBody = show;
-			this.$emit('toggle', show);
-		},
-		remove() {
-			this.$emit('remove');
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.cpjygsrt {
-	position: relative;
-	overflow: hidden;
-	background: var(--panel);
-	border: solid 2px var(--X12);
-	border-radius: 8px;
-
-	&:hover {
-		border: solid 2px var(--X13);
-	}
-
-	&.warn {
-		border: solid 2px #dec44c;
-	}
-
-	&.error {
-		border: solid 2px #f00;
-	}
-
-	> header {
-		> .title {
-			z-index: 1;
-			margin: 0;
-			padding: 0 16px;
-			line-height: 42px;
-			font-size: 0.9em;
-			font-weight: bold;
-			box-shadow: 0 1px rgba(#000, 0.07);
-
-			> i {
-				margin-right: 6px;
-			}
-
-			&:empty {
-				display: none;
-			}
-		}
-
-		> .buttons {
-			position: absolute;
-			z-index: 2;
-			top: 0;
-			right: 0;
-
-			> button {
-				padding: 0;
-				width: 42px;
-				font-size: 0.9em;
-				line-height: 42px;
-			}
-
-			.drag-handle {
-				cursor: move;
-			}
-		}
-	}
-
-	> .warn {
-		color: #b19e49;
-		margin: 0;
-		padding: 16px 16px 0 16px;
-		font-size: 14px;
-	}
-
-	> .error {
-		color: #f00;
-		margin: 0;
-		padding: 16px 16px 0 16px;
-		font-size: 14px;
-	}
-
-	> .body {
-		::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
-			&:not(.inline):first-child {
-				margin-top: 28px;
-			}
-
-			&:not(.inline):last-child {
-				margin-bottom: 20px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
deleted file mode 100644
index 968aa12de2..0000000000
--- a/packages/client/src/pages/page-editor/page-editor.vue
+++ /dev/null
@@ -1,394 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div class="jqqmcavi">
-			<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ $ts._pages.viewPage }}</MkButton>
-			<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ $ts.save }}</MkButton>
-			<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ $ts.duplicate }}</MkButton>
-			<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ $ts.delete }}</MkButton>
-		</div>
-
-		<div v-if="tab === 'settings'">
-			<div class="_formRoot">
-				<MkInput v-model="title" class="_formBlock">
-					<template #label>{{ $ts._pages.title }}</template>
-				</MkInput>
-
-				<MkInput v-model="summary" class="_formBlock">
-					<template #label>{{ $ts._pages.summary }}</template>
-				</MkInput>
-
-				<MkInput v-model="name" class="_formBlock">
-					<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
-					<template #label>{{ $ts._pages.url }}</template>
-				</MkInput>
-
-				<MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch>
-
-				<MkSelect v-model="font" class="_formBlock">
-					<template #label>{{ $ts._pages.font }}</template>
-					<option value="serif">{{ $ts._pages.fontSerif }}</option>
-					<option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option>
-				</MkSelect>
-
-				<MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch>
-
-				<div class="eyeCatch">
-					<MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
-					<div v-else-if="eyeCatchingImage">
-						<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
-						<MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
-					</div>
-				</div>
-			</div>
-		</div>
-
-		<div v-else-if="tab === 'contents'">
-			<div :class="$style.contents">
-				<XBlocks v-model="content" class="content"/>
-
-				<MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, provide, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
-import XBlocks from './page-editor.blocks.vue';
-import MkTextarea from '@/components/form/textarea.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import MkInput from '@/components/form/input.vue';
-import { url } from '@/config';
-import * as os from '@/os';
-import { selectFile } from '@/scripts/select-file';
-import { mainRouter } from '@/router';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { $i } from '@/account';
-
-const props = defineProps<{
-	initPageId?: string;
-	initPageName?: string;
-	initUser?: string;
-}>();
-
-let tab = $ref('settings');
-let author = $ref($i);
-let readonly = $ref(false);
-let page = $ref(null);
-let pageId = $ref(null);
-let currentName = $ref(null);
-let title = $ref('');
-let summary = $ref(null);
-let name = $ref(Date.now().toString());
-let eyeCatchingImage = $ref(null);
-let eyeCatchingImageId = $ref(null);
-let font = $ref('sans-serif');
-let content = $ref([]);
-let alignCenter = $ref(false);
-let hideTitleWhenPinned = $ref(false);
-
-provide('readonly', readonly);
-provide('getPageBlockList', getPageBlockList);
-
-watch($$(eyeCatchingImageId), async () => {
-	if (eyeCatchingImageId == null) {
-		eyeCatchingImage = null;
-	} else {
-		eyeCatchingImage = await os.api('drive/files/show', {
-			fileId: eyeCatchingImageId,
-		});
-	}
-});
-
-function getSaveOptions() {
-	return {
-		title: title.trim(),
-		name: name.trim(),
-		summary: summary,
-		font: font,
-		script: '',
-		hideTitleWhenPinned: hideTitleWhenPinned,
-		alignCenter: alignCenter,
-		content: content,
-		variables: [],
-		eyeCatchingImageId: eyeCatchingImageId,
-	};
-}
-
-function save() {
-	const options = getSaveOptions();
-
-	const onError = err => {
-		if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
-			if (err.info.param === 'name') {
-				os.alert({
-					type: 'error',
-					title: i18n.ts._pages.invalidNameTitle,
-					text: i18n.ts._pages.invalidNameText,
-				});
-			}
-		} else if (err.code === 'NAME_ALREADY_EXISTS') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts._pages.nameAlreadyExists,
-			});
-		}
-	};
-
-	if (pageId) {
-		options.pageId = pageId;
-		os.api('pages/update', options)
-			.then(page => {
-				currentName = name.trim();
-				os.alert({
-					type: 'success',
-					text: i18n.ts._pages.updated,
-				});
-			}).catch(onError);
-	} else {
-		os.api('pages/create', options)
-			.then(created => {
-				pageId = created.id;
-				currentName = name.trim();
-				os.alert({
-					type: 'success',
-					text: i18n.ts._pages.created,
-				});
-				mainRouter.push(`/pages/edit/${pageId}`);
-			}).catch(onError);
-	}
-}
-
-function del() {
-	os.confirm({
-		type: 'warning',
-		text: i18n.t('removeAreYouSure', { x: title.trim() }),
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		os.api('pages/delete', {
-			pageId: pageId,
-		}).then(() => {
-			os.alert({
-				type: 'success',
-				text: i18n.ts._pages.deleted,
-			});
-			mainRouter.push('/pages');
-		});
-	});
-}
-
-function duplicate() {
-	title = title + ' - copy';
-	name = name + '-copy';
-	os.api('pages/create', getSaveOptions()).then(created => {
-		pageId = created.id;
-		currentName = name.trim();
-		os.alert({
-			type: 'success',
-			text: i18n.ts._pages.created,
-		});
-		mainRouter.push(`/pages/edit/${pageId}`);
-	});
-}
-
-async function add() {
-	const { canceled, result: type } = await os.select({
-		type: null,
-		title: i18n.ts._pages.chooseBlock,
-		items: getPageBlockList(),
-	});
-	if (canceled) return;
-
-	const id = uuid();
-	content.push({ id, type });
-}
-
-function getPageBlockList() {
-	return [
-		{ value: 'section', text: i18n.ts._pages.blocks.section },
-		{ value: 'text', text: i18n.ts._pages.blocks.text },
-		{ value: 'image', text: i18n.ts._pages.blocks.image },
-		{ value: 'note', text: i18n.ts._pages.blocks.note },
-	];
-}
-
-function setEyeCatchingImage(img) {
-	selectFile(img.currentTarget ?? img.target, null).then(file => {
-		eyeCatchingImageId = file.id;
-	});
-}
-
-function removeEyeCatchingImage() {
-	eyeCatchingImageId = null;
-}
-
-async function init() {
-	if (props.initPageId) {
-		page = await os.api('pages/show', {
-			pageId: props.initPageId,
-		});
-	} else if (props.initPageName && props.initUser) {
-		page = await os.api('pages/show', {
-			name: props.initPageName,
-			username: props.initUser,
-		});
-		readonly = true;
-	}
-
-	if (page) {
-		author = page.user;
-		pageId = page.id;
-		title = page.title;
-		name = page.name;
-		currentName = page.name;
-		summary = page.summary;
-		font = page.font;
-		hideTitleWhenPinned = page.hideTitleWhenPinned;
-		alignCenter = page.alignCenter;
-		content = page.content;
-		eyeCatchingImageId = page.eyeCatchingImageId;
-	} else {
-		const id = uuid();
-		content = [{
-			id,
-			type: 'text',
-			text: 'Hello World!',
-		}];
-	}
-}
-
-init();
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => [{
-	key: 'settings',
-	title: i18n.ts._pages.pageSetting,
-	icon: 'ti ti-settings',
-}, {
-	key: 'contents',
-	title: i18n.ts._pages.contents,
-	icon: 'ti ti-note',
-}]);
-
-definePageMetadata(computed(() => {
-	let title = i18n.ts._pages.newPage;
-	if (props.initPageId) {
-		title = i18n.ts._pages.editPage;
-	}
-	else if (props.initPageName && props.initUser) {
-		title = i18n.ts._pages.readPage;
-	}
-	return {
-		title: title,
-		icon: 'ti ti-pencil',
-	};
-}));
-</script>
-
-<style lang="scss" module>
-.contents {
-	&:global {
-		> .add {
-			margin: 16px auto 0 auto;
-		}
-	}
-}
-</style>
-
-<style lang="scss" scoped>
-.jqqmcavi {
-	margin-bottom: 16px;
-
-	> .button {
-		& + .button {
-			margin-left: 8px;
-		}
-	}
-}
-
-.gwbmwxkm {
-	position: relative;
-
-	> header {
-		> .title {
-			z-index: 1;
-			margin: 0;
-			padding: 0 16px;
-			line-height: 42px;
-			font-size: 0.9em;
-			font-weight: bold;
-			box-shadow: 0 1px rgba(#000, 0.07);
-
-			> i {
-				margin-right: 6px;
-			}
-
-			&:empty {
-				display: none;
-			}
-		}
-
-		> .buttons {
-			position: absolute;
-			z-index: 2;
-			top: 0;
-			right: 0;
-
-			> button {
-				padding: 0;
-				width: 42px;
-				font-size: 0.9em;
-				line-height: 42px;
-			}
-		}
-	}
-
-	> section {
-		padding: 0 32px 32px 32px;
-
-		@media (max-width: 500px) {
-			padding: 0 16px 16px 16px;
-		}
-
-		> .view {
-			display: inline-block;
-			margin: 16px 0 0 0;
-			font-size: 14px;
-		}
-
-		> .content {
-			margin-bottom: 16px;
-		}
-
-		> .eyeCatch {
-			margin-bottom: 16px;
-
-			> div {
-				> img {
-					max-width: 100%;
-				}
-			}
-		}
-	}
-}
-
-.qmuvgica {
-	padding: 16px;
-
-	> .variables {
-		margin-bottom: 16px;
-	}
-
-	> .add {
-		margin-bottom: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
deleted file mode 100644
index a95bfe485c..0000000000
--- a/packages/client/src/pages/page.vue
+++ /dev/null
@@ -1,277 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
-			<div v-if="page" :key="page.id" class="xcukqgmh">
-				<div class="_block main">
-					<!--
-				<div class="header">
-					<h1>{{ page.title }}</h1>
-				</div>
-				-->
-					<div class="banner">
-						<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
-					</div>
-					<div class="content">
-						<XPage :page="page"/>
-					</div>
-					<div class="actions">
-						<div class="like">
-							<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
-							<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
-						</div>
-						<div class="other">
-							<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
-							<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
-						</div>
-					</div>
-					<div class="user">
-						<MkAvatar :user="page.user" class="avatar"/>
-						<div class="name">
-							<MkUserName :user="page.user" style="display: block;"/>
-							<MkAcct :user="page.user"/>
-						</div>
-						<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
-					</div>
-					<div class="links">
-						<MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
-						<template v-if="$i && $i.id === page.userId">
-							<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
-							<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
-							<button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button>
-						</template>
-					</div>
-				</div>
-				<div class="footer">
-					<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
-					<div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
-				</div>
-				<MkAd :prefer="['horizontal', 'horizontal-big']"/>
-				<MkContainer :max-height="300" :foldable="true" class="other">
-					<template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template>
-					<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
-						<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
-					</MkPagination>
-				</MkContainer>
-			</div>
-			<MkError v-else-if="error" @retry="fetchPage()"/>
-			<MkLoading v-else/>
-		</transition>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import XPage from '@/components/page/page.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { url } from '@/config';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import MkContainer from '@/components/MkContainer.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkPagePreview from '@/components/MkPagePreview.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	pageName: string;
-	username: string;
-}>();
-
-let page = $ref(null);
-let error = $ref(null);
-const otherPostsPagination = {
-	endpoint: 'users/pages' as const,
-	limit: 6,
-	params: computed(() => ({
-		userId: page.user.id,
-	})),
-};
-const path = $computed(() => props.username + '/' + props.pageName);
-
-function fetchPage() {
-	page = null;
-	os.api('pages/show', {
-		name: props.pageName,
-		username: props.username,
-	}).then(_page => {
-		page = _page;
-	}).catch(err => {
-		error = err;
-	});
-}
-
-function share() {
-	navigator.share({
-		title: page.title ?? page.name,
-		text: page.summary,
-		url: `${url}/@${page.user.username}/pages/${page.name}`,
-	});
-}
-
-function shareWithNote() {
-	os.post({
-		initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
-	});
-}
-
-function like() {
-	os.apiWithDialog('pages/like', {
-		pageId: page.id,
-	}).then(() => {
-		page.isLiked = true;
-		page.likedCount++;
-	});
-}
-
-async function unlike() {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.unlikeConfirm,
-	});
-	if (confirm.canceled) return;
-	os.apiWithDialog('pages/unlike', {
-		pageId: page.id,
-	}).then(() => {
-		page.isLiked = false;
-		page.likedCount--;
-	});
-}
-
-function pin(pin) {
-	os.apiWithDialog('i/update', {
-		pinnedPageId: pin ? page.id : null,
-	});
-}
-
-watch(() => path, fetchPage, { immediate: true });
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => page ? {
-	title: computed(() => page.title || page.name),
-	avatar: page.user,
-	path: `/@${page.user.username}/pages/${page.name}`,
-	share: {
-		title: page.title || page.name,
-		text: page.summary,
-	},
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.xcukqgmh {
-	> .main {
-		padding: 32px;
-
-		> .header {
-			padding: 16px;
-
-			> h1 {
-				margin: 0;
-			}
-		}
-
-		> .banner {
-			> img {
-				// TODO: 良い感じのアスペクト比で表示
-				display: block;
-				width: 100%;
-				height: 150px;
-				object-fit: cover;
-			}
-		}
-
-		> .content {
-			margin-top: 16px;
-			padding: 16px 0 0 0;
-		}
-
-		> .actions {
-			display: flex;
-			align-items: center;
-			margin-top: 16px;
-			padding: 16px 0 0 0;
-			border-top: solid 0.5px var(--divider);
-
-			> .like {
-				> .button {
-					--accent: rgb(241 97 132);
-					--X8: rgb(241 92 128);
-					--buttonBg: rgb(216 71 106 / 5%);
-					--buttonHoverBg: rgb(216 71 106 / 10%);
-					color: #ff002f;
-
-					::v-deep(.count) {
-						margin-left: 0.5em;
-					}
-				}
-			}
-
-			> .other {
-				margin-left: auto;
-
-				> button {
-					padding: 8px;
-					margin: 0 8px;
-
-					&:hover {
-						color: var(--fgHighlighted);
-					}
-				}
-			}
-		}
-
-		> .user {
-			margin-top: 16px;
-			padding: 16px 0 0 0;
-			border-top: solid 0.5px var(--divider);
-			display: flex;
-			align-items: center;
-
-			> .avatar {
-				width: 52px;
-				height: 52px;
-			}
-
-			> .name {
-				margin: 0 0 0 12px;
-				font-size: 90%;
-			}
-
-			> .koudoku {
-				margin-left: auto;
-			}
-		}
-
-		> .links {
-			margin-top: 16px;
-			padding: 24px 0 0 0;
-			border-top: solid 0.5px var(--divider);
-
-			> .link {
-				margin-right: 0.75em;
-			}
-		}
-	}
-
-	> .footer {
-		margin: var(--margin) 0 var(--margin) 0;
-		font-size: 85%;
-		opacity: 0.75;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
deleted file mode 100644
index b077180df8..0000000000
--- a/packages/client/src/pages/pages.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="700">
-		<div v-if="tab === 'featured'" class="rknalgpo">
-			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
-				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-			</MkPagination>
-		</div>
-
-		<div v-else-if="tab === 'my'" class="rknalgpo my">
-			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
-			<MkPagination v-slot="{items}" :pagination="myPagesPagination">
-				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-			</MkPagination>
-		</div>
-
-		<div v-else-if="tab === 'liked'" class="rknalgpo">
-			<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
-				<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
-			</MkPagination>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, inject } from 'vue';
-import MkPagePreview from '@/components/MkPagePreview.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import { useRouter } from '@/router';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const router = useRouter();
-
-let tab = $ref('featured');
-
-const featuredPagesPagination = {
-	endpoint: 'pages/featured' as const,
-	noPaging: true,
-};
-const myPagesPagination = {
-	endpoint: 'i/pages' as const,
-	limit: 5,
-};
-const likedPagesPagination = {
-	endpoint: 'i/page-likes' as const,
-	limit: 5,
-};
-
-function create() {
-	router.push('/pages/new');
-}
-
-const headerActions = $computed(() => [{
-	icon: 'ti ti-plus',
-	text: i18n.ts.create,
-	handler: create,
-}]);
-
-const headerTabs = $computed(() => [{
-	key: 'featured',
-	title: i18n.ts._pages.featured,
-	icon: 'fas fa-fire-alt',
-}, {
-	key: 'my',
-	title: i18n.ts._pages.my,
-	icon: 'ti ti-edit',
-}, {
-	key: 'liked',
-	title: i18n.ts._pages.liked,
-	icon: 'ti ti-heart',
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.pages,
-	icon: 'ti ti-note',
-})));
-</script>
-
-<style lang="scss" scoped>
-.rknalgpo {
-	&.my .ckltabjg:first-child {
-		margin-top: 16px;
-	}
-
-	.ckltabjg:not(:last-child) {
-		margin-bottom: 8px;
-	}
-
-	@media (min-width: 500px) {
-		.ckltabjg:not(:last-child) {
-			margin-bottom: 16px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
deleted file mode 100644
index 354f686e46..0000000000
--- a/packages/client/src/pages/preview.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-<div class="graojtoi">
-	<MkSample/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkSample from '@/components/MkSample.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.preview,
-	icon: 'ti ti-eye',
-})));
-</script>
-
-<style lang="scss" scoped>
-.graojtoi {
-	padding: var(--margin);
-}
-</style>
diff --git a/packages/client/src/pages/registry.keys.vue b/packages/client/src/pages/registry.keys.vue
deleted file mode 100644
index f179fbe957..0000000000
--- a/packages/client/src/pages/registry.keys.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="600" :margin-min="16">
-		<FormSplit>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts._registry.domain }}</template>
-				<template #value>{{ i18n.ts.system }}</template>
-			</MkKeyValue>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts._registry.scope }}</template>
-				<template #value>{{ scope.join('/') }}</template>
-			</MkKeyValue>
-		</FormSplit>
-		
-		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
-
-		<FormSection v-if="keys">
-			<template #label>{{ i18n.ts.keys }}</template>
-			<div class="_formLinks">
-				<FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
-			</div>
-		</FormSection>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import JSON5 from 'json5';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import FormSplit from '@/components/form/split.vue';
-
-const props = defineProps<{
-	path: string;
-}>();
-
-const scope = $computed(() => props.path.split('/'));
-
-let keys = $ref(null);
-
-function fetchKeys() {
-	os.api('i/registry/keys-with-type', {
-		scope: scope,
-	}).then(res => {
-		keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0]));
-	});
-}
-
-async function createKey() {
-	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
-		key: {
-			type: 'string',
-			label: i18n.ts._registry.key,
-		},
-		value: {
-			type: 'string',
-			multiline: true,
-			label: i18n.ts.value,
-		},
-		scope: {
-			type: 'string',
-			label: i18n.ts._registry.scope,
-			default: scope.join('/'),
-		},
-	});
-	if (canceled) return;
-	os.apiWithDialog('i/registry/set', {
-		scope: result.scope.split('/'),
-		key: result.key,
-		value: JSON5.parse(result.value),
-	}).then(() => {
-		fetchKeys();
-	});
-}
-
-watch(() => props.path, fetchKeys, { immediate: true });
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.registry,
-	icon: 'ti ti-adjustments',
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/registry.value.vue b/packages/client/src/pages/registry.value.vue
deleted file mode 100644
index 378420b1ba..0000000000
--- a/packages/client/src/pages/registry.value.vue
+++ /dev/null
@@ -1,123 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="600" :margin-min="16">
-		<FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo>
-
-		<template v-if="value">
-			<FormSplit>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ i18n.ts._registry.domain }}</template>
-					<template #value>{{ i18n.ts.system }}</template>
-				</MkKeyValue>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ i18n.ts._registry.scope }}</template>
-					<template #value>{{ scope.join('/') }}</template>
-				</MkKeyValue>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ i18n.ts._registry.key }}</template>
-					<template #value>{{ key }}</template>
-				</MkKeyValue>
-			</FormSplit>
-			
-			<FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace">
-				<template #label>{{ i18n.ts.value }} (JSON)</template>
-			</FormTextarea>
-
-			<MkButton class="_formBlock" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.updatedAt }}</template>
-				<template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
-			</MkKeyValue>
-
-			<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
-		</template>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import JSON5 from 'json5';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormInfo from '@/components/MkInfo.vue';
-
-const props = defineProps<{
-	path: string;
-}>();
-
-const scope = $computed(() => props.path.split('/').slice(0, -1));
-const key = $computed(() => props.path.split('/').at(-1));
-
-let value = $ref(null);
-let valueForEditor = $ref(null);
-
-function fetchValue() {
-	os.api('i/registry/get-detail', {
-		scope,
-		key,
-	}).then(res => {
-		value = res;
-		valueForEditor = JSON5.stringify(res.value, null, '\t');
-	});
-}
-
-async function save() {
-	try {
-		JSON5.parse(valueForEditor);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts.invalidValue,
-		});
-		return;
-	}
-	os.confirm({
-		type: 'warning',
-		text: i18n.ts.saveConfirm,
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		os.apiWithDialog('i/registry/set', {
-			scope,
-			key,
-			value: JSON5.parse(valueForEditor),
-		});
-	});
-}
-
-function del() {
-	os.confirm({
-		type: 'warning',
-		text: i18n.ts.deleteConfirm,
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		os.apiWithDialog('i/registry/remove', {
-			scope,
-			key,
-		});
-	});
-}
-
-watch(() => props.path, fetchValue, { immediate: true });
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.registry,
-	icon: 'ti ti-adjustments',
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/registry.vue b/packages/client/src/pages/registry.vue
deleted file mode 100644
index a2c65294fc..0000000000
--- a/packages/client/src/pages/registry.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="600" :margin-min="16">
-		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
-
-		<FormSection v-if="scopes">
-			<template #label>{{ i18n.ts.system }}</template>
-			<div class="_formLinks">
-				<FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
-			</div>
-		</FormSection>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import JSON5 from 'json5';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-
-let scopes = $ref(null);
-
-function fetchScopes() {
-	os.api('i/registry/scopes').then(res => {
-		scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
-	});
-}
-
-async function createKey() {
-	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
-		key: {
-			type: 'string',
-			label: i18n.ts._registry.key,
-		},
-		value: {
-			type: 'string',
-			multiline: true,
-			label: i18n.ts.value,
-		},
-		scope: {
-			type: 'string',
-			label: i18n.ts._registry.scope,
-		},
-	});
-	if (canceled) return;
-	os.apiWithDialog('i/registry/set', {
-		scope: result.scope.split('/'),
-		key: result.key,
-		value: JSON5.parse(result.value),
-	}).then(() => {
-		fetchScopes();
-	});
-}
-
-fetchScopes();
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.registry,
-	icon: 'ti ti-adjustments',
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
deleted file mode 100644
index 8ec15f6425..0000000000
--- a/packages/client/src/pages/reset-password.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
-		<div class="_formRoot">
-			<FormInput v-model="password" type="password" class="_formBlock">
-				<template #prefix><i class="ti ti-lock"></i></template>
-				<template #label>{{ i18n.ts.newPassword }}</template>
-			</FormInput>
-		
-			<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, onMounted } from 'vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { mainRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	token?: string;
-}>();
-
-let password = $ref('');
-
-async function save() {
-	await os.apiWithDialog('reset-password', {
-		token: props.token,
-		password: password,
-	});
-	mainRouter.push('/');
-}
-
-onMounted(() => {
-	if (props.token == null) {
-		os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed');
-		mainRouter.push('/');
-	}
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.resetPassword,
-	icon: 'ti ti-lock',
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
deleted file mode 100644
index edb2d8e18c..0000000000
--- a/packages/client/src/pages/scratchpad.vue
+++ /dev/null
@@ -1,137 +0,0 @@
-<template>
-<div class="iltifgqe">
-	<div class="editor _panel _gap">
-		<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
-		<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
-	</div>
-
-	<MkContainer :foldable="true" class="_gap">
-		<template #header>{{ i18n.ts.output }}</template>
-		<div class="bepmlvbi">
-			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
-		</div>
-	</MkContainer>
-
-	<div class="_gap">
-		{{ i18n.ts.scratchpadDescription }}
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import 'prismjs';
-import { highlight, languages } from 'prismjs/components/prism-core';
-import 'prismjs/components/prism-clike';
-import 'prismjs/components/prism-javascript';
-import 'prismjs/themes/prism-okaidia.css';
-import { PrismEditor } from 'vue-prism-editor';
-import 'vue-prism-editor/dist/prismeditor.min.css';
-import { AiScript, parse, utils } from '@syuilo/aiscript';
-import MkContainer from '@/components/MkContainer.vue';
-import MkButton from '@/components/MkButton.vue';
-import { createAiScriptEnv } from '@/scripts/aiscript/api';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const code = ref('');
-const logs = ref<any[]>([]);
-
-const saved = localStorage.getItem('scratchpad');
-if (saved) {
-	code.value = saved;
-}
-
-watch(code, () => {
-	localStorage.setItem('scratchpad', code.value);
-});
-
-async function run() {
-	logs.value = [];
-	const aiscript = new AiScript(createAiScriptEnv({
-		storageKey: 'scratchpad',
-		token: $i?.token,
-	}), {
-		in: (q) => {
-			return new Promise(ok => {
-				os.inputText({
-					title: q,
-				}).then(({ canceled, result: a }) => {
-					ok(a);
-				});
-			});
-		},
-		out: (value) => {
-			logs.value.push({
-				id: Math.random(),
-				text: value.type === 'str' ? value.value : utils.valToString(value),
-				print: true,
-			});
-		},
-		log: (type, params) => {
-			switch (type) {
-				case 'end': logs.value.push({
-					id: Math.random(),
-					text: utils.valToString(params.val, true),
-					print: false,
-				}); break;
-				default: break;
-			}
-		},
-	});
-
-	let ast;
-	try {
-		ast = parse(code.value);
-	} catch (error) {
-		os.alert({
-			type: 'error',
-			text: 'Syntax error :(',
-		});
-		return;
-	}
-	try {
-		await aiscript.exec(ast);
-	} catch (error: any) {
-		os.alert({
-			type: 'error',
-			text: error.message,
-		});
-	}
-}
-
-function highlighter(code) {
-	return highlight(code, languages.js, 'javascript');
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.scratchpad,
-	icon: 'ti ti-terminal-2',
-});
-</script>
-
-<style lang="scss" scoped>
-.iltifgqe {
-	padding: 16px;
-
-	> .editor {
-		position: relative;
-	}
-}
-
-.bepmlvbi {
-	padding: 16px;
-
-	> .log {
-		&:not(.print) {
-			opacity: 0.7;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
deleted file mode 100644
index c080b763bb..0000000000
--- a/packages/client/src/pages/search.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<XNotes ref="notes" :pagination="pagination"/>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import XNotes from '@/components/MkNotes.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	query: string;
-	channel?: string;
-}>();
-
-const pagination = {
-	endpoint: 'notes/search' as const,
-	limit: 10,
-	params: computed(() => ({
-		query: props.query,
-		channelId: props.channel,
-	})),
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: i18n.t('searchWith', { q: props.query }),
-	icon: 'ti ti-search',
-})));
-</script>
diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue
deleted file mode 100644
index 1803129aaa..0000000000
--- a/packages/client/src/pages/settings/2fa.vue
+++ /dev/null
@@ -1,216 +0,0 @@
-<template>
-<div>
-	<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton>
-	<template v-if="$i.twoFactorEnabled">
-		<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
-		<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
-
-		<template v-if="supportsCredentials">
-			<hr class="totp-method-sep">
-
-			<h2 class="heading">{{ i18n.ts.securityKey }}</h2>
-			<p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
-			<div class="key-list">
-				<div v-for="key in $i.securityKeysList" class="key">
-					<h3>{{ key.name }}</h3>
-					<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
-					<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton>
-				</div>
-			</div>
-
-			<MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch>
-
-			<MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo>
-			<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton>
-
-			<ol v-if="registration && !registration.error">
-				<li v-if="registration.stage >= 0">
-					{{ i18n.ts.tapSecurityKey }}
-					<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
-				</li>
-				<li v-if="registration.stage >= 1">
-					<MkForm :disabled="registration.stage != 1 || registration.saving">
-						<MkInput v-model="keyName" :max="30">
-							<template #label>{{ i18n.ts.securityKeyName }}</template>
-						</MkInput>
-						<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton>
-						<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/>
-					</MkForm>
-				</li>
-			</ol>
-		</template>
-	</template>
-	<div v-if="twoFactorData && !$i.twoFactorEnabled">
-		<ol style="margin: 0; padding: 0 0 0 1em;">
-			<li>
-				<I18n :src="i18n.ts._2fa.step1" tag="span">
-					<template #a>
-						<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
-					</template>
-					<template #b>
-						<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
-					</template>
-				</I18n>
-			</li>
-			<li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li>
-			<li>
-				{{ i18n.ts._2fa.step3 }}<br>
-				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
-				<MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton>
-			</li>
-		</ol>
-		<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import { hostname } from '@/config';
-import { byteify, hexify, stringify } from '@/scripts/2fa';
-import MkButton from '@/components/MkButton.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-
-const twoFactorData = ref<any>(null);
-const supportsCredentials = ref(!!navigator.credentials);
-const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
-const registration = ref<any>(null);
-const keyName = ref('');
-const token = ref(null);
-
-function register() {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		os.api('i/2fa/register', {
-			password: password,
-		}).then(data => {
-			twoFactorData.value = data;
-		});
-	});
-}
-
-function unregister() {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		os.api('i/2fa/unregister', {
-			password: password,
-		}).then(() => {
-			usePasswordLessLogin.value = false;
-			updatePasswordLessLogin();
-		}).then(() => {
-			os.success();
-			$i!.twoFactorEnabled = false;
-		});
-	});
-}
-
-function submit() {
-	os.api('i/2fa/done', {
-		token: token.value,
-	}).then(() => {
-		os.success();
-		$i!.twoFactorEnabled = true;
-	}).catch(err => {
-		os.alert({
-			type: 'error',
-			text: err,
-		});
-	});
-}
-
-function registerKey() {
-	registration.value.saving = true;
-	os.api('i/2fa/key-done', {
-		password: registration.value.password,
-		name: keyName.value,
-		challengeId: registration.value.challengeId,
-		// we convert each 16 bits to a string to serialise
-		clientDataJSON: stringify(registration.value.credential.response.clientDataJSON),
-		attestationObject: hexify(registration.value.credential.response.attestationObject),
-	}).then(key => {
-		registration.value = null;
-		key.lastUsed = new Date();
-		os.success();
-	});
-}
-
-function unregisterKey(key) {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		return os.api('i/2fa/remove-key', {
-			password,
-			credentialId: key.id,
-		}).then(() => {
-			usePasswordLessLogin.value = false;
-			updatePasswordLessLogin();
-		}).then(() => {
-			os.success();
-		});
-	});
-}
-
-function addSecurityKey() {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		os.api('i/2fa/register-key', {
-			password,
-		}).then(reg => {
-			registration.value = {
-				password,
-				challengeId: reg!.challengeId,
-				stage: 0,
-				publicKeyOptions: {
-					challenge: byteify(reg!.challenge, 'base64'),
-					rp: {
-						id: hostname,
-						name: 'Misskey',
-					},
-					user: {
-						id: byteify($i!.id, 'ascii'),
-						name: $i!.username,
-						displayName: $i!.name,
-					},
-					pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
-					timeout: 60000,
-					attestation: 'direct',
-				},
-				saving: true,
-			};
-			return navigator.credentials.create({
-				publicKey: registration.value.publicKeyOptions,
-			});
-		}).then(credential => {
-			registration.value.credential = credential;
-			registration.value.saving = false;
-			registration.value.stage = 1;
-		}).catch(err => {
-			console.warn('Error while registering?', err);
-			registration.value.error = err.message;
-			registration.value.stage = -1;
-		});
-	});
-}
-
-async function updatePasswordLessLogin() {
-	await os.api('i/2fa/password-less', {
-		value: !!usePasswordLessLogin.value,
-	});
-}
-</script>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
deleted file mode 100644
index ccd99c162a..0000000000
--- a/packages/client/src/pages/settings/account-info.vue
+++ /dev/null
@@ -1,158 +0,0 @@
-<template>
-<div class="_formRoot">
-	<MkKeyValue>
-		<template #key>ID</template>
-		<template #value><span class="_monospace">{{ $i.id }}</span></template>
-	</MkKeyValue>
-
-	<FormSection>
-		<MkKeyValue>
-			<template #key>{{ i18n.ts.registeredDate }}</template>
-			<template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
-		</MkKeyValue>
-	</FormSection>
-
-	<FormSection v-if="stats">
-		<template #label>{{ i18n.ts.statistics }}</template>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.notesCount }}</template>
-			<template #value>{{ number(stats.notesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.repliesCount }}</template>
-			<template #value>{{ number(stats.repliesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.renotesCount }}</template>
-			<template #value>{{ number(stats.renotesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.repliedCount }}</template>
-			<template #value>{{ number(stats.repliedCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.renotedCount }}</template>
-			<template #value>{{ number(stats.renotedCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.pollVotesCount }}</template>
-			<template #value>{{ number(stats.pollVotesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.pollVotedCount }}</template>
-			<template #value>{{ number(stats.pollVotedCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.sentReactionsCount }}</template>
-			<template #value>{{ number(stats.sentReactionsCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.receivedReactionsCount }}</template>
-			<template #value>{{ number(stats.receivedReactionsCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.noteFavoritesCount }}</template>
-			<template #value>{{ number(stats.noteFavoritesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followingCount }}</template>
-			<template #value>{{ number(stats.followingCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template>
-			<template #value>{{ number(stats.localFollowingCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template>
-			<template #value>{{ number(stats.remoteFollowingCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followersCount }}</template>
-			<template #value>{{ number(stats.followersCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template>
-			<template #value>{{ number(stats.localFollowersCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template>
-			<template #value>{{ number(stats.remoteFollowersCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.pageLikesCount }}</template>
-			<template #value>{{ number(stats.pageLikesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.pageLikedCount }}</template>
-			<template #value>{{ number(stats.pageLikedCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.driveFilesCount }}</template>
-			<template #value>{{ number(stats.driveFilesCount) }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>{{ i18n.ts.driveUsage }}</template>
-			<template #value>{{ bytes(stats.driveUsage) }}</template>
-		</MkKeyValue>
-	</FormSection>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.other }}</template>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>emailVerified</template>
-			<template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>twoFactorEnabled</template>
-			<template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>securityKeys</template>
-			<template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>usePasswordLessLogin</template>
-			<template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>isModerator</template>
-			<template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-		<MkKeyValue oneline style="margin: 1em 0;">
-			<template #key>isAdmin</template>
-			<template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template>
-		</MkKeyValue>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
-import FormSection from '@/components/form/section.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import bytes from '@/filters/bytes';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const stats = ref<any>({});
-
-onMounted(() => {
-	os.api('users/stats', {
-		userId: $i!.id,
-	}).then(response => {
-		stats.value = response;
-	});
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.accountInfo,
-	icon: 'ti ti-info-circle',
-});
-</script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
deleted file mode 100644
index 493d3b2618..0000000000
--- a/packages/client/src/pages/settings/accounts.vue
+++ /dev/null
@@ -1,143 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSuspense :p="init">
-		<FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton>
-
-		<div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)">
-			<div class="avatar">
-				<MkAvatar :user="account" class="avatar"/>
-			</div>
-			<div class="body">
-				<div class="name">
-					<MkUserName :user="account"/>
-				</div>
-				<div class="acct">
-					<MkAcct :user="account"/>
-				</div>
-			</div>
-		</div>
-	</FormSuspense>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, ref } from 'vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const storedAccounts = ref<any>(null);
-const accounts = ref<any>(null);
-
-const init = async () => {
-	getAccounts().then(accounts => {
-		storedAccounts.value = accounts.filter(x => x.id !== $i!.id);
-
-		console.log(storedAccounts.value);
-
-		return os.api('users/show', {
-			userIds: storedAccounts.value.map(x => x.id),
-		});
-	}).then(response => {
-		accounts.value = response;
-		console.log(accounts.value);
-	});
-};
-
-function menu(account, ev) {
-	os.popupMenu([{
-		text: i18n.ts.switch,
-		icon: 'ti ti-switch-horizontal',
-		action: () => switchAccount(account),
-	}, {
-		text: i18n.ts.remove,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: () => removeAccount(account),
-	}], ev.currentTarget ?? ev.target);
-}
-
-function addAccount(ev) {
-	os.popupMenu([{
-		text: i18n.ts.existingAccount,
-		action: () => { addExistingAccount(); },
-	}, {
-		text: i18n.ts.createAccount,
-		action: () => { createAccount(); },
-	}], ev.currentTarget ?? ev.target);
-}
-
-function removeAccount(account) {
-	_removeAccount(account.id);
-}
-
-function addExistingAccount() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
-		done: res => {
-			addAccounts(res.id, res.i);
-			os.success();
-		},
-	}, 'closed');
-}
-
-function createAccount() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
-		done: res => {
-			addAccounts(res.id, res.i);
-			switchAccountWithToken(res.i);
-		},
-	}, 'closed');
-}
-
-async function switchAccount(account: any) {
-	const fetchedAccounts: any[] = await getAccounts();
-	const token = fetchedAccounts.find(x => x.id === account.id).token;
-	switchAccountWithToken(token);
-}
-
-function switchAccountWithToken(token: string) {
-	login(token);
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.accounts,
-	icon: 'ti ti-users',
-});
-</script>
-
-<style lang="scss" scoped>
-.lcjjdxlm {
-	display: flex;
-	padding: 16px;
-
-	> .avatar {
-		display: block;
-		flex-shrink: 0;
-		margin: 0 12px 0 0;
-
-		> .avatar {
-			width: 50px;
-			height: 50px;
-		}
-	}
-
-	> .body {
-		display: flex;
-		flex-direction: column;
-		justify-content: center;
-		width: calc(100% - 62px);
-		position: relative;
-
-		> .name {
-			font-weight: bold;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
deleted file mode 100644
index 8d7291cd10..0000000000
--- a/packages/client/src/pages/settings/api.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton>
-	<FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink>
-	<FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, ref } from 'vue';
-import FormLink from '@/components/form/link.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const isDesktop = ref(window.innerWidth >= 1100);
-
-function generateToken() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
-		done: async result => {
-			const { name, permissions } = result;
-			const { token } = await os.api('miauth/gen-token', {
-				session: null,
-				name: name,
-				permission: permissions,
-			});
-
-			os.alert({
-				type: 'success',
-				title: i18n.ts.token,
-				text: token,
-			});
-		},
-	}, 'closed');
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: 'API',
-	icon: 'ti ti-api',
-});
-</script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
deleted file mode 100644
index 05abadff23..0000000000
--- a/packages/client/src/pages/settings/apps.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormPagination ref="list" :pagination="pagination">
-		<template #empty>
-			<div class="_fullinfo">
-				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-				<div>{{ i18n.ts.nothing }}</div>
-			</div>
-		</template>
-		<template #default="{items}">
-			<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
-				<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
-				<div class="body">
-					<div class="name">{{ token.name }}</div>
-					<div class="description">{{ token.description }}</div>
-					<div class="_keyValue">
-						<div>{{ i18n.ts.installedDate }}:</div>
-						<div><MkTime :time="token.createdAt"/></div>
-					</div>
-					<div class="_keyValue">
-						<div>{{ i18n.ts.lastUsedDate }}:</div>
-						<div><MkTime :time="token.lastUsedAt"/></div>
-					</div>
-					<div class="actions">
-						<button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button>
-					</div>
-					<details>
-						<summary>{{ i18n.ts.details }}</summary>
-						<ul>
-							<li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
-						</ul>
-					</details>
-				</div>
-			</div>
-		</template>
-	</FormPagination>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import FormPagination from '@/components/MkPagination.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const list = ref<any>(null);
-
-const pagination = {
-	endpoint: 'i/apps' as const,
-	limit: 100,
-	params: {
-		sort: '+lastUsedAt',
-	},
-};
-
-function revoke(token) {
-	os.api('i/revoke-token', { tokenId: token.id }).then(() => {
-		list.value.reload();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.installedApps,
-	icon: 'ti ti-plug',
-});
-</script>
-
-<style lang="scss" scoped>
-.bfomjevm {
-	display: flex;
-	padding: 16px;
-
-	> .icon {
-		display: block;
-		flex-shrink: 0;
-		margin: 0 12px 0 0;
-		width: 50px;
-		height: 50px;
-		border-radius: 8px;
-	}
-
-	> .body {
-		width: calc(100% - 62px);
-		position: relative;
-
-		> .name {
-			font-weight: bold;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
deleted file mode 100644
index 2caad22b7b..0000000000
--- a/packages/client/src/pages/settings/custom-css.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo>
-
-	<FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;">
-		<template #label>CSS</template>
-	</FormTextarea>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import * as os from '@/os';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
-
-async function apply() {
-	localStorage.setItem('customCss', localCustomCss.value);
-
-	const { canceled } = await os.confirm({
-		type: 'info',
-		text: i18n.ts.reloadToApplySetting,
-	});
-	if (canceled) return;
-
-	unisonReload();
-}
-
-watch(localCustomCss, async () => {
-	await apply();
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.customCss,
-	icon: 'ti ti-code',
-});
-</script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
deleted file mode 100644
index 82cefe05d5..0000000000
--- a/packages/client/src/pages/settings/deck.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch>
-
-	<FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch>
-
-	<FormRadios v-model="columnAlign" class="_formBlock">
-		<template #label>{{ i18n.ts._deck.columnAlign }}</template>
-		<option value="left">{{ i18n.ts.left }}</option>
-		<option value="center">{{ i18n.ts.center }}</option>
-	</FormRadios>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormLink from '@/components/form/link.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormInput from '@/components/form/input.vue';
-import { deckStore } from '@/ui/deck/deck-store';
-import * as os from '@/os';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
-const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
-const columnAlign = computed(deckStore.makeGetterSetter('columnAlign'));
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.deck,
-	icon: 'ti ti-columns',
-});
-</script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
deleted file mode 100644
index 8a25ff39f0..0000000000
--- a/packages/client/src/pages/settings/delete-account.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo>
-	<FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo>
-	<FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton>
-	<FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import FormInfo from '@/components/MkInfo.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { signout } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-async function deleteAccount() {
-	{
-		const { canceled } = await os.confirm({
-			type: 'warning',
-			text: i18n.ts.deleteAccountConfirm,
-		});
-		if (canceled) return;
-	}
-
-	const { canceled, result: password } = await os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	});
-	if (canceled) return;
-
-	await os.apiWithDialog('i/delete-account', {
-		password: password,
-	});
-
-	await os.alert({
-		title: i18n.ts._accountDelete.started,
-	});
-
-	await signout();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts._accountDelete.accountDelete,
-	icon: 'ti ti-alert-triangle',
-});
-</script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
deleted file mode 100644
index 2d45b1add8..0000000000
--- a/packages/client/src/pages/settings/drive.vue
+++ /dev/null
@@ -1,145 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection v-if="!fetching">
-		<template #label>{{ i18n.ts.usageAmount }}</template>
-		<div class="_formBlock uawsfosz">
-			<div class="meter"><div :style="meterStyle"></div></div>
-		</div>
-		<FormSplit>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.capacity }}</template>
-				<template #value>{{ bytes(capacity, 1) }}</template>
-			</MkKeyValue>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.inUse }}</template>
-				<template #value>{{ bytes(usage, 1) }}</template>
-			</MkKeyValue>
-		</FormSplit>
-	</FormSection>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.statistics }}</template>
-		<MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
-	</FormSection>
-
-	<FormSection>
-		<FormLink @click="chooseUploadFolder()">
-			{{ i18n.ts.uploadFolder }}
-			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
-			<template #suffixIcon><i class="fas fa-folder-open"></i></template>
-		</FormLink>
-		<FormSwitch v-model="keepOriginalUploading" class="_formBlock">
-			<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
-			<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
-		</FormSwitch>
-		<FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()">
-			<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
-		</FormSwitch>
-		<FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()">
-			<template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
-			<template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template>
-		</FormSwitch>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref } from 'vue';
-import tinycolor from 'tinycolor2';
-import FormLink from '@/components/form/link.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSection from '@/components/form/section.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import FormSplit from '@/components/form/split.vue';
-import * as os from '@/os';
-import bytes from '@/filters/bytes';
-import { defaultStore } from '@/store';
-import MkChart from '@/components/MkChart.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { $i } from '@/account';
-
-const fetching = ref(true);
-const usage = ref<any>(null);
-const capacity = ref<any>(null);
-const uploadFolder = ref<any>(null);
-let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw);
-let autoSensitive = $ref($i.autoSensitive);
-
-const meterStyle = computed(() => {
-	return {
-		width: `${usage.value / capacity.value * 100}%`,
-		background: tinycolor({
-			h: 180 - (usage.value / capacity.value * 180),
-			s: 0.7,
-			l: 0.5,
-		}),
-	};
-});
-
-const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
-
-os.api('drive').then(info => {
-	capacity.value = info.capacity;
-	usage.value = info.usage;
-	fetching.value = false;
-});
-
-if (defaultStore.state.uploadFolder) {
-	os.api('drive/folders/show', {
-		folderId: defaultStore.state.uploadFolder,
-	}).then(response => {
-		uploadFolder.value = response;
-	});
-}
-
-function chooseUploadFolder() {
-	os.selectDriveFolder(false).then(async folder => {
-		defaultStore.set('uploadFolder', folder ? folder.id : null);
-		os.success();
-		if (defaultStore.state.uploadFolder) {
-			uploadFolder.value = await os.api('drive/folders/show', {
-				folderId: defaultStore.state.uploadFolder,
-			});
-		} else {
-			uploadFolder.value = null;
-		}
-	});
-}
-
-function saveProfile() {
-	os.api('i/update', {
-		alwaysMarkNsfw: !!alwaysMarkNsfw,
-		autoSensitive: !!autoSensitive,
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.drive,
-	icon: 'ti ti-cloud',
-});
-</script>
-
-<style lang="scss" scoped>
-
-@use "sass:math";
-
-.uawsfosz {
-
-	> .meter {
-		$size: 12px;
-		background: rgba(0, 0, 0, 0.1);
-		border-radius: math.div($size, 2);
-		overflow: hidden;
-
-		> div {
-			height: $size;
-			border-radius: math.div($size, 2);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
deleted file mode 100644
index 3fff8c6b1d..0000000000
--- a/packages/client/src/pages/settings/email.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection>
-		<template #label>{{ i18n.ts.emailAddress }}</template>
-		<FormInput v-model="emailAddress" type="email" manual-save>
-			<template #prefix><i class="ti ti-mail"></i></template>
-			<template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template>
-			<template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template>
-		</FormInput>
-	</FormSection>
-
-	<FormSection>
-		<FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail">
-			{{ i18n.ts.receiveAnnouncementFromInstance }}
-		</FormSwitch>
-	</FormSection>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.emailNotification }}</template>
-		<FormSwitch v-model="emailNotification_mention" class="_formBlock">
-			{{ i18n.ts._notification._types.mention }}
-		</FormSwitch>
-		<FormSwitch v-model="emailNotification_reply" class="_formBlock">
-			{{ i18n.ts._notification._types.reply }}
-		</FormSwitch>
-		<FormSwitch v-model="emailNotification_quote" class="_formBlock">
-			{{ i18n.ts._notification._types.quote }}
-		</FormSwitch>
-		<FormSwitch v-model="emailNotification_follow" class="_formBlock">
-			{{ i18n.ts._notification._types.follow }}
-		</FormSwitch>
-		<FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock">
-			{{ i18n.ts._notification._types.receiveFollowRequest }}
-		</FormSwitch>
-		<FormSwitch v-model="emailNotification_groupInvited" class="_formBlock">
-			{{ i18n.ts._notification._types.groupInvited }}
-		</FormSwitch>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, ref, watch } from 'vue';
-import FormSection from '@/components/form/section.vue';
-import FormInput from '@/components/form/input.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const emailAddress = ref($i!.email);
-
-const onChangeReceiveAnnouncementEmail = (v) => {
-	os.api('i/update', {
-		receiveAnnouncementEmail: v,
-	});
-};
-
-const saveEmailAddress = () => {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		os.apiWithDialog('i/update-email', {
-			password: password,
-			email: emailAddress.value,
-		});
-	});
-};
-
-const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
-const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
-const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote'));
-const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow'));
-const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest'));
-const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited'));
-
-const saveNotificationSettings = () => {
-	os.api('i/update', {
-		emailNotificationTypes: [
-			...[emailNotification_mention.value ? 'mention' : null],
-			...[emailNotification_reply.value ? 'reply' : null],
-			...[emailNotification_quote.value ? 'quote' : null],
-			...[emailNotification_follow.value ? 'follow' : null],
-			...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
-			...[emailNotification_groupInvited.value ? 'groupInvited' : null],
-		].filter(x => x != null),
-	});
-};
-
-watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => {
-	saveNotificationSettings();
-});
-
-onMounted(() => {
-	watch(emailAddress, () => {
-		saveEmailAddress();
-	});
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.email,
-	icon: 'ti ti-mail',
-});
-</script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
deleted file mode 100644
index 84d99d2fd7..0000000000
--- a/packages/client/src/pages/settings/general.vue
+++ /dev/null
@@ -1,196 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSelect v-model="lang" class="_formBlock">
-		<template #label>{{ i18n.ts.uiLanguage }}</template>
-		<option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option>
-		<template #caption>
-			<I18n :src="i18n.ts.i18nInfo" tag="span">
-				<template #link>
-					<MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
-				</template>
-			</I18n>
-		</template>
-	</FormSelect>
-
-	<FormRadios v-model="overridedDeviceKind" class="_formBlock">
-		<template #label>{{ i18n.ts.overridedDeviceKind }}</template>
-		<option :value="null">{{ i18n.ts.auto }}</option>
-		<option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option>
-		<option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option>
-		<option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option>
-	</FormRadios>
-
-	<FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.behavior }}</template>
-		<FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch>
-		<FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch>
-		<FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch>
-		<FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch>
-
-		<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
-			<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
-			<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
-			<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
-			<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
-		</FormSelect>
-	</FormSection>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.appearance }}</template>
-		<FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch>
-		<FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch>
-		<FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch>
-		<FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch>
-		<FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch>
-		<FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch>
-		<FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
-		<FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
-		<FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
-		<div class="_formBlock">
-			<FormRadios v-model="emojiStyle">
-				<template #label>{{ i18n.ts.emojiStyle }}</template>
-				<option value="native">{{ i18n.ts.native }}</option>
-				<option value="fluentEmoji">Fluent Emoji</option>
-				<option value="twemoji">Twemoji</option>
-			</FormRadios>
-			<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
-		</div>
-
-		<FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
-
-		<FormRadios v-model="fontSize" class="_formBlock">
-			<template #label>{{ i18n.ts.fontSize }}</template>
-			<option :value="null"><span style="font-size: 14px;">Aa</span></option>
-			<option value="1"><span style="font-size: 15px;">Aa</span></option>
-			<option value="2"><span style="font-size: 16px;">Aa</span></option>
-			<option value="3"><span style="font-size: 17px;">Aa</span></option>
-		</FormRadios>
-	</FormSection>
-
-	<FormSection>
-		<FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch>
-	</FormSection>
-
-	<FormSelect v-model="instanceTicker" class="_formBlock">
-		<template #label>{{ i18n.ts.instanceTicker }}</template>
-		<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
-		<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
-		<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
-	</FormSelect>
-
-	<FormSelect v-model="nsfw" class="_formBlock">
-		<template #label>{{ i18n.ts.nsfw }}</template>
-		<option value="respect">{{ i18n.ts._nsfw.respect }}</option>
-		<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
-		<option value="force">{{ i18n.ts._nsfw.force }}</option>
-	</FormSelect>
-
-	<FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock">
-		<template #label>{{ i18n.ts.numberOfPageCache }}</template>
-		<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
-	</FormRange>
-
-	<FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink>
-
-	<FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref, watch } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormRange from '@/components/form/range.vue';
-import FormSection from '@/components/form/section.vue';
-import FormLink from '@/components/form/link.vue';
-import MkLink from '@/components/MkLink.vue';
-import { langs } from '@/config';
-import { defaultStore } from '@/store';
-import * as os from '@/os';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const lang = ref(localStorage.getItem('lang'));
-const fontSize = ref(localStorage.getItem('fontSize'));
-const useSystemFont = ref(localStorage.getItem('useSystemFont') != null);
-
-async function reloadAsk() {
-	const { canceled } = await os.confirm({
-		type: 'info',
-		text: i18n.ts.reloadToApplySetting,
-	});
-	if (canceled) return;
-
-	unisonReload();
-}
-
-const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
-const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
-const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
-const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
-const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
-const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
-const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v));
-const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
-const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
-const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
-const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
-const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
-const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
-const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript'));
-const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
-const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
-const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
-const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
-const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
-const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
-const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode'));
-
-watch(lang, () => {
-	localStorage.setItem('lang', lang.value as string);
-	localStorage.removeItem('locale');
-});
-
-watch(fontSize, () => {
-	if (fontSize.value == null) {
-		localStorage.removeItem('fontSize');
-	} else {
-		localStorage.setItem('fontSize', fontSize.value);
-	}
-});
-
-watch(useSystemFont, () => {
-	if (useSystemFont.value) {
-		localStorage.setItem('useSystemFont', 't');
-	} else {
-		localStorage.removeItem('useSystemFont');
-	}
-});
-
-watch([
-	lang,
-	fontSize,
-	useSystemFont,
-	enableInfiniteScroll,
-	squareAvatars,
-	aiChanMode,
-	showGapBetweenNotesInTimeline,
-	instanceTicker,
-	overridedDeviceKind,
-], async () => {
-	await reloadAsk();
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.general,
-	icon: 'ti ti-adjustments',
-});
-</script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
deleted file mode 100644
index 7db267c142..0000000000
--- a/packages/client/src/pages/settings/import-export.vue
+++ /dev/null
@@ -1,165 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection>
-		<template #label>{{ i18n.ts._exportOrImport.allNotes }}</template>
-		<FormFolder>
-			<template #label>{{ i18n.ts.export }}</template>
-			<template #icon><i class="ti ti-download"></i></template>
-			<MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
-		</FormFolder>
-	</FormSection>
-	<FormSection>
-		<template #label>{{ i18n.ts._exportOrImport.followingList }}</template>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.export }}</template>
-			<template #icon><i class="ti ti-download"></i></template>
-			<FormSwitch v-model="excludeMutingUsers" class="_formBlock">
-				{{ i18n.ts._exportOrImport.excludeMutingUsers }}
-			</FormSwitch>
-			<FormSwitch v-model="excludeInactiveUsers" class="_formBlock">
-				{{ i18n.ts._exportOrImport.excludeInactiveUsers }}
-			</FormSwitch>
-			<MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.import }}</template>
-			<template #icon><i class="ti ti-upload"></i></template>
-			<MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
-		</FormFolder>
-	</FormSection>
-	<FormSection>
-		<template #label>{{ i18n.ts._exportOrImport.userLists }}</template>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.export }}</template>
-			<template #icon><i class="ti ti-download"></i></template>
-			<MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.import }}</template>
-			<template #icon><i class="ti ti-upload"></i></template>
-			<MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
-		</FormFolder>
-	</FormSection>
-	<FormSection>
-		<template #label>{{ i18n.ts._exportOrImport.muteList }}</template>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.export }}</template>
-			<template #icon><i class="ti ti-download"></i></template>
-			<MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.import }}</template>
-			<template #icon><i class="ti ti-upload"></i></template>
-			<MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
-		</FormFolder>
-	</FormSection>
-	<FormSection>
-		<template #label>{{ i18n.ts._exportOrImport.blockingList }}</template>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.export }}</template>
-			<template #icon><i class="ti ti-download"></i></template>
-			<MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
-		</FormFolder>
-		<FormFolder class="_formBlock">
-			<template #label>{{ i18n.ts.import }}</template>
-			<template #icon><i class="ti ti-upload"></i></template>
-			<MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
-		</FormFolder>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import FormSection from '@/components/form/section.vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { selectFile } from '@/scripts/select-file';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const excludeMutingUsers = ref(false);
-const excludeInactiveUsers = ref(false);
-
-const onExportSuccess = () => {
-	os.alert({
-		type: 'info',
-		text: i18n.ts.exportRequested,
-	});
-};
-
-const onImportSuccess = () => {
-	os.alert({
-		type: 'info',
-		text: i18n.ts.importRequested,
-	});
-};
-
-const onError = (ev) => {
-	os.alert({
-		type: 'error',
-		text: ev.message,
-	});
-};
-
-const exportNotes = () => {
-	os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
-};
-
-const exportFollowing = () => {
-	os.api('i/export-following', {
-		excludeMuting: excludeMutingUsers.value,
-		excludeInactive: excludeInactiveUsers.value,
-	})
-		.then(onExportSuccess).catch(onError);
-};
-
-const exportBlocking = () => {
-	os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
-};
-
-const exportUserLists = () => {
-	os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
-};
-
-const exportMuting = () => {
-	os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
-};
-
-const importFollowing = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
-	os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
-};
-
-const importUserLists = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
-	os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
-};
-
-const importMuting = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
-	os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
-};
-
-const importBlocking = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
-	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.importAndExport,
-	icon: 'ti ti-package',
-});
-</script>
-
-<style module>
-.button {
-	margin-right: 16px;
-}
-</style>
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
deleted file mode 100644
index 01436cd554..0000000000
--- a/packages/client/src/pages/settings/index.vue
+++ /dev/null
@@ -1,291 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
-		<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
-			<div class="body">
-				<div v-if="!narrow || currentPage?.route.name == null" class="nav">
-					<div class="baaadecd">
-						<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
-						<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
-					</div>
-				</div>
-				<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
-					<div class="bkzroven">
-						<RouterView/>
-					</div>
-				</div>
-			</div>
-		</div>
-	</MkSpacer>
-</mkstickycontainer>
-</template>
-
-<script setup lang="ts">
-import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue';
-import { i18n } from '@/i18n';
-import MkInfo from '@/components/MkInfo.vue';
-import MkSuperMenu from '@/components/MkSuperMenu.vue';
-import { scroll } from '@/scripts/scroll';
-import { signout, $i } from '@/account';
-import { unisonReload } from '@/scripts/unison-reload';
-import { instance } from '@/instance';
-import { useRouter } from '@/router';
-import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-import * as os from '@/os';
-
-const indexInfo = {
-	title: i18n.ts.settings,
-	icon: 'ti ti-settings',
-	hideHeader: true,
-};
-const INFO = ref(indexInfo);
-const el = ref<HTMLElement | null>(null);
-const childInfo = ref(null);
-
-const router = useRouter();
-
-let narrow = $ref(false);
-const NARROW_THRESHOLD = 600;
-
-let currentPage = $computed(() => router.currentRef.value.child);
-
-const ro = new ResizeObserver((entries, observer) => {
-	if (entries.length === 0) return;
-	narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
-});
-
-const menuDef = computed(() => [{
-	title: i18n.ts.basicSettings,
-	items: [{
-		icon: 'ti ti-user',
-		text: i18n.ts.profile,
-		to: '/settings/profile',
-		active: currentPage?.route.name === 'profile',
-	}, {
-		icon: 'ti ti-lock-open',
-		text: i18n.ts.privacy,
-		to: '/settings/privacy',
-		active: currentPage?.route.name === 'privacy',
-	}, {
-		icon: 'ti ti-mood-happy',
-		text: i18n.ts.reaction,
-		to: '/settings/reaction',
-		active: currentPage?.route.name === 'reaction',
-	}, {
-		icon: 'ti ti-cloud',
-		text: i18n.ts.drive,
-		to: '/settings/drive',
-		active: currentPage?.route.name === 'drive',
-	}, {
-		icon: 'ti ti-bell',
-		text: i18n.ts.notifications,
-		to: '/settings/notifications',
-		active: currentPage?.route.name === 'notifications',
-	}, {
-		icon: 'ti ti-mail',
-		text: i18n.ts.email,
-		to: '/settings/email',
-		active: currentPage?.route.name === 'email',
-	}, {
-		icon: 'ti ti-share',
-		text: i18n.ts.integration,
-		to: '/settings/integration',
-		active: currentPage?.route.name === 'integration',
-	}, {
-		icon: 'ti ti-lock',
-		text: i18n.ts.security,
-		to: '/settings/security',
-		active: currentPage?.route.name === 'security',
-	}],
-}, {
-	title: i18n.ts.clientSettings,
-	items: [{
-		icon: 'ti ti-adjustments',
-		text: i18n.ts.general,
-		to: '/settings/general',
-		active: currentPage?.route.name === 'general',
-	}, {
-		icon: 'ti ti-palette',
-		text: i18n.ts.theme,
-		to: '/settings/theme',
-		active: currentPage?.route.name === 'theme',
-	}, {
-		icon: 'ti ti-menu-2',
-		text: i18n.ts.navbar,
-		to: '/settings/navbar',
-		active: currentPage?.route.name === 'navbar',
-	}, {
-		icon: 'ti ti-equal-double',
-		text: i18n.ts.statusbar,
-		to: '/settings/statusbar',
-		active: currentPage?.route.name === 'statusbar',
-	}, {
-		icon: 'ti ti-music',
-		text: i18n.ts.sounds,
-		to: '/settings/sounds',
-		active: currentPage?.route.name === 'sounds',
-	}, {
-		icon: 'ti ti-plug',
-		text: i18n.ts.plugins,
-		to: '/settings/plugin',
-		active: currentPage?.route.name === 'plugin',
-	}],
-}, {
-	title: i18n.ts.otherSettings,
-	items: [{
-		icon: 'ti ti-package',
-		text: i18n.ts.importAndExport,
-		to: '/settings/import-export',
-		active: currentPage?.route.name === 'import-export',
-	}, {
-		icon: 'ti ti-planet-off',
-		text: i18n.ts.instanceMute,
-		to: '/settings/instance-mute',
-		active: currentPage?.route.name === 'instance-mute',
-	}, {
-		icon: 'ti ti-ban',
-		text: i18n.ts.muteAndBlock,
-		to: '/settings/mute-block',
-		active: currentPage?.route.name === 'mute-block',
-	}, {
-		icon: 'ti ti-message-off',
-		text: i18n.ts.wordMute,
-		to: '/settings/word-mute',
-		active: currentPage?.route.name === 'word-mute',
-	}, {
-		icon: 'ti ti-api',
-		text: 'API',
-		to: '/settings/api',
-		active: currentPage?.route.name === 'api',
-	}, {
-		icon: 'ti ti-webhook',
-		text: 'Webhook',
-		to: '/settings/webhook',
-		active: currentPage?.route.name === 'webhook',
-	}, {
-		icon: 'ti ti-dots',
-		text: i18n.ts.other,
-		to: '/settings/other',
-		active: currentPage?.route.name === 'other',
-	}],
-}, {
-	items: [{
-		icon: 'ti ti-device-floppy',
-		text: i18n.ts.preferencesBackups,
-		to: '/settings/preferences-backups',
-		active: currentPage?.route.name === 'preferences-backups',
-	}, {
-		type: 'button',
-		icon: 'ti ti-trash',
-		text: i18n.ts.clearCache,
-		action: () => {
-			localStorage.removeItem('locale');
-			localStorage.removeItem('theme');
-			unisonReload();
-		},
-	}, {
-		type: 'button',
-		icon: 'ti ti-power',
-		text: i18n.ts.logout,
-		action: async () => {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.logoutConfirm,
-			});
-			if (canceled) return;
-			signout();
-		},
-		danger: true,
-	}],
-}]);
-
-watch($$(narrow), () => {
-});
-
-onMounted(() => {
-	ro.observe(el.value);
-
-	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
-
-	if (!narrow && currentPage?.route.name == null) {
-		router.replace('/settings/profile');
-	}
-});
-
-onActivated(() => {
-	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
-
-	if (!narrow && currentPage?.route.name == null) {
-		router.replace('/settings/profile');
-	}
-});
-
-onUnmounted(() => {
-	ro.disconnect();
-});
-
-const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
-
-provideMetadataReceiver((info) => {
-	if (info == null) {
-		childInfo.value = null;
-	} else {
-		childInfo.value = info;
-	}
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(INFO);
-// w 890
-// h 700
-</script>
-
-<style lang="scss" scoped>
-.vvcocwet {
-	> .body {
-		> .nav {
-			.baaadecd {
-				> .info {
-					margin: 16px 0;
-				}
-
-				> .accounts {
-					> .avatar {
-						display: block;
-						width: 50px;
-						height: 50px;
-						margin: 8px auto 16px auto;
-					}
-				}
-			}
-		}
-
-		> .main {
-			.bkzroven {
-			}
-		}
-	}
-
-	&.wide {
-		> .body {
-			display: flex;
-			height: 100%;
-
-			> .nav {
-				width: 34%;
-				padding-right: 32px;
-				box-sizing: border-box;
-			}
-
-			> .main {
-				flex: 1;
-				min-width: 0;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue
deleted file mode 100644
index 54504de188..0000000000
--- a/packages/client/src/pages/settings/instance-mute.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<template>
-<div class="_formRoot">
-	<MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo>
-	<FormTextarea v-model="instanceMutes" class="_formBlock">
-		<template #label>{{ i18n.ts._instanceMute.heading }}</template>
-		<template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template>
-	</FormTextarea>
-	<MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const instanceMutes = ref($i!.mutedInstances.join('\n'));
-const changed = ref(false);
-
-async function save() {
-	let mutes = instanceMutes.value
-		.trim().split('\n')
-		.map(el => el.trim())
-		.filter(el => el);
-
-	await os.api('i/update', {
-		mutedInstances: mutes,
-	});
-
-	changed.value = false;
-
-	// Refresh filtered list to signal to the user how they've been saved
-	instanceMutes.value = mutes.join('\n');
-}
-
-watch(instanceMutes, () => {
-	changed.value = true;
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.instanceMute,
-	icon: 'ti ti-planet-off',
-});
-</script>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
deleted file mode 100644
index 557fe778e6..0000000000
--- a/packages/client/src/pages/settings/integration.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection v-if="instance.enableTwitterIntegration">
-		<template #label><i class="ti ti-brand-twitter"></i> Twitter</template>
-		<p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
-		<MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton>
-	</FormSection>
-
-	<FormSection v-if="instance.enableDiscordIntegration">
-		<template #label><i class="ti ti-brand-discord"></i> Discord</template>
-		<p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
-		<MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton>
-	</FormSection>
-
-	<FormSection v-if="instance.enableGithubIntegration">
-		<template #label><i class="ti ti-brand-github"></i> GitHub</template>
-		<p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
-		<MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onMounted, ref, watch } from 'vue';
-import { apiUrl } from '@/config';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import { $i } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const twitterForm = ref<Window | null>(null);
-const discordForm = ref<Window | null>(null);
-const githubForm = ref<Window | null>(null);
-
-const integrations = computed(() => $i!.integrations);
-
-function openWindow(service: string, type: string) {
-	return window.open(`${apiUrl}/${type}/${service}`,
-		`${service}_${type}_window`,
-		'height=570, width=520',
-	);
-}
-
-function connectTwitter() {
-	twitterForm.value = openWindow('twitter', 'connect');
-}
-
-function disconnectTwitter() {
-	openWindow('twitter', 'disconnect');
-}
-
-function connectDiscord() {
-	discordForm.value = openWindow('discord', 'connect');
-}
-
-function disconnectDiscord() {
-	openWindow('discord', 'disconnect');
-}
-
-function connectGithub() {
-	githubForm.value = openWindow('github', 'connect');
-}
-
-function disconnectGithub() {
-	openWindow('github', 'disconnect');
-}
-
-onMounted(() => {
-	document.cookie = `igi=${$i!.token}; path=/;` +
-		' max-age=31536000;' +
-		(document.location.protocol.startsWith('https') ? ' secure' : '');
-
-	watch(integrations, () => {
-		if (integrations.value.twitter) {
-			if (twitterForm.value) twitterForm.value.close();
-		}
-		if (integrations.value.discord) {
-			if (discordForm.value) discordForm.value.close();
-		}
-		if (integrations.value.github) {
-			if (githubForm.value) githubForm.value.close();
-		}
-	});
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.integration,
-	icon: 'ti ti-share',
-});
-</script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
deleted file mode 100644
index 1cf33d34db..0000000000
--- a/packages/client/src/pages/settings/mute-block.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<div class="_formRoot">
-	<MkTab v-model="tab" style="margin-bottom: var(--margin);">
-		<option value="mute">{{ i18n.ts.mutedUsers }}</option>
-		<option value="block">{{ i18n.ts.blockedUsers }}</option>
-	</MkTab>
-	<div v-if="tab === 'mute'">
-		<MkPagination :pagination="mutingPagination" class="muting">
-			<template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
-			<template #default="{items}">
-				<FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
-					<MkAcct :user="mute.mutee"/>
-				</FormLink>
-			</template>
-		</MkPagination>
-	</div>
-	<div v-if="tab === 'block'">
-		<MkPagination :pagination="blockingPagination" class="blocking">
-			<template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
-			<template #default="{items}">
-				<FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
-					<MkAcct :user="block.blockee"/>
-				</FormLink>
-			</template>
-		</MkPagination>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import MkTab from '@/components/MkTab.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import FormLink from '@/components/form/link.vue';
-import { userPage } from '@/filters/user';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let tab = $ref('mute');
-
-const mutingPagination = {
-	endpoint: 'mute/list' as const,
-	limit: 10,
-};
-
-const blockingPagination = {
-	endpoint: 'blocking/list' as const,
-	limit: 10,
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.muteAndBlock,
-	icon: 'ti ti-ban',
-});
-</script>
diff --git a/packages/client/src/pages/settings/navbar.vue b/packages/client/src/pages/settings/navbar.vue
deleted file mode 100644
index 0b2776ec90..0000000000
--- a/packages/client/src/pages/settings/navbar.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormTextarea v-model="items" tall manual-save class="_formBlock">
-		<template #label>{{ i18n.ts.navbar }}</template>
-		<template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template>
-	</FormTextarea>
-
-	<FormRadios v-model="menuDisplay" class="_formBlock">
-		<template #label>{{ i18n.ts.display }}</template>
-		<option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option>
-		<option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option>
-		<option value="top">{{ i18n.ts._menuDisplay.top }}</option>
-		<!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
-	</FormRadios>
-
-	<FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref, watch } from 'vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { defaultStore } from '@/store';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const items = ref(defaultStore.state.menu.join('\n'));
-
-const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== ''));
-const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
-
-async function reloadAsk() {
-	const { canceled } = await os.confirm({
-		type: 'info',
-		text: i18n.ts.reloadToApplySetting,
-	});
-	if (canceled) return;
-
-	unisonReload();
-}
-
-async function addItem() {
-	const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k));
-	const { canceled, result: item } = await os.select({
-		title: i18n.ts.addItem,
-		items: [...menu.map(k => ({
-			value: k, text: i18n.ts[navbarItemDef[k].title],
-		})), {
-			value: '-', text: i18n.ts.divider,
-		}],
-	});
-	if (canceled) return;
-	items.value = [...split.value, item].join('\n');
-}
-
-async function save() {
-	defaultStore.set('menu', split.value);
-	await reloadAsk();
-}
-
-function reset() {
-	defaultStore.reset('menu');
-	items.value = defaultStore.state.menu.join('\n');
-}
-
-watch(items, async () => {
-	await save();
-});
-
-watch(menuDisplay, async () => {
-	await reloadAsk();
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.navbar,
-	icon: 'ti ti-list',
-});
-</script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
deleted file mode 100644
index e85fede157..0000000000
--- a/packages/client/src/pages/settings/notifications.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink>
-	<FormSection>
-		<FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
-		<FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
-		<FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink>
-	</FormSection>
-	<FormSection>
-		<template #label>{{ i18n.ts.pushNotification }}</template>
-		<MkPushNotificationAllowButton ref="allowButton" />
-		<FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage">
-			<template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template>
-			<template #caption>
-				<I18n :src="i18n.ts.sendPushNotificationReadMessageCaption">
-					<template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template>
-				</I18n>
-			</template>
-		</FormSwitch>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import FormButton from '@/components/MkButton.vue';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
-
-let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>();
-let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer);
-let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false);
-
-async function readAllUnreadNotes() {
-	await os.api('i/read-all-unread-notes');
-}
-
-async function readAllMessagingMessages() {
-	await os.api('i/read-all-messaging-messages');
-}
-
-async function readAllNotifications() {
-	await os.api('notifications/mark-all-as-read');
-}
-
-function configure() {
-	const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x));
-	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
-		includingTypes,
-		showGlobalToggle: false,
-	}, {
-		done: async (res) => {
-			const { includingTypes: value } = res;
-			await os.apiWithDialog('i/update', {
-				mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
-			}).then(i => {
-				$i!.mutingNotificationTypes = i.mutingNotificationTypes;
-			});
-		},
-	}, 'closed');
-}
-
-function onChangeSendReadMessage(v: boolean) {
-	if (!pushRegistrationInServer) return;
-
-	os.apiWithDialog('sw/update-registration', {
-		endpoint: pushRegistrationInServer.endpoint,
-		sendReadMessage: v,
-	}).then(res => {
-		if (!allowButton)	return;
-		allowButton.pushRegistrationInServer = res;
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.notifications,
-	icon: 'ti ti-bell',
-});
-</script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
deleted file mode 100644
index 40bb202789..0000000000
--- a/packages/client/src/pages/settings/other.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote">
-		{{ i18n.ts.showFeaturedNotesInTimeline }}
-	</FormSwitch>
-
-	<!--
-	<FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch>
-	-->
-
-	<FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink>
-
-	<FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
-
-	<FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormLink from '@/components/form/link.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const reportError = computed(defaultStore.makeGetterSetter('reportError'));
-
-function onChangeInjectFeaturedNote(v) {
-	os.api('i/update', {
-		injectFeaturedNote: v,
-	}).then((i) => {
-		$i!.injectFeaturedNote = i.injectFeaturedNote;
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.other,
-	icon: 'ti ti-dots',
-});
-</script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
deleted file mode 100644
index 550bba242e..0000000000
--- a/packages/client/src/pages/settings/plugin.install.vue
+++ /dev/null
@@ -1,124 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo>
-
-	<FormTextarea v-model="code" tall class="_formBlock">
-		<template #label>{{ i18n.ts.code }}</template>
-	</FormTextarea>
-
-	<div class="_formBlock">
-		<FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, nextTick, ref } from 'vue';
-import { AiScript, parse } from '@syuilo/aiscript';
-import { serialize } from '@syuilo/aiscript/built/serializer';
-import { v4 as uuid } from 'uuid';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInfo from '@/components/MkInfo.vue';
-import * as os from '@/os';
-import { ColdDeviceStorage } from '@/store';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const code = ref(null);
-
-function installPlugin({ id, meta, ast, token }) {
-	ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
-		...meta,
-		id,
-		active: true,
-		configData: {},
-		token: token,
-		ast: ast,
-	}));
-}
-
-async function install() {
-	let ast;
-	try {
-		ast = parse(code.value);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: 'Syntax error :(',
-		});
-		return;
-	}
-
-	const meta = AiScript.collectMetadata(ast);
-	if (meta == null) {
-		os.alert({
-			type: 'error',
-			text: 'No metadata found :(',
-		});
-		return;
-	}
-
-	const metadata = meta.get(null);
-	if (metadata == null) {
-		os.alert({
-			type: 'error',
-			text: 'No metadata found :(',
-		});
-		return;
-	}
-
-	const { name, version, author, description, permissions, config } = metadata;
-	if (name == null || version == null || author == null) {
-		os.alert({
-			type: 'error',
-			text: 'Required property not found :(',
-		});
-		return;
-	}
-
-	const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
-		os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
-			title: i18n.ts.tokenRequested,
-			information: i18n.ts.pluginTokenRequestedDescription,
-			initialName: name,
-			initialPermissions: permissions,
-		}, {
-			done: async result => {
-				const { name, permissions } = result;
-				const { token } = await os.api('miauth/gen-token', {
-					session: null,
-					name: name,
-					permission: permissions,
-				});
-				res(token);
-			},
-		}, 'closed');
-	});
-
-	installPlugin({
-		id: uuid(),
-		meta: {
-			name, version, author, description, permissions, config,
-		},
-		token,
-		ast: serialize(ast),
-	});
-
-	os.success();
-
-	nextTick(() => {
-		unisonReload();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts._plugin.install,
-	icon: 'ti ti-download',
-});
-</script>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
deleted file mode 100644
index 905efd833d..0000000000
--- a/packages/client/src/pages/settings/plugin.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.manage }}</template>
-		<div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
-			<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
-
-			<FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
-
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.author }}</template>
-				<template #value>{{ plugin.author }}</template>
-			</MkKeyValue>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.description }}</template>
-				<template #value>{{ plugin.description }}</template>
-			</MkKeyValue>
-			<MkKeyValue class="_formBlock">
-				<template #key>{{ i18n.ts.permission }}</template>
-				<template #value>{{ plugin.permission }}</template>
-			</MkKeyValue>
-
-			<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-				<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
-				<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
-			</div>
-		</div>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, ref } from 'vue';
-import FormLink from '@/components/form/link.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import * as os from '@/os';
-import { ColdDeviceStorage } from '@/store';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const plugins = ref(ColdDeviceStorage.get('plugins'));
-
-function uninstall(plugin) {
-	ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
-	os.success();
-	nextTick(() => {
-		unisonReload();
-	});
-}
-
-// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
-async function config(plugin) {
-	const config = plugin.config;
-	for (const key in plugin.configData) {
-		config[key].default = plugin.configData[key];
-	}
-
-	const { canceled, result } = await os.form(plugin.name, config);
-	if (canceled) return;
-
-	const coldPlugins = ColdDeviceStorage.get('plugins');
-	coldPlugins.find(p => p.id === plugin.id)!.configData = result;
-	ColdDeviceStorage.set('plugins', coldPlugins);
-
-	nextTick(() => {
-		location.reload();
-	});
-}
-
-function changeActive(plugin, active) {
-	const coldPlugins = ColdDeviceStorage.get('plugins');
-	coldPlugins.find(p => p.id === plugin.id)!.active = active;
-	ColdDeviceStorage.set('plugins', coldPlugins);
-
-	nextTick(() => {
-		location.reload();
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.plugins,
-	icon: 'ti ti-plug',
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/settings/preferences-backups.vue b/packages/client/src/pages/settings/preferences-backups.vue
deleted file mode 100644
index f427a170c4..0000000000
--- a/packages/client/src/pages/settings/preferences-backups.vue
+++ /dev/null
@@ -1,444 +0,0 @@
-<template>
-<div class="_formRoot">
-	<div :class="$style.buttons">
-		<MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton>
-		<MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton>
-	</div>
-
-	<FormSection>
-		<template #label>{{ ts._preferencesBackups.list }}</template>
-		<template v-if="profiles && Object.keys(profiles).length > 0">
-			<div
-				v-for="(profile, id) in profiles"
-				:key="id"
-				class="_formBlock _panel"
-				:class="$style.profile"
-				@click="$event => menu($event, id)"
-				@contextmenu.prevent.stop="$event => menu($event, id)"
-			>
-				<div :class="$style.profileName">{{ profile.name }}</div>
-				<div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div>
-				<div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div>
-			</div>
-		</template>
-		<div v-else-if="profiles">
-			<MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo>
-		</div>
-		<MkLoading v-else/>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onMounted, onUnmounted, useCssModule } from 'vue';
-import { v4 as uuid } from 'uuid';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import * as os from '@/os';
-import { ColdDeviceStorage, defaultStore } from '@/store';
-import { unisonReload } from '@/scripts/unison-reload';
-import { stream } from '@/stream';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { version, host } from '@/config';
-import { definePageMetadata } from '@/scripts/page-metadata';
-const { t, ts } = i18n;
-
-useCssModule();
-
-const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
-	'menu',
-	'visibility',
-	'localOnly',
-	'statusbars',
-	'widgets',
-	'tl',
-	'overridedDeviceKind',
-	'serverDisconnectedBehavior',
-	'nsfw',
-	'animation',
-	'animatedMfm',
-	'loadRawImages',
-	'imageNewTab',
-	'disableShowingAnimatedImages',
-	'disablePagesScript',
-	'emojiStyle',
-	'disableDrawer',
-	'useBlurEffectForModal',
-	'useBlurEffect',
-	'showFixedPostForm',
-	'enableInfiniteScroll',
-	'useReactionPickerForContextMenu',
-	'showGapBetweenNotesInTimeline',
-	'instanceTicker',
-	'reactionPickerSize',
-	'reactionPickerWidth',
-	'reactionPickerHeight',
-	'reactionPickerUseDrawerForMobile',
-	'defaultSideView',
-	'menuDisplay',
-	'reportError',
-	'squareAvatars',
-	'numberOfPageCache',
-	'aiChanMode',
-];
-const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
-	'lightTheme',
-	'darkTheme',
-	'syncDeviceDarkMode',
-	'plugins',
-	'mediaVolume',
-	'sound_masterVolume',
-	'sound_note',
-	'sound_noteMy',
-	'sound_notification',
-	'sound_chat',
-	'sound_chatBg',
-	'sound_antenna',
-	'sound_channel',
-];
-
-const scope = ['clientPreferencesProfiles'];
-
-const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings'];
-
-type Profile = {
-	name: string;
-	createdAt: string;
-	updatedAt: string | null;
-	misskeyVersion: string;
-	host: string;
-	settings: {
-		hot: Record<keyof typeof defaultStoreSaveKeys, unknown>;
-		cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
-		fontSize: string | null;
-		useSystemFont: 't' | null;
-		wallpaper: string | null;
-	};
-};
-
-const connection = $i && stream.useChannel('main');
-
-let profiles = $ref<Record<string, Profile> | null>(null);
-
-os.api('i/registry/get-all', { scope })
-	.then(res => {
-		profiles = res || {};
-	});
-
-function isObject(value: unknown): value is Record<string, unknown> {
-	return value != null && typeof value === 'object' && !Array.isArray(value);
-}
-
-function validate(profile: unknown): void {
-	if (!isObject(profile)) throw new Error('not an object');
-
-	// Check if unnecessary properties exist
-	if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist');
-
-	if (!profile.name) throw new Error('Missing required prop: name');
-	if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion');
-	
-	// Check if createdAt and updatedAt is Date
-	// https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date
-	if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date');
-	if (profile.updatedAt) {
-		if (Number.isNaN(new Date(profile.updatedAt).getTime())) {
-			throw new Error('updatedAt is not Date');
-		}
-	} else if (profile.updatedAt !== null) {
-		throw new Error('updatedAt is not null');
-	}
-
-	if (!profile.settings) throw new Error('Missing required prop: settings');
-	if (!isObject(profile.settings)) throw new Error('Invalid prop: settings');
-}
-
-function getSettings(): Profile['settings'] {
-	const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>;
-	for (const key of defaultStoreSaveKeys) {
-		hot[key] = defaultStore.state[key];
-	}
-
-	const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
-	for (const key of coldDeviceStorageSaveKeys) {
-		cold[key] = ColdDeviceStorage.get(key);
-	}
-
-	return {
-		hot,
-		cold,
-		fontSize: localStorage.getItem('fontSize'),
-		useSystemFont: localStorage.getItem('useSystemFont') as 't' | null,
-		wallpaper: localStorage.getItem('wallpaper'),
-	};
-}
-
-async function saveNew(): Promise<void> {
-	if (!profiles) return;
-
-	const { canceled, result: name } = await os.inputText({
-		title: ts._preferencesBackups.inputName,
-	});
-	if (canceled) return;
-
-	if (Object.values(profiles).some(x => x.name === name)) {
-		return os.alert({
-			title: ts._preferencesBackups.cannotSave,
-			text: t('_preferencesBackups.nameAlreadyExists', { name }),
-		});
-	}
-
-	const id = uuid();
-	const profile: Profile = {
-		name,
-		createdAt: (new Date()).toISOString(),
-		updatedAt: null,
-		misskeyVersion: version,
-		host,
-		settings: getSettings(),
-	};
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-}
-
-function loadFile(): void {
-	const input = document.createElement('input');
-	input.type = 'file';
-	input.multiple = false;
-	input.onchange = async () => {
-		if (!profiles) return;
-		if (!input.files || input.files.length === 0) return;
-
-		const file = input.files[0];
-
-		if (file.type !== 'application/json') {
-			return os.alert({
-				type: 'error',
-				title: ts._preferencesBackups.cannotLoad,
-				text: ts._preferencesBackups.invalidFile,
-			});
-		}
-
-		let profile: Profile;
-		try {
-			profile = JSON.parse(await file.text()) as unknown as Profile;
-			validate(profile);
-		} catch (err) {
-			return os.alert({
-				type: 'error',
-				title: ts._preferencesBackups.cannotLoad,
-				text: err?.message,
-			});
-		}
-
-		const id = uuid();
-		await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-
-		// 一応廃棄
-		(window as any).__misskey_input_ref__ = null;
-	};
-
-	// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
-	// iOS Safari で正常に動かす為のおまじない
-	(window as any).__misskey_input_ref__ = input;
-
-	input.click();
-}
-
-async function applyProfile(id: string): Promise<void> {
-	if (!profiles) return;
-
-	const profile = profiles[id];
-
-	const { canceled: cancel1 } = await os.confirm({
-		type: 'warning',
-		title: ts._preferencesBackups.apply,
-		text: t('_preferencesBackups.applyConfirm', { name: profile.name }),
-	});
-	if (cancel1) return;
-
-	// TODO: バージョン or ホストが違ったらさらに警告を表示
-
-	const settings = profile.settings;
-
-	// defaultStore
-	for (const key of defaultStoreSaveKeys) {
-		if (settings.hot[key] !== undefined) {
-			defaultStore.set(key, settings.hot[key]);
-		}
-	}
-	
-	// coldDeviceStorage
-	for (const key of coldDeviceStorageSaveKeys) {
-		if (settings.cold[key] !== undefined) {
-			ColdDeviceStorage.set(key, settings.cold[key]);
-		}
-	}
-
-	// fontSize
-	if (settings.fontSize) {
-		localStorage.setItem('fontSize', settings.fontSize);
-	} else {
-		localStorage.removeItem('fontSize');
-	}
-
-	// useSystemFont
-	if (settings.useSystemFont) {
-		localStorage.setItem('useSystemFont', settings.useSystemFont);
-	} else {
-		localStorage.removeItem('useSystemFont');
-	}
-
-	// wallpaper
-	if (settings.wallpaper != null) {
-		localStorage.setItem('wallpaper', settings.wallpaper);
-	} else {
-		localStorage.removeItem('wallpaper');
-	}
-
-	const { canceled: cancel2 } = await os.confirm({
-		type: 'info',
-		text: ts.reloadToApplySetting,
-	});
-	if (cancel2) return;
-
-	unisonReload();
-}
-
-async function deleteProfile(id: string): Promise<void> {
-	if (!profiles) return;
-
-	const { canceled } = await os.confirm({
-		type: 'info',
-		title: ts.delete,
-		text: t('deleteAreYouSure', { x: profiles[id].name }),
-	});
-	if (canceled) return;
-
-	await os.apiWithDialog('i/registry/remove', { scope, key: id });
-	delete profiles[id];
-}
-
-async function save(id: string): Promise<void> {
-	if (!profiles) return;
-
-	const { name, createdAt } = profiles[id];
-
-	const { canceled } = await os.confirm({
-		type: 'info',
-		title: ts._preferencesBackups.save,
-		text: t('_preferencesBackups.saveConfirm', { name }),
-	});
-	if (canceled) return;
-
-	const profile: Profile = {
-		name,
-		createdAt,
-		updatedAt: (new Date()).toISOString(),
-		misskeyVersion: version,
-		host,
-		settings: getSettings(),
-	};
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-}
-
-async function rename(id: string): Promise<void> {
-	if (!profiles) return;
-
-	const { canceled: cancel1, result: name } = await os.inputText({
-		title: ts._preferencesBackups.inputName,
-	});
-	if (cancel1 || profiles[id].name === name) return;
-
-	if (Object.values(profiles).some(x => x.name === name)) {
-		return os.alert({
-			title: ts._preferencesBackups.cannotSave,
-			text: t('_preferencesBackups.nameAlreadyExists', { name }),
-		});
-	}
-
-	const registry = Object.assign({}, { ...profiles[id] });
-
-	const { canceled: cancel2 } = await os.confirm({
-		type: 'info',
-		title: ts._preferencesBackups.rename,
-		text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }),
-	});
-	if (cancel2) return;
-
-	registry.name = name;
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry });
-}
-
-function menu(ev: MouseEvent, profileId: string) {
-	if (!profiles) return;
-
-	return os.popupMenu([{
-		text: ts._preferencesBackups.apply,
-		icon: 'ti ti-check',
-		action: () => applyProfile(profileId),
-	}, {
-		type: 'a',
-		text: ts.download,
-		icon: 'ti ti-download',
-		href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })),
-		download: `${profiles[profileId].name}.json`,
-	}, null, {
-		text: ts.rename,
-		icon: 'ti ti-forms',
-		action: () => rename(profileId),
-	}, {
-		text: ts._preferencesBackups.save,
-		icon: 'ti ti-device-floppy',
-		action: () => save(profileId),
-	}, null, {
-		text: ts._preferencesBackups.delete,
-		icon: 'ti ti-trash',
-		action: () => deleteProfile(profileId),
-		danger: true,
-	}], ev.currentTarget ?? ev.target);
-}
-
-onMounted(() => {
-	// streamingのuser storage updateイベントを監視して更新
-	connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => {
-		if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return;
-		if (!profiles) return;
-
-		profiles[key] = value;
-	});
-});
-
-onUnmounted(() => {
-	connection?.off('registryUpdated');
-});
-
-definePageMetadata(computed(() => ({
-	title: ts.preferencesBackups,
-	icon: 'ti ti-device-floppy',
-	bg: 'var(--bg)',
-})));
-</script>
-
-<style lang="scss" module>
-.buttons {
-	display: flex;
-	gap: var(--margin);
-	flex-wrap: wrap;
-}
-
-.profile {
-	padding: 20px;
-	cursor: pointer;
-
-	&Name {
-		font-weight: 700;
-	}
-
-	&Time {
-		font-size: .85em;
-		opacity: .7;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
deleted file mode 100644
index 915ca05767..0000000000
--- a/packages/client/src/pages/settings/privacy.vue
+++ /dev/null
@@ -1,100 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch>
-	<FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch>
-
-	<FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()">
-		{{ i18n.ts.makeReactionsPublic }}
-		<template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
-	</FormSwitch>
-		
-	<FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()">
-		<template #label>{{ i18n.ts.ffVisibility }}</template>
-		<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
-		<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
-		<option value="private">{{ i18n.ts._ffVisibility.private }}</option>
-		<template #caption>{{ i18n.ts.ffVisibilityDescription }}</template>
-	</FormSelect>
-		
-	<FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()">
-		{{ i18n.ts.hideOnlineStatus }}
-		<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template>
-	</FormSwitch>
-	<FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()">
-		{{ i18n.ts.noCrawle }}
-		<template #caption>{{ i18n.ts.noCrawleDescription }}</template>
-	</FormSwitch>
-	<FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()">
-		{{ i18n.ts.makeExplorable }}
-		<template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
-	</FormSwitch>
-
-	<FormSection>
-		<FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch>
-		<FormFolder v-if="!rememberNoteVisibility" class="_formBlock">
-			<template #label>{{ i18n.ts.defaultNoteVisibility }}</template>
-			<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
-			<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
-			<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
-			<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
-
-			<FormSelect v-model="defaultNoteVisibility" class="_formBlock">
-				<option value="public">{{ i18n.ts._visibility.public }}</option>
-				<option value="home">{{ i18n.ts._visibility.home }}</option>
-				<option value="followers">{{ i18n.ts._visibility.followers }}</option>
-				<option value="specified">{{ i18n.ts._visibility.specified }}</option>
-			</FormSelect>
-			<FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch>
-		</FormFolder>
-	</FormSection>
-
-	<FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
-import FormSection from '@/components/form/section.vue';
-import FormFolder from '@/components/form/folder.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let isLocked = $ref($i.isLocked);
-let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
-let noCrawle = $ref($i.noCrawle);
-let isExplorable = $ref($i.isExplorable);
-let hideOnlineStatus = $ref($i.hideOnlineStatus);
-let publicReactions = $ref($i.publicReactions);
-let ffVisibility = $ref($i.ffVisibility);
-
-let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility'));
-let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly'));
-let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
-let keepCw = $computed(defaultStore.makeGetterSetter('keepCw'));
-
-function save() {
-	os.api('i/update', {
-		isLocked: !!isLocked,
-		autoAcceptFollowed: !!autoAcceptFollowed,
-		noCrawle: !!noCrawle,
-		isExplorable: !!isExplorable,
-		hideOnlineStatus: !!hideOnlineStatus,
-		publicReactions: !!publicReactions,
-		ffVisibility: ffVisibility,
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.privacy,
-	icon: 'ti ti-lock-open',
-});
-</script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
deleted file mode 100644
index 14eeeaaa11..0000000000
--- a/packages/client/src/pages/settings/profile.vue
+++ /dev/null
@@ -1,220 +0,0 @@
-<template>
-<div class="_formRoot">
-	<div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
-		<div class="avatar">
-			<MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/>
-			<MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
-		</div>
-		<MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
-	</div>
-
-	<FormInput v-model="profile.name" :max="30" manual-save class="_formBlock">
-		<template #label>{{ i18n.ts._profile.name }}</template>
-	</FormInput>
-
-	<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
-		<template #label>{{ i18n.ts._profile.description }}</template>
-		<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
-	</FormTextarea>
-
-	<FormInput v-model="profile.location" manual-save class="_formBlock">
-		<template #label>{{ i18n.ts.location }}</template>
-		<template #prefix><i class="ti ti-map-pin"></i></template>
-	</FormInput>
-
-	<FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock">
-		<template #label>{{ i18n.ts.birthday }}</template>
-		<template #prefix><i class="ti ti-cake"></i></template>
-	</FormInput>
-
-	<FormSelect v-model="profile.lang" class="_formBlock">
-		<template #label>{{ i18n.ts.language }}</template>
-		<option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option>
-	</FormSelect>
-
-	<FormSlot class="_formBlock">
-		<FormFolder>
-			<template #icon><i class="ti ti-list"></i></template>
-			<template #label>{{ i18n.ts._profile.metadataEdit }}</template>
-
-			<div class="_formRoot">
-				<FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock">
-					<FormInput v-model="record.name" small>
-						<template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template>
-					</FormInput>
-					<FormInput v-model="record.value" small>
-						<template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template>
-					</FormInput>
-				</FormSplit>
-				<MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
-				<MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
-			</div>
-		</FormFolder>
-		<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
-	</FormSlot>
-
-	<FormFolder>
-		<template #label>{{ i18n.ts.advancedSettings }}</template>
-
-		<div class="_formRoot">
-			<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
-			<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
-		</div>
-	</FormFolder>
-
-	<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { reactive, watch } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import FormInput from '@/components/form/input.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSlot from '@/components/form/slot.vue';
-import { host } from '@/config';
-import { selectFile } from '@/scripts/select-file';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-import { langmap } from '@/scripts/langmap';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const profile = reactive({
-	name: $i.name,
-	description: $i.description,
-	location: $i.location,
-	birthday: $i.birthday,
-	lang: $i.lang,
-	isBot: $i.isBot,
-	isCat: $i.isCat,
-	showTimelineReplies: $i.showTimelineReplies,
-});
-
-watch(() => profile, () => {
-	save();
-}, {
-	deep: true,
-});
-
-const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value })));
-
-function addField() {
-	fields.push({
-		name: '',
-		value: '',
-	});
-}
-
-while (fields.length < 4) {
-	addField();
-}
-
-function saveFields() {
-	os.apiWithDialog('i/update', {
-		fields: fields.filter(field => field.name !== '' && field.value !== ''),
-	});
-}
-
-function save() {
-	os.apiWithDialog('i/update', {
-		name: profile.name || null,
-		description: profile.description || null,
-		location: profile.location || null,
-		birthday: profile.birthday || null,
-		lang: profile.lang || null,
-		isBot: !!profile.isBot,
-		isCat: !!profile.isCat,
-		showTimelineReplies: !!profile.showTimelineReplies,
-	});
-}
-
-function changeAvatar(ev) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => {
-		let originalOrCropped = file;
-
-		const { canceled } = await os.confirm({
-			type: 'question',
-			text: i18n.t('cropImageAsk'),
-		});
-
-		if (!canceled) {
-			originalOrCropped = await os.cropImage(file, {
-				aspectRatio: 1,
-			});
-		}
-
-		const i = await os.apiWithDialog('i/update', {
-			avatarId: originalOrCropped.id,
-		});
-		$i.avatarId = i.avatarId;
-		$i.avatarUrl = i.avatarUrl;
-	});
-}
-
-function changeBanner(ev) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => {
-		let originalOrCropped = file;
-
-		const { canceled } = await os.confirm({
-			type: 'question',
-			text: i18n.t('cropImageAsk'),
-		});
-
-		if (!canceled) {
-			originalOrCropped = await os.cropImage(file, {
-				aspectRatio: 2,
-			});
-		}
-
-		const i = await os.apiWithDialog('i/update', {
-			bannerId: originalOrCropped.id,
-		});
-		$i.bannerId = i.bannerId;
-		$i.bannerUrl = i.bannerUrl;
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.profile,
-	icon: 'ti ti-user',
-});
-</script>
-
-<style lang="scss" scoped>
-.llvierxe {
-	position: relative;
-	background-size: cover;
-	background-position: center;
-	border: solid 1px var(--divider);
-	border-radius: 10px;
-	overflow: clip;
-
-	> .avatar {
-		display: inline-block;
-		text-align: center;
-		padding: 16px;
-
-		> .avatar {
-			display: inline-block;
-			width: 72px;
-			height: 72px;
-			margin: 0 auto 16px auto;
-		}
-	}
-
-	> .bannerEdit {
-		position: absolute;
-		top: 16px;
-		right: 16px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
deleted file mode 100644
index 2748cd7d4e..0000000000
--- a/packages/client/src/pages/settings/reaction.vue
+++ /dev/null
@@ -1,154 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FromSlot class="_formBlock">
-		<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
-		<div v-panel style="border-radius: 6px;">
-			<Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true">
-				<template #item="{element}">
-					<button class="_button item" @click="remove(element, $event)">
-						<MkEmoji :emoji="element" :normal="true"/>
-					</button>
-				</template>
-				<template #footer>
-					<button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
-				</template>
-			</Sortable>
-		</div>
-		<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
-	</FromSlot>
-
-	<FormRadios v-model="reactionPickerSize" class="_formBlock">
-		<template #label>{{ i18n.ts.size }}</template>
-		<option :value="1">{{ i18n.ts.small }}</option>
-		<option :value="2">{{ i18n.ts.medium }}</option>
-		<option :value="3">{{ i18n.ts.large }}</option>
-	</FormRadios>
-	<FormRadios v-model="reactionPickerWidth" class="_formBlock">
-		<template #label>{{ i18n.ts.numberOfColumn }}</template>
-		<option :value="1">5</option>
-		<option :value="2">6</option>
-		<option :value="3">7</option>
-		<option :value="4">8</option>
-		<option :value="5">9</option>
-	</FormRadios>
-	<FormRadios v-model="reactionPickerHeight" class="_formBlock">
-		<template #label>{{ i18n.ts.height }}</template>
-		<option :value="1">{{ i18n.ts.small }}</option>
-		<option :value="2">{{ i18n.ts.medium }}</option>
-		<option :value="3">{{ i18n.ts.large }}</option>
-		<option :value="4">{{ i18n.ts.large }}+</option>
-	</FormRadios>
-
-	<FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock">
-		{{ i18n.ts.useDrawerReactionPickerForMobile }}
-		<template #caption>{{ i18n.ts.needReloadToApply }}</template>
-	</FormSwitch>
-
-	<FormSection>
-		<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-			<FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
-			<FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
-		</div>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, watch } from 'vue';
-import Sortable from 'vuedraggable';
-import FormInput from '@/components/form/input.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FromSlot from '@/components/form/slot.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { deepClone } from '@/scripts/clone';
-
-let reactions = $ref(deepClone(defaultStore.state.reactions));
-
-const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize'));
-const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
-const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
-const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
-
-function save() {
-	defaultStore.set('reactions', reactions);
-}
-
-function remove(reaction, ev: MouseEvent) {
-	os.popupMenu([{
-		text: i18n.ts.remove,
-		action: () => {
-			reactions = reactions.filter(x => x !== reaction);
-		},
-	}], ev.currentTarget ?? ev.target);
-}
-
-function preview(ev: MouseEvent) {
-	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
-		asReactionPicker: true,
-		src: ev.currentTarget ?? ev.target,
-	}, {}, 'closed');
-}
-
-async function setDefault() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.resetAreYouSure,
-	});
-	if (canceled) return;
-
-	reactions = deepClone(defaultStore.def.reactions.default);
-}
-
-function chooseEmoji(ev: MouseEvent) {
-	os.pickEmoji(ev.currentTarget ?? ev.target, {
-		showPinned: false,
-	}).then(emoji => {
-		if (!reactions.includes(emoji)) {
-			reactions.push(emoji);
-		}
-	});
-}
-
-watch($$(reactions), () => {
-	save();
-}, {
-	deep: true,
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.reaction,
-	icon: 'ti ti-mood-happy',
-	action: {
-		icon: 'ti ti-eye',
-		handler: preview,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.zoaiodol {
-	padding: 12px;
-	font-size: 1.1em;
-
-	> .item {
-		display: inline-block;
-		padding: 8px;
-		cursor: move;
-	}
-
-	> .add {
-		display: inline-block;
-		padding: 8px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
deleted file mode 100644
index 33f49eb3ef..0000000000
--- a/packages/client/src/pages/settings/security.vue
+++ /dev/null
@@ -1,160 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection>
-		<template #label>{{ i18n.ts.password }}</template>
-		<FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton>
-	</FormSection>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
-		<X2fa/>
-	</FormSection>
-	
-	<FormSection>
-		<template #label>{{ i18n.ts.signinHistory }}</template>
-		<MkPagination :pagination="pagination" disable-auto-load>
-			<template #default="{items}">
-				<div>
-					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
-						<header>
-							<i v-if="item.success" class="ti ti-check icon succ"></i>
-							<i v-else class="ti ti-circle-x icon fail"></i>
-							<code class="ip _monospace">{{ item.ip }}</code>
-							<MkTime :time="item.createdAt" class="time"/>
-						</header>
-					</div>
-				</div>
-			</template>
-		</MkPagination>
-	</FormSection>
-
-	<FormSection>
-		<FormSlot>
-			<FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton>
-			<template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template>
-		</FormSlot>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import X2fa from './2fa.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSlot from '@/components/form/slot.vue';
-import FormButton from '@/components/MkButton.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'i/signin-history' as const,
-	limit: 5,
-};
-
-async function change() {
-	const { canceled: canceled1, result: currentPassword } = await os.inputText({
-		title: i18n.ts.currentPassword,
-		type: 'password',
-	});
-	if (canceled1) return;
-
-	const { canceled: canceled2, result: newPassword } = await os.inputText({
-		title: i18n.ts.newPassword,
-		type: 'password',
-	});
-	if (canceled2) return;
-
-	const { canceled: canceled3, result: newPassword2 } = await os.inputText({
-		title: i18n.ts.newPasswordRetype,
-		type: 'password',
-	});
-	if (canceled3) return;
-
-	if (newPassword !== newPassword2) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts.retypedNotMatch,
-		});
-		return;
-	}
-	
-	os.apiWithDialog('i/change-password', {
-		currentPassword,
-		newPassword,
-	});
-}
-
-function regenerateToken() {
-	os.inputText({
-		title: i18n.ts.password,
-		type: 'password',
-	}).then(({ canceled, result: password }) => {
-		if (canceled) return;
-		os.api('i/regenerate_token', {
-			password: password,
-		});
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.security,
-	icon: 'ti ti-lock',
-});
-</script>
-
-<style lang="scss" scoped>
-.timnmucd {
-	padding: 16px;
-
-	&:first-child {
-		border-top-left-radius: 6px;
-		border-top-right-radius: 6px;
-	}
-
-	&:last-child {
-		border-bottom-left-radius: 6px;
-		border-bottom-right-radius: 6px;
-	}
-
-	&:not(:last-child) {
-		border-bottom: solid 0.5px var(--divider);
-	}
-
-	> header {
-		display: flex;
-		align-items: center;
-
-		> .icon {
-			width: 1em;
-			margin-right: 0.75em;
-
-			&.succ {
-				color: var(--success);
-			}
-
-			&.fail {
-				color: var(--error);
-			}
-		}
-
-		> .ip {
-			flex: 1;
-			min-width: 0;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			margin-right: 12px;
-		}
-
-		> .time {
-			margin-left: auto;
-			opacity: 0.7;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/sounds.sound.vue b/packages/client/src/pages/settings/sounds.sound.vue
deleted file mode 100644
index 62627c6333..0000000000
--- a/packages/client/src/pages/settings/sounds.sound.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSelect v-model="type">
-		<template #label>{{ i18n.ts.sound }}</template>
-		<option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option>
-	</FormSelect>
-	<FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
-		<template #label>{{ i18n.ts.volume }}</template>
-	</FormRange>
-
-	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-		<FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton>
-		<FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormSelect from '@/components/form/select.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormRange from '@/components/form/range.vue';
-import { i18n } from '@/i18n';
-import { playFile, soundsTypes } from '@/scripts/sound';
-
-const props = defineProps<{
-	type: string;
-	volume: number;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'update', result: { type: string; volume: number; }): void;
-}>();
-
-let type = $ref(props.type);
-let volume = $ref(props.volume);
-
-function listen() {
-	playFile(type, volume);
-}
-
-function save() {
-	emit('update', { type, volume });
-}
-</script>
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
deleted file mode 100644
index ef60b2c3c9..0000000000
--- a/packages/client/src/pages/settings/sounds.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
-		<template #label>{{ i18n.ts.masterVolume }}</template>
-	</FormRange>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.sounds }}</template>
-		<FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;">
-			<template #label>{{ $t('_sfx.' + type) }}</template>
-			<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
-
-			<XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/>
-		</FormFolder>
-	</FormSection>
-
-	<FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref } from 'vue';
-import XSound from './sounds.sound.vue';
-import FormRange from '@/components/form/range.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import FormFolder from '@/components/form/folder.vue';
-import * as os from '@/os';
-import { ColdDeviceStorage } from '@/store';
-import { playFile } from '@/scripts/sound';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const masterVolume = computed({
-	get: () => {
-		return ColdDeviceStorage.get('sound_masterVolume');
-	},
-	set: (value) => {
-		ColdDeviceStorage.set('sound_masterVolume', value);
-	},
-});
-
-const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
-
-const sounds = ref({
-	note: ColdDeviceStorage.get('sound_note'),
-	noteMy: ColdDeviceStorage.get('sound_noteMy'),
-	notification: ColdDeviceStorage.get('sound_notification'),
-	chat: ColdDeviceStorage.get('sound_chat'),
-	chatBg: ColdDeviceStorage.get('sound_chatBg'),
-	antenna: ColdDeviceStorage.get('sound_antenna'),
-	channel: ColdDeviceStorage.get('sound_channel'),
-});
-
-async function updated(type, sound) {
-	const v = {
-		type: sound.type,
-		volume: sound.volume,
-	};
-
-	ColdDeviceStorage.set('sound_' + type, v);
-	sounds.value[type] = v;
-}
-
-function reset() {
-	for (const sound of Object.keys(sounds.value)) {
-		const v = ColdDeviceStorage.default['sound_' + sound];
-		ColdDeviceStorage.set('sound_' + sound, v);
-		sounds.value[sound] = v;
-	}
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.sounds,
-	icon: 'ti ti-music',
-});
-</script>
diff --git a/packages/client/src/pages/settings/statusbar.statusbar.vue b/packages/client/src/pages/settings/statusbar.statusbar.vue
deleted file mode 100644
index 608222386e..0000000000
--- a/packages/client/src/pages/settings/statusbar.statusbar.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock">
-		<template #label>{{ i18n.ts.type }}</template>
-		<option value="rss">RSS</option>
-		<option value="federation">Federation</option>
-		<option value="userList">User list timeline</option>
-	</FormSelect>
-
-	<MkInput v-model="statusbar.name" manual-save class="_formBlock">
-		<template #label>{{ i18n.ts.label }}</template>
-	</MkInput>
-
-	<MkSwitch v-model="statusbar.black" class="_formBlock">
-		<template #label>Black</template>
-	</MkSwitch>
-
-	<FormRadios v-model="statusbar.size" class="_formBlock">
-		<template #label>{{ i18n.ts.size }}</template>
-		<option value="verySmall">{{ i18n.ts.small }}+</option>
-		<option value="small">{{ i18n.ts.small }}</option>
-		<option value="medium">{{ i18n.ts.medium }}</option>
-		<option value="large">{{ i18n.ts.large }}</option>
-		<option value="veryLarge">{{ i18n.ts.large }}+</option>
-	</FormRadios>
-
-	<template v-if="statusbar.type === 'rss'">
-		<MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url">
-			<template #label>URL</template>
-		</MkInput>
-		<MkSwitch v-model="statusbar.props.shuffle" class="_formBlock">
-			<template #label>{{ i18n.ts.shuffle }}</template>
-		</MkSwitch>
-		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
-			<template #label>{{ i18n.ts.refreshInterval }}</template>
-		</MkInput>
-		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
-			<template #label>{{ i18n.ts.speed }}</template>
-			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
-		</FormRange>
-		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
-			<template #label>{{ i18n.ts.reverse }}</template>
-		</MkSwitch>
-	</template>
-	<template v-else-if="statusbar.type === 'federation'">
-		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
-			<template #label>{{ i18n.ts.refreshInterval }}</template>
-		</MkInput>
-		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
-			<template #label>{{ i18n.ts.speed }}</template>
-			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
-		</FormRange>
-		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
-			<template #label>{{ i18n.ts.reverse }}</template>
-		</MkSwitch>
-		<MkSwitch v-model="statusbar.props.colored" class="_formBlock">
-			<template #label>{{ i18n.ts.colored }}</template>
-		</MkSwitch>
-	</template>
-	<template v-else-if="statusbar.type === 'userList' && userLists != null">
-		<FormSelect v-model="statusbar.props.userListId" class="_formBlock">
-			<template #label>{{ i18n.ts.userList }}</template>
-			<option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
-		</FormSelect>
-		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
-			<template #label>{{ i18n.ts.refreshInterval }}</template>
-		</MkInput>
-		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
-			<template #label>{{ i18n.ts.speed }}</template>
-			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
-		</FormRange>
-		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
-			<template #label>{{ i18n.ts.reverse }}</template>
-		</MkSwitch>
-	</template>
-
-	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-		<FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, reactive, ref, watch } from 'vue';
-import FormSelect from '@/components/form/select.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormRange from '@/components/form/range.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { deepClone } from '@/scripts/clone';
-
-const props = defineProps<{
-	_id: string;
-	userLists: any[] | null;
-}>();
-
-const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id)));
-
-watch(() => statusbar.type, () => {
-	if (statusbar.type === 'rss') {
-		statusbar.name = 'NEWS';
-		statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews';
-		statusbar.props.shuffle = true;
-		statusbar.props.refreshIntervalSec = 120;
-		statusbar.props.display = 'marquee';
-		statusbar.props.marqueeDuration = 100;
-		statusbar.props.marqueeReverse = false;
-	} else if (statusbar.type === 'federation') {
-		statusbar.name = 'FEDERATION';
-		statusbar.props.refreshIntervalSec = 120;
-		statusbar.props.display = 'marquee';
-		statusbar.props.marqueeDuration = 100;
-		statusbar.props.marqueeReverse = false;
-		statusbar.props.colored = false;
-	} else if (statusbar.type === 'userList') {
-		statusbar.name = 'LIST TL';
-		statusbar.props.refreshIntervalSec = 120;
-		statusbar.props.display = 'marquee';
-		statusbar.props.marqueeDuration = 100;
-		statusbar.props.marqueeReverse = false;
-	}
-});
-
-watch(statusbar, save);
-
-async function save() {
-	const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
-	const statusbars = deepClone(defaultStore.state.statusbars);
-	statusbars[i] = deepClone(statusbar);
-	defaultStore.set('statusbars', statusbars);
-}
-
-function del() {
-	defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
-}
-</script>
diff --git a/packages/client/src/pages/settings/statusbar.vue b/packages/client/src/pages/settings/statusbar.vue
deleted file mode 100644
index 86c69fa2c3..0000000000
--- a/packages/client/src/pages/settings/statusbar.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock">
-		<template #label>{{ x.type ?? i18n.ts.notSet }}</template>
-		<template #suffix>{{ x.name }}</template>
-		<XStatusbar :_id="x.id" :user-lists="userLists"/>
-	</FormFolder>
-	<FormButton primary @click="add">{{ i18n.ts.add }}</FormButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onMounted, ref, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
-import XStatusbar from './statusbar.statusbar.vue';
-import FormRadios from '@/components/form/radios.vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { unisonReload } from '@/scripts/unison-reload';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const statusbars = defaultStore.reactiveState.statusbars;
-
-let userLists = $ref();
-
-onMounted(() => {
-	os.api('users/lists/list').then(res => {
-		userLists = res;
-	});
-});
-
-async function add() {
-	defaultStore.push('statusbars', {
-		id: uuid(),
-		type: null,
-		black: false,
-		size: 'medium',
-		props: {},
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.statusbar,
-	icon: 'ti ti-list',
-	bg: 'var(--bg)',
-});
-</script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
deleted file mode 100644
index 52a436e18d..0000000000
--- a/packages/client/src/pages/settings/theme.install.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormTextarea v-model="installThemeCode" class="_formBlock">
-		<template #label>{{ i18n.ts._theme.code }}</template>
-	</FormTextarea>
-
-	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-		<FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
-		<FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import JSON5 from 'json5';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormButton from '@/components/MkButton.vue';
-import { applyTheme, validateTheme } from '@/scripts/theme';
-import * as os from '@/os';
-import { addTheme, getThemes } from '@/theme-store';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let installThemeCode = $ref(null);
-
-function parseThemeCode(code: string) {
-	let theme;
-
-	try {
-		theme = JSON5.parse(code);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts._theme.invalid,
-		});
-		return false;
-	}
-	if (!validateTheme(theme)) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts._theme.invalid,
-		});
-		return false;
-	}
-	if (getThemes().some(t => t.id === theme.id)) {
-		os.alert({
-			type: 'info',
-			text: i18n.ts._theme.alreadyInstalled,
-		});
-		return false;
-	}
-
-	return theme;
-}
-
-function preview(code: string): void {
-	const theme = parseThemeCode(code);
-	if (theme) applyTheme(theme, false);
-}
-
-async function install(code: string): Promise<void> {
-	const theme = parseThemeCode(code);
-	if (!theme) return;
-	await addTheme(theme);
-	os.alert({
-		type: 'success',
-		text: i18n.t('_theme.installed', { name: theme.name }),
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts._theme.install,
-	icon: 'ti ti-download',
-});
-</script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
deleted file mode 100644
index 409f0af650..0000000000
--- a/packages/client/src/pages/settings/theme.manage.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSelect v-model="selectedThemeId" class="_formBlock">
-		<template #label>{{ i18n.ts.theme }}</template>
-		<optgroup :label="i18n.ts._theme.installedThemes">
-			<option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
-		</optgroup>
-		<optgroup :label="i18n.ts._theme.builtinThemes">
-			<option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
-		</optgroup>
-	</FormSelect>
-	<template v-if="selectedTheme">
-		<FormInput readonly :model-value="selectedTheme.author" class="_formBlock">
-			<template #label>{{ i18n.ts.author }}</template>
-		</FormInput>
-		<FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock">
-			<template #label>{{ i18n.ts._theme.description }}</template>
-		</FormTextarea>
-		<FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock">
-			<template #label>{{ i18n.ts._theme.code }}</template>
-			<template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template>
-		</FormTextarea>
-		<FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton>
-	</template>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref } from 'vue';
-import JSON5 from 'json5';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSelect from '@/components/form/select.vue';
-import FormInput from '@/components/form/input.vue';
-import FormButton from '@/components/MkButton.vue';
-import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import * as os from '@/os';
-import { getThemes, removeTheme } from '@/theme-store';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const installedThemes = ref(getThemes());
-const builtinThemes = getBuiltinThemesRef();
-const selectedThemeId = ref(null);
-
-const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]);
-
-const selectedTheme = computed(() => {
-	if (selectedThemeId.value == null) return null;
-	return themes.value.find(x => x.id === selectedThemeId.value);
-});
-
-const selectedThemeCode = computed(() => {
-	if (selectedTheme.value == null) return null;
-	return JSON5.stringify(selectedTheme.value, null, '\t');
-});
-
-function copyThemeCode() {
-	copyToClipboard(selectedThemeCode.value);
-	os.success();
-}
-
-function uninstall() {
-	removeTheme(selectedTheme.value as Theme);
-	installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value);
-	selectedThemeId.value = null;
-	os.success();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts._theme.manage,
-	icon: 'ti ti-tool',
-});
-</script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
deleted file mode 100644
index f37c213b06..0000000000
--- a/packages/client/src/pages/settings/theme.vue
+++ /dev/null
@@ -1,409 +0,0 @@
-<template>
-<div class="_formRoot rsljpzjq">
-	<div v-adaptive-border class="rfqxtzch _panel _formBlock">
-		<div class="toggle">
-			<div class="toggleWrapper">
-				<input id="dn" v-model="darkMode" type="checkbox" class="dn"/>
-				<label for="dn" class="toggle">
-					<span class="before">{{ i18n.ts.light }}</span>
-					<span class="after">{{ i18n.ts.dark }}</span>
-					<span class="toggle__handler">
-						<span class="crater crater--1"></span>
-						<span class="crater crater--2"></span>
-						<span class="crater crater--3"></span>
-					</span>
-					<span class="star star--1"></span>
-					<span class="star star--2"></span>
-					<span class="star star--3"></span>
-					<span class="star star--4"></span>
-					<span class="star star--5"></span>
-					<span class="star star--6"></span>
-				</label>
-			</div>
-		</div>
-		<div class="sync">
-			<FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch>
-		</div>
-	</div>
-
-	<div class="selects _formBlock">
-		<FormSelect v-model="lightThemeId" large class="select">
-			<template #label>{{ i18n.ts.themeForLightMode }}</template>
-			<template #prefix><i class="ti ti-sun"></i></template>
-			<option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option>
-			<optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes">
-				<option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
-			</optgroup>
-			<optgroup :label="i18n.ts._theme.builtinThemes">
-				<option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
-			</optgroup>
-		</FormSelect>
-		<FormSelect v-model="darkThemeId" large class="select">
-			<template #label>{{ i18n.ts.themeForDarkMode }}</template>
-			<template #prefix><i class="ti ti-moon"></i></template>
-			<option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option>
-			<optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes">
-				<option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
-			</optgroup>
-			<optgroup :label="i18n.ts._theme.builtinThemes">
-				<option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
-			</optgroup>
-		</FormSelect>
-	</div>
-
-	<FormSection>
-		<div class="_formLinksGrid">
-			<FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink>
-			<FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink>
-			<FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink>
-			<FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink>
-		</div>
-	</FormSection>
-
-	<FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton>
-	<FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, onActivated, ref, watch } from 'vue';
-import JSON5 from 'json5';
-import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
-import FormSection from '@/components/form/section.vue';
-import FormLink from '@/components/form/link.vue';
-import FormButton from '@/components/MkButton.vue';
-import { getBuiltinThemesRef } from '@/scripts/theme';
-import { selectFile } from '@/scripts/select-file';
-import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { ColdDeviceStorage, defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import { uniqueBy } from '@/scripts/array';
-import { fetchThemes, getThemes } from '@/theme-store';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const installedThemes = ref(getThemes());
-const builtinThemes = getBuiltinThemesRef();
-
-const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null);
-const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
-const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
-const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null);
-const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
-const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
-const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id));
-
-const darkTheme = ColdDeviceStorage.ref('darkTheme');
-const darkThemeId = computed({
-	get() {
-		return darkTheme.value.id;
-	},
-	set(id) {
-		const t = themes.value.find(x => x.id === id);
-		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
-			ColdDeviceStorage.set('darkTheme', t);
-		}
-	},
-});
-const lightTheme = ColdDeviceStorage.ref('lightTheme');
-const lightThemeId = computed({
-	get() {
-		return lightTheme.value.id;
-	},
-	set(id) {
-		const t = themes.value.find(x => x.id === id);
-		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
-			ColdDeviceStorage.set('lightTheme', t);
-		}
-	},
-});
-const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
-const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
-const wallpaper = ref(localStorage.getItem('wallpaper'));
-const themesCount = installedThemes.value.length;
-
-watch(syncDeviceDarkMode, () => {
-	if (syncDeviceDarkMode.value) {
-		defaultStore.set('darkMode', isDeviceDarkmode());
-	}
-});
-
-watch(wallpaper, () => {
-	if (wallpaper.value == null) {
-		localStorage.removeItem('wallpaper');
-	} else {
-		localStorage.setItem('wallpaper', wallpaper.value);
-	}
-	location.reload();
-});
-
-onActivated(() => {
-	fetchThemes().then(() => {
-		installedThemes.value = getThemes();
-	});
-});
-
-fetchThemes().then(() => {
-	installedThemes.value = getThemes();
-});
-
-function setWallpaper(event) {
-	selectFile(event.currentTarget ?? event.target, null).then(file => {
-		wallpaper.value = file.url;
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.theme,
-	icon: 'ti ti-palette',
-});
-</script>
-
-<style lang="scss" scoped>
-.rfqxtzch {
-	border-radius: 6px;
-
-	> .toggle {
-		position: relative;
-		padding: 26px 0;
-		text-align: center;
-
-		&.disabled {
-			opacity: 0.7;
-
-			&, * {
-				cursor: not-allowed !important;
-			}
-		}
-
-		> .toggleWrapper {
-			display: inline-block;
-			text-align: left;
-			overflow: clip;
-			padding: 0 100px;
-			vertical-align: bottom;
-
-			input {
-				position: absolute;
-				left: -99em;
-			}
-		}
-
-		.toggle {
-			cursor: pointer;
-			display: inline-block;
-			position: relative;
-			width: 90px;
-			height: 50px;
-			background-color: #83D8FF;
-			border-radius: 90px - 6;
-			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-
-			> .before, > .after {
-				position: absolute;
-				top: 15px;
-				transition: color 1s ease;
-			}
-
-			> .before {
-				left: -70px;
-				color: var(--accent);
-			}
-
-			> .after {
-				right: -68px;
-				color: var(--fg);
-			}
-		}
-
-		.toggle__handler {
-			display: inline-block;
-			position: relative;
-			z-index: 1;
-			top: 3px;
-			left: 3px;
-			width: 50px - 6;
-			height: 50px - 6;
-			background-color: #FFCF96;
-			border-radius: 50px;
-			box-shadow: 0 2px 6px rgba(0,0,0,.3);
-			transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
-			transform:  rotate(-45deg);
-
-			.crater {
-				position: absolute;
-				background-color: #E8CDA5;
-				opacity: 0;
-				transition: opacity 200ms ease-in-out !important;
-				border-radius: 100%;
-			}
-
-			.crater--1 {
-				top: 18px;
-				left: 10px;
-				width: 4px;
-				height: 4px;
-			}
-
-			.crater--2 {
-				top: 28px;
-				left: 22px;
-				width: 6px;
-				height: 6px;
-			}
-
-			.crater--3 {
-				top: 10px;
-				left: 25px;
-				width: 8px;
-				height: 8px;
-			}
-		}
-
-		.star {
-			position: absolute;
-			background-color: #ffffff;
-			transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-			border-radius: 50%;
-		}
-
-		.star--1 {
-			top: 10px;
-			left: 35px;
-			z-index: 0;
-			width: 30px;
-			height: 3px;
-		}
-
-		.star--2 {
-			top: 18px;
-			left: 28px;
-			z-index: 1;
-			width: 30px;
-			height: 3px;
-		}
-
-		.star--3 {
-			top: 27px;
-			left: 40px;
-			z-index: 0;
-			width: 30px;
-			height: 3px;
-		}
-
-		.star--4,
-		.star--5,
-		.star--6 {
-			opacity: 0;
-			transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-		}
-
-		.star--4 {
-			top: 16px;
-			left: 11px;
-			z-index: 0;
-			width: 2px;
-			height: 2px;
-			transform: translate3d(3px,0,0);
-		}
-
-		.star--5 {
-			top: 32px;
-			left: 17px;
-			z-index: 0;
-			width: 3px;
-			height: 3px;
-			transform: translate3d(3px,0,0);
-		}
-
-		.star--6 {
-			top: 36px;
-			left: 28px;
-			z-index: 0;
-			width: 2px;
-			height: 2px;
-			transform: translate3d(3px,0,0);
-		}
-
-		input:checked {
-			+ .toggle {
-				background-color: #749DD6;
-
-				> .before {
-					color: var(--fg);
-				}
-
-				> .after {
-					color: var(--accent);
-				}
-
-				.toggle__handler {
-					background-color: #FFE5B5;
-					transform: translate3d(40px, 0, 0) rotate(0);
-
-					.crater { opacity: 1; }
-				}
-
-				.star--1 {
-					width: 2px;
-					height: 2px;
-				}
-
-				.star--2 {
-					width: 4px;
-					height: 4px;
-					transform: translate3d(-5px, 0, 0);
-				}
-
-				.star--3 {
-					width: 2px;
-					height: 2px;
-					transform: translate3d(-7px, 0, 0);
-				}
-
-				.star--4,
-				.star--5,
-				.star--6 {
-					opacity: 1;
-					transform: translate3d(0,0,0);
-				}
-
-				.star--4 {
-					transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-				}
-
-				.star--5 {
-					transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-				}
-
-				.star--6 {
-					transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-				}
-			}
-		}
-	}
-
-	> .sync {
-		padding: 14px 16px;
-		border-top: solid 0.5px var(--divider);
-	}
-}
-
-.rsljpzjq {
-	> .selects {
-		display: flex;
-		gap: 1.5em var(--margin);
-		flex-wrap: wrap;
-
-		> .select {
-			flex: 1;
-			min-width: 280px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue
deleted file mode 100644
index c8ec1ea586..0000000000
--- a/packages/client/src/pages/settings/webhook.edit.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormInput v-model="name" class="_formBlock">
-		<template #label>Name</template>
-	</FormInput>
-
-	<FormInput v-model="url" type="url" class="_formBlock">
-		<template #label>URL</template>
-	</FormInput>
-
-	<FormInput v-model="secret" class="_formBlock">
-		<template #prefix><i class="ti ti-lock"></i></template>
-		<template #label>Secret</template>
-	</FormInput>
-
-	<FormSection>
-		<template #label>Events</template>
-
-		<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
-		<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
-		<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
-		<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
-		<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
-		<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
-		<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
-	</FormSection>
-
-	<FormSwitch v-model="active" class="_formBlock">Active</FormSwitch>
-
-	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-		<FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormInput from '@/components/form/input.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	webhookId: string;
-}>();
-
-const webhook = await os.api('i/webhooks/show', {
-	webhookId: props.webhookId,
-});
-
-let name = $ref(webhook.name);
-let url = $ref(webhook.url);
-let secret = $ref(webhook.secret);
-let active = $ref(webhook.active);
-
-let event_follow = $ref(webhook.on.includes('follow'));
-let event_followed = $ref(webhook.on.includes('followed'));
-let event_note = $ref(webhook.on.includes('note'));
-let event_reply = $ref(webhook.on.includes('reply'));
-let event_renote = $ref(webhook.on.includes('renote'));
-let event_reaction = $ref(webhook.on.includes('reaction'));
-let event_mention = $ref(webhook.on.includes('mention'));
-
-async function save(): Promise<void> {
-	const events = [];
-	if (event_follow) events.push('follow');
-	if (event_followed) events.push('followed');
-	if (event_note) events.push('note');
-	if (event_reply) events.push('reply');
-	if (event_renote) events.push('renote');
-	if (event_reaction) events.push('reaction');
-	if (event_mention) events.push('mention');
-
-	os.apiWithDialog('i/webhooks/update', {
-		name,
-		url,
-		secret,
-		webhookId: props.webhookId,
-		on: events,
-		active,
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: 'Edit webhook',
-	icon: 'ti ti-webhook',
-});
-</script>
diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue
deleted file mode 100644
index 00a547da69..0000000000
--- a/packages/client/src/pages/settings/webhook.new.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormInput v-model="name" class="_formBlock">
-		<template #label>Name</template>
-	</FormInput>
-
-	<FormInput v-model="url" type="url" class="_formBlock">
-		<template #label>URL</template>
-	</FormInput>
-
-	<FormInput v-model="secret" class="_formBlock">
-		<template #prefix><i class="ti ti-lock"></i></template>
-		<template #label>Secret</template>
-	</FormInput>
-
-	<FormSection>
-		<template #label>Events</template>
-
-		<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
-		<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
-		<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
-		<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
-		<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
-		<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
-		<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
-	</FormSection>
-
-	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-		<FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import FormInput from '@/components/form/input.vue';
-import FormSection from '@/components/form/section.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let name = $ref('');
-let url = $ref('');
-let secret = $ref('');
-
-let event_follow = $ref(true);
-let event_followed = $ref(true);
-let event_note = $ref(true);
-let event_reply = $ref(true);
-let event_renote = $ref(true);
-let event_reaction = $ref(true);
-let event_mention = $ref(true);
-
-async function create(): Promise<void> {
-	const events = [];
-	if (event_follow) events.push('follow');
-	if (event_followed) events.push('followed');
-	if (event_note) events.push('note');
-	if (event_reply) events.push('reply');
-	if (event_renote) events.push('renote');
-	if (event_reaction) events.push('reaction');
-	if (event_mention) events.push('mention');
-
-	os.apiWithDialog('i/webhooks/create', {
-		name,
-		url,
-		secret,
-		on: events,
-	});
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: 'Create new webhook',
-	icon: 'ti ti-webhook',
-});
-</script>
diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue
deleted file mode 100644
index 9be23ee4f0..0000000000
--- a/packages/client/src/pages/settings/webhook.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<template>
-<div class="_formRoot">
-	<FormSection>
-		<FormLink :to="`/settings/webhook/new`">
-			Create webhook
-		</FormLink>
-	</FormSection>
-	
-	<FormSection>
-		<MkPagination :pagination="pagination">
-			<template #default="{items}">
-				<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock">
-					<template #icon>
-						<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
-						<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
-						<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
-						<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
-					</template>
-					{{ webhook.name || webhook.url }}
-					<template #suffix>
-						<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
-					</template>
-				</FormLink>
-			</template>
-		</MkPagination>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkPagination from '@/components/MkPagination.vue';
-import FormSection from '@/components/form/section.vue';
-import FormLink from '@/components/form/link.vue';
-import { userPage } from '@/filters/user';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const pagination = {
-	endpoint: 'i/webhooks/list' as const,
-	limit: 10,
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: 'Webhook',
-	icon: 'ti ti-webhook',
-});
-</script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
deleted file mode 100644
index 6961d8151d..0000000000
--- a/packages/client/src/pages/settings/word-mute.vue
+++ /dev/null
@@ -1,128 +0,0 @@
-<template>
-<div class="_formRoot">
-	<MkTab v-model="tab" class="_formBlock">
-		<option value="soft">{{ i18n.ts._wordMute.soft }}</option>
-		<option value="hard">{{ i18n.ts._wordMute.hard }}</option>
-	</MkTab>
-	<div class="_formBlock">
-		<div v-show="tab === 'soft'">
-			<MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo>
-			<FormTextarea v-model="softMutedWords" class="_formBlock">
-				<span>{{ i18n.ts._wordMute.muteWords }}</span>
-				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
-			</FormTextarea>
-		</div>
-		<div v-show="tab === 'hard'">
-			<MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo>
-			<FormTextarea v-model="hardMutedWords" class="_formBlock">
-				<span>{{ i18n.ts._wordMute.muteWords }}</span>
-				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
-			</FormTextarea>
-			<MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock">
-				<template #key>{{ i18n.ts._wordMute.mutedNotes }}</template>
-				<template #value>{{ number(hardWordMutedNotesCount) }}</template>
-			</MkKeyValue>
-		</div>
-	</div>
-	<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import MkTab from '@/components/MkTab.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { defaultStore } from '@/store';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const render = (mutedWords) => mutedWords.map(x => {
-	if (Array.isArray(x)) {
-		return x.join(' ');
-	} else {
-		return x;
-	}
-}).join('\n');
-
-const tab = ref('soft');
-const softMutedWords = ref(render(defaultStore.state.mutedWords));
-const hardMutedWords = ref(render($i!.mutedWords));
-const hardWordMutedNotesCount = ref(null);
-const changed = ref(false);
-
-os.api('i/get-word-muted-notes-count', {}).then(response => {
-	hardWordMutedNotesCount.value = response?.count;
-});
-
-watch(softMutedWords, () => {
-	changed.value = true;
-});
-
-watch(hardMutedWords, () => {
-	changed.value = true;
-});
-
-async function save() {
-	const parseMutes = (mutes, tab) => {
-		// split into lines, remove empty lines and unnecessary whitespace
-		let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
-
-		// check each line if it is a RegExp or not
-		for (let i = 0; i < lines.length; i++) {
-			const line = lines[i];
-			const regexp = line.match(/^\/(.+)\/(.*)$/);
-			if (regexp) {
-				// check that the RegExp is valid
-				try {
-					new RegExp(regexp[1], regexp[2]);
-					// note that regex lines will not be split by spaces!
-				} catch (err: any) {
-					// invalid syntax: do not save, do not reset changed flag
-					os.alert({
-						type: 'error',
-						title: i18n.ts.regexpError,
-						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
-					});
-					// re-throw error so these invalid settings are not saved
-					throw err;
-				}
-			} else {
-				lines[i] = line.split(' ');
-			}
-		}
-
-		return lines;
-	};
-
-	let softMutes, hardMutes;
-	try {
-		softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft);
-		hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard);
-	} catch (err) {
-		// already displayed error message in parseMutes
-		return;
-	}
-
-	defaultStore.set('mutedWords', softMutes);
-	await os.api('i/update', {
-		mutedWords: hardMutes,
-	});
-
-	changed.value = false;
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.wordMute,
-	icon: 'ti ti-message-off',
-});
-</script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
deleted file mode 100644
index a7e797eeab..0000000000
--- a/packages/client/src/pages/share.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<XPostForm
-			v-if="state === 'writing'"
-			fixed
-			:instant="true"
-			:initial-text="initialText"
-			:initial-visibility="visibility"
-			:initial-files="files"
-			:initial-local-only="localOnly"
-			:reply="reply"
-			:renote="renote"
-			:initial-visible-users="visibleUsers"
-			class="_panel"
-			@posted="state = 'posted'"
-		/>
-		<MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
-
-import { } from 'vue';
-import { noteVisibilities } from 'misskey-js';
-import * as Acct from 'misskey-js/built/acct';
-import * as Misskey from 'misskey-js';
-import MkButton from '@/components/MkButton.vue';
-import XPostForm from '@/components/MkPostForm.vue';
-import * as os from '@/os';
-import { mainRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const urlParams = new URLSearchParams(window.location.search);
-const localOnlyQuery = urlParams.get('localOnly');
-const visibilityQuery = urlParams.get('visibility');
-
-let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
-let title = $ref(urlParams.get('title'));
-const text = urlParams.get('text');
-const url = urlParams.get('url');
-let initialText = $ref(null as string | null);
-let reply = $ref(null as Misskey.entities.Note | null);
-let renote = $ref(null as Misskey.entities.Note | null);
-let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null);
-let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null);
-let files = $ref([] as Misskey.entities.DriveFile[]);
-let visibleUsers = $ref([] as Misskey.entities.User[]);
-
-async function init() {
-	let noteText = '';
-	if (title) noteText += `[ ${title} ]\n`;
-	// Googleニュース対策
-	if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, '');
-	else if (text && title !== text) noteText += `${text}\n`;
-	if (url) noteText += `${url}`;
-	initialText = noteText.trim();
-
-	if (visibility === 'specified') {
-		const visibleUserIds = urlParams.get('visibleUserIds');
-		const visibleAccts = urlParams.get('visibleAccts');
-		await Promise.all(
-			[
-				...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
-				...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []),
-			]
-			// TypeScriptの指示通りに変換する
-			.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
-			.map(q => os.api('users/show', q)
-				.then(user => {
-					visibleUsers.push(user);
-				}, () => {
-					console.error(`Invalid user query: ${JSON.stringify(q)}`);
-				}),
-			),
-		);
-	}
-
-	try {
-		//#region Reply
-		const replyId = urlParams.get('replyId');
-		const replyUri = urlParams.get('replyUri');
-		if (replyId) {
-			reply = await os.api('notes/show', {
-				noteId: replyId,
-			});
-		} else if (replyUri) {
-			const obj = await os.api('ap/show', {
-				uri: replyUri,
-			});
-			if (obj.type === 'Note') {
-				reply = obj.object;
-			}
-		}
-		//#endregion
-
-		//#region Renote
-		const renoteId = urlParams.get('renoteId');
-		const renoteUri = urlParams.get('renoteUri');
-		if (renoteId) {
-			renote = await os.api('notes/show', {
-				noteId: renoteId,
-			});
-		} else if (renoteUri) {
-			const obj = await os.api('ap/show', {
-				uri: renoteUri,
-			});
-			if (obj.type === 'Note') {
-				renote = obj.object;
-			}
-		}
-		//#endregion
-
-		//#region Drive files
-		const fileIds = urlParams.get('fileIds');
-		if (fileIds) {
-			await Promise.all(
-				fileIds.split(',')
-				.map(fileId => os.api('drive/files/show', { fileId })
-					.then(file => {
-						files.push(file);
-					}, () => {
-						console.error(`Failed to fetch a file ${fileId}`);
-					}),
-				),
-			);
-		}
-		//#endregion
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			title: err.message,
-			text: err.name,
-		});
-	}
-
-	state = 'writing';
-}
-
-init();
-
-function close(): void {
-	window.close();
-
-	// 閉じなければ100ms後タイムラインに
-	window.setTimeout(() => {
-		mainRouter.push('/');
-	}, 100);
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.share,
-	icon: 'ti ti-share',
-});
-</script>
-
-<style lang="scss" scoped>
-.close {
-	margin: 16px auto;
-}
-</style>
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
deleted file mode 100644
index 5459532310..0000000000
--- a/packages/client/src/pages/signup-complete.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-<template>
-<div>
-	{{ i18n.ts.processing }}
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as os from '@/os';
-import { login } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	code: string;
-}>();
-
-onMounted(async () => {
-	await os.alert({
-		type: 'info',
-		text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }),
-	});
-	const res = await os.apiWithDialog('signup-pending', {
-		code: props.code,
-	});
-	login(res.i, '/');
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.signup,
-	icon: 'ti ti-user',
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
deleted file mode 100644
index 72775ed5c9..0000000000
--- a/packages/client/src/pages/tag.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800">
-		<XNotes class="_content" :pagination="pagination"/>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import XNotes from '@/components/MkNotes.vue';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const props = defineProps<{
-	tag: string;
-}>();
-
-const pagination = {
-	endpoint: 'notes/search-by-tag' as const,
-	limit: 10,
-	params: computed(() => ({
-		tag: props.tag,
-	})),
-};
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: props.tag,
-	icon: 'ti ti-hash',
-})));
-</script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
deleted file mode 100644
index d8ff170ca2..0000000000
--- a/packages/client/src/pages/theme-editor.vue
+++ /dev/null
@@ -1,283 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
-		<div class="cwepdizn _formRoot">
-			<FormFolder :default-open="true" class="_formBlock">
-				<template #label>{{ i18n.ts.backgroundColor }}</template>
-				<div class="cwepdizn-colors">
-					<div class="row">
-						<button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
-							<div class="preview" :style="{ background: color.forPreview }"></div>
-						</button>
-					</div>
-					<div class="row">
-						<button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
-							<div class="preview" :style="{ background: color.forPreview }"></div>
-						</button>
-					</div>
-				</div>
-			</FormFolder>
-
-			<FormFolder :default-open="true" class="_formBlock">
-				<template #label>{{ i18n.ts.accentColor }}</template>
-				<div class="cwepdizn-colors">
-					<div class="row">
-						<button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)">
-							<div class="preview" :style="{ background: color }"></div>
-						</button>
-					</div>
-				</div>
-			</FormFolder>
-
-			<FormFolder :default-open="true" class="_formBlock">
-				<template #label>{{ i18n.ts.textColor }}</template>
-				<div class="cwepdizn-colors">
-					<div class="row">
-						<button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)">
-							<div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
-						</button>
-					</div>
-				</div>
-			</FormFolder>
-
-			<FormFolder :default-open="false" class="_formBlock">
-				<template #icon><i class="ti ti-code"></i></template>
-				<template #label>{{ i18n.ts.editCode }}</template>
-
-				<div class="_formRoot">
-					<FormTextarea v-model="themeCode" tall class="_formBlock">
-						<template #label>{{ i18n.ts._theme.code }}</template>
-					</FormTextarea>
-					<FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton>
-				</div>
-			</FormFolder>
-
-			<FormFolder :default-open="false" class="_formBlock">
-				<template #label>{{ i18n.ts.addDescription }}</template>
-
-				<div class="_formRoot">
-					<FormTextarea v-model="description">
-						<template #label>{{ i18n.ts._theme.description }}</template>
-					</FormTextarea>
-				</div>
-			</FormFolder>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { watch } from 'vue';
-import { toUnicode } from 'punycode/';
-import tinycolor from 'tinycolor2';
-import { v4 as uuid } from 'uuid';
-import JSON5 from 'json5';
-
-import FormButton from '@/components/MkButton.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormFolder from '@/components/form/folder.vue';
-
-import { $i } from '@/account';
-import { Theme, applyTheme } from '@/scripts/theme';
-import lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
-import { host } from '@/config';
-import * as os from '@/os';
-import { ColdDeviceStorage, defaultStore } from '@/store';
-import { addTheme } from '@/theme-store';
-import { i18n } from '@/i18n';
-import { useLeaveGuard } from '@/scripts/use-leave-guard';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const bgColors = [
-	{ color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
-	{ color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' },
-	{ color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' },
-	{ color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' },
-	{ color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' },
-	{ color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' },
-	{ color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' },
-	{ color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' },
-	{ color: '#2b2b2b', kind: 'dark', forPreview: '#444444' },
-	{ color: '#362e29', kind: 'dark', forPreview: '#735c4d' },
-	{ color: '#303629', kind: 'dark', forPreview: '#506d2f' },
-	{ color: '#293436', kind: 'dark', forPreview: '#258192' },
-	{ color: '#2e2936', kind: 'dark', forPreview: '#504069' },
-	{ color: '#252722', kind: 'dark', forPreview: '#3c462f' },
-	{ color: '#212525', kind: 'dark', forPreview: '#303e3e' },
-	{ color: '#191919', kind: 'dark', forPreview: '#272727' },
-] as const;
-const accentColors = ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'];
-const fgColors = [
-	{ color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null },
-	{ color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' },
-	{ color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' },
-	{ color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' },
-	{ color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' },
-	{ color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' },
-	{ color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' },
-];
-
-let theme = $ref<Partial<Theme>>({
-	base: 'light',
-	props: lightTheme.props,
-});
-let description = $ref<string | null>(null);
-let themeCode = $ref<string | null>(null);
-let changed = $ref(false);
-
-useLeaveGuard($$(changed));
-
-function showPreview() {
-	os.pageWindow('/preview');
-}
-
-function setBgColor(color: typeof bgColors[number]) {
-	if (theme.base !== color.kind) {
-		const base = color.kind === 'dark' ? darkTheme : lightTheme;
-		for (const prop of Object.keys(base.props)) {
-			if (prop === 'accent') continue;
-			if (prop === 'fg') continue;
-			theme.props[prop] = base.props[prop];
-		}
-	}
-	theme.base = color.kind;
-	theme.props.bg = color.color;
-
-	if (theme.props.fg) {
-		const matchedFgColor = fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(theme.props.fg).toRgbString()));
-		if (matchedFgColor) setFgColor(matchedFgColor);
-	}
-}
-
-function setAccentColor(color) {
-	theme.props.accent = color;
-}
-
-function setFgColor(color) {
-	theme.props.fg = theme.base === 'light' ? color.forLight : color.forDark;
-}
-
-function apply() {
-	themeCode = JSON5.stringify(theme, null, '\t');
-	applyTheme(theme, false);
-	changed = true;
-}
-
-function applyThemeCode() {
-	let parsed;
-
-	try {
-		parsed = JSON5.parse(themeCode);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: i18n.ts._theme.invalid,
-		});
-		return;
-	}
-
-	theme = parsed;
-}
-
-async function saveAs() {
-	const { canceled, result: name } = await os.inputText({
-		title: i18n.ts.name,
-		allowEmpty: false,
-	});
-	if (canceled) return;
-
-	theme.id = uuid();
-	theme.name = name;
-	theme.author = `@${$i.username}@${toUnicode(host)}`;
-	if (description) theme.desc = description;
-	await addTheme(theme);
-	applyTheme(theme);
-	if (defaultStore.state.darkMode) {
-		ColdDeviceStorage.set('darkTheme', theme);
-	} else {
-		ColdDeviceStorage.set('lightTheme', theme);
-	}
-	changed = false;
-	os.alert({
-		type: 'success',
-		text: i18n.t('_theme.installed', { name: theme.name }),
-	});
-}
-
-watch($$(theme), apply, { deep: true });
-
-const headerActions = $computed(() => [{
-	asFullButton: true,
-	icon: 'ti ti-eye',
-	text: i18n.ts.preview,
-	handler: showPreview,
-}, {
-	asFullButton: true,
-	icon: 'ti ti-check',
-	text: i18n.ts.saveAs,
-	handler: saveAs,
-}]);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
-	title: i18n.ts.themeEditor,
-	icon: 'ti ti-palette',
-});
-</script>
-
-<style lang="scss" scoped>
-.cwepdizn {
-	::v-deep(.cwepdizn-colors) {
-		text-align: center;
-
-		> .row {
-			> .color {
-				display: inline-block;
-				position: relative;
-				width: 64px;
-				height: 64px;
-				border-radius: 8px;
-
-				> .preview {
-					position: absolute;
-					top: 0;
-					left: 0;
-					right: 0;
-					bottom: 0;
-					margin: auto;
-					width: 42px;
-					height: 42px;
-					border-radius: 4px;
-					box-shadow: 0 2px 4px rgb(0 0 0 / 30%);
-					transition: transform 0.15s ease;
-				}
-
-				&:hover {
-					> .preview {
-						transform: scale(1.1);
-					}
-				}
-
-				&.active {
-					box-shadow: 0 0 0 2px var(--divider) inset;
-				}
-
-				&.rounded {
-					border-radius: 999px;
-
-					> .preview {
-						border-radius: 999px;
-					}
-				}
-
-				&.char {
-					line-height: 42px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue
deleted file mode 100644
index ae7b098b90..0000000000
--- a/packages/client/src/pages/timeline.tutorial.vue
+++ /dev/null
@@ -1,142 +0,0 @@
-<template>
-<div class="_card">
-	<div :class="$style.title" class="_title">
-		<div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div>
-		<div :class="$style.step">
-			<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
-				<i class="ti ti-chevron-left"></i>
-			</button>
-			<span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span>
-			<button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++">
-				<i class="ti ti-chevron-right"></i>
-			</button>
-		</div>
-	</div>
-	<div v-if="tutorial === 0" class="_content">
-		<div>{{ i18n.ts._tutorial.step1_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step1_2 }}</div>
-		<div>{{ i18n.ts._tutorial.step1_3 }}</div>
-	</div>
-	<div v-else-if="tutorial === 1" class="_content">
-		<div>{{ i18n.ts._tutorial.step2_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step2_2 }}</div>
-		<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
-	</div>
-	<div v-else-if="tutorial === 2" class="_content">
-		<div>{{ i18n.ts._tutorial.step3_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step3_2 }}</div>
-		<div>{{ i18n.ts._tutorial.step3_3 }}</div>
-		<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
-	</div>
-	<div v-else-if="tutorial === 3" class="_content">
-		<div>{{ i18n.ts._tutorial.step4_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step4_2 }}</div>
-	</div>
-	<div v-else-if="tutorial === 4" class="_content">
-		<div>{{ i18n.ts._tutorial.step5_1 }}</div>
-		<I18n :src="i18n.ts._tutorial.step5_2" tag="div">
-			<template #featured>
-				<MkA class="_link" to="/featured">{{ i18n.ts.featured }}</MkA>
-			</template>
-			<template #explore>
-				<MkA class="_link" to="/explore">{{ i18n.ts.explore }}</MkA>
-			</template>
-		</I18n>
-		<div>{{ i18n.ts._tutorial.step5_3 }}</div>
-		<small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small>
-	</div>
-	<div v-else-if="tutorial === 5" class="_content">
-		<div>{{ i18n.ts._tutorial.step6_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step6_2 }}</div>
-		<div>{{ i18n.ts._tutorial.step6_3 }}</div>
-	</div>
-	<div v-else-if="tutorial === 6" class="_content">
-		<div>{{ i18n.ts._tutorial.step7_1 }}</div>
-		<I18n :src="i18n.ts._tutorial.step7_2" tag="div">
-			<template #help>
-				<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
-			</template>
-		</I18n>
-		<div>{{ i18n.ts._tutorial.step7_3 }}</div>
-	</div>
-	<div v-else-if="tutorial === 7" class="_content">
-		<div>{{ i18n.ts._tutorial.step8_1 }}</div>
-		<div>{{ i18n.ts._tutorial.step8_2 }}</div>
-		<small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small>
-	</div>
-
-	<div class="_footer" :class="$style.footer">
-		<template v-if="tutorial === tutorialsNumber - 1">
-			<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1" />
-			<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
-		</template>
-		<template v-else>
-			<MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton>
-		</template>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-
-const tutorialsNumber = 8;
-
-const tutorial = computed({
-	get() { return defaultStore.reactiveState.tutorial.value || 0; },
-	set(value) { defaultStore.set('tutorial', value); },
-});
-</script>
-
-<style lang="scss" module>
-.small {
-	opacity: 0.7;
-}
-
-.title {
-	display: flex;
-	flex-wrap: wrap;
-
-	&Text {
-		margin: 4px 0;
-		padding-right: 4px;
-	}
-}
-
-.step {
-	margin-left: auto;
-
-	&Arrow {
-		padding: 4px;
-		&:disabled {
-			opacity: 0.5;
-		}
-		&:first-child {
-			padding-right: 8px;
-		}
-		&:last-child {
-			padding-left: 8px;
-		}
-	}
-
-	&Number {
-		font-weight: normal;
-		margin: 4px;
-	}
-}
-
-.footer {
-	display: flex;
-	flex-wrap: wrap;
-	flex-direction: row;
-	justify-content: right;
-
-	&Item {
-		margin: 4px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
deleted file mode 100644
index 1c9e389367..0000000000
--- a/packages/client/src/pages/timeline.vue
+++ /dev/null
@@ -1,183 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs" :display-my-avatar="true"/></template>
-	<MkSpacer :content-max="800">
-		<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
-			<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
-			<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
-
-			<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
-			<div class="tl _block">
-				<XTimeline
-					ref="tl" :key="src"
-					class="tl"
-					:src="src"
-					:sound="true"
-					@queue="queueUpdated"
-				/>
-			</div>
-		</div>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, watch } from 'vue';
-import XTimeline from '@/components/MkTimeline.vue';
-import XPostForm from '@/components/MkPostForm.vue';
-import { scroll } from '@/scripts/scroll';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import { $i } from '@/account';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
-
-const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
-const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
-const keymap = {
-	't': focus,
-};
-
-const tlComponent = $ref<InstanceType<typeof XTimeline>>();
-const rootEl = $ref<HTMLElement>();
-
-let queue = $ref(0);
-const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) });
-
-watch ($$(src), () => queue = 0);
-
-function queueUpdated(q: number): void {
-	queue = q;
-}
-
-function top(): void {
-	scroll(rootEl, { top: 0 });
-}
-
-async function chooseList(ev: MouseEvent): Promise<void> {
-	const lists = await os.api('users/lists/list');
-	const items = lists.map(list => ({
-		type: 'link' as const,
-		text: list.name,
-		to: `/timeline/list/${list.id}`,
-	}));
-	os.popupMenu(items, ev.currentTarget ?? ev.target);
-}
-
-async function chooseAntenna(ev: MouseEvent): Promise<void> {
-	const antennas = await os.api('antennas/list');
-	const items = antennas.map(antenna => ({
-		type: 'link' as const,
-		text: antenna.name,
-		indicate: antenna.hasUnreadNote,
-		to: `/timeline/antenna/${antenna.id}`,
-	}));
-	os.popupMenu(items, ev.currentTarget ?? ev.target);
-}
-
-async function chooseChannel(ev: MouseEvent): Promise<void> {
-	const channels = await os.api('channels/followed');
-	const items = channels.map(channel => ({
-		type: 'link' as const,
-		text: channel.name,
-		indicate: channel.hasUnreadNote,
-		to: `/channels/${channel.id}`,
-	}));
-	os.popupMenu(items, ev.currentTarget ?? ev.target);
-}
-
-function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void {
-	defaultStore.set('tl', {
-		...defaultStore.state.tl,
-		src: newSrc,
-	});
-}
-
-async function timetravel(): Promise<void> {
-	const { canceled, result: date } = await os.inputDate({
-		title: i18n.ts.date,
-	});
-	if (canceled) return;
-
-	tlComponent.timetravel(date);
-}
-
-function focus(): void {
-	tlComponent.focus();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => [{
-	key: 'home',
-	title: i18n.ts._timelines.home,
-	icon: 'ti ti-home',
-	iconOnly: true,
-}, ...(isLocalTimelineAvailable ? [{
-	key: 'local',
-	title: i18n.ts._timelines.local,
-	icon: 'ti ti-messages',
-	iconOnly: true,
-}, {
-	key: 'social',
-	title: i18n.ts._timelines.social,
-	icon: 'ti ti-share',
-	iconOnly: true,
-}] : []), ...(isGlobalTimelineAvailable ? [{
-	key: 'global',
-	title: i18n.ts._timelines.global,
-	icon: 'ti ti-world',
-	iconOnly: true,
-}] : []), {
-	icon: 'ti ti-list',
-	title: i18n.ts.lists,
-	iconOnly: true,
-	onClick: chooseList,
-}, {
-	icon: 'ti ti-antenna',
-	title: i18n.ts.antennas,
-	iconOnly: true,
-	onClick: chooseAntenna,
-}, {
-	icon: 'ti ti-device-tv',
-	title: i18n.ts.channel,
-	iconOnly: true,
-	onClick: chooseChannel,
-}]);
-
-definePageMetadata(computed(() => ({
-	title: i18n.ts.timeline,
-	icon: src === 'local' ? 'ti ti-messages' : src === 'social' ? 'ti ti-share' : src === 'global' ? 'ti ti-world' : 'ti ti-home',
-})));
-</script>
-
-<style lang="scss" scoped>
-.cmuxhskf {
-	> .new {
-		position: sticky;
-		top: calc(var(--stickyTop, 0px) + 16px);
-		z-index: 1000;
-		width: 100%;
-
-		> button {
-			display: block;
-			margin: var(--margin) auto 0 auto;
-			padding: 8px 16px;
-			border-radius: 32px;
-		}
-	}
-
-	> .post-form {
-		border-radius: var(--radius);
-	}
-
-	> .tl {
-		background: var(--bg);
-		border-radius: var(--radius);
-		overflow: clip;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
deleted file mode 100644
index addc8db9e6..0000000000
--- a/packages/client/src/pages/user-info.vue
+++ /dev/null
@@ -1,485 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
-		<FormSuspense :p="init">
-			<div v-if="tab === 'overview'" class="_formRoot">
-				<div class="_formBlock aeakzknw">
-					<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
-					<div class="body">
-						<span class="name"><MkUserName class="name" :user="user"/></span>
-						<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
-						<span class="state">
-							<span v-if="suspended" class="suspended">Suspended</span>
-							<span v-if="silenced" class="silenced">Silenced</span>
-							<span v-if="moderator" class="moderator">Moderator</span>
-						</span>
-					</div>
-				</div>
-
-				<MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo>
-
-				<div v-if="user.url" class="_formLinksGrid _formBlock">
-					<FormLink :to="userPage(user)">Profile</FormLink>
-					<FormLink :to="user.url" :external="true">Profile (remote)</FormLink>
-				</div>
-				<FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink>
-
-				<FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
-
-				<div class="_formBlock">
-					<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
-						<template #key>ID</template>
-						<template #value><span class="_monospace">{{ user.id }}</span></template>
-					</MkKeyValue>
-					<!-- 要る？
-					<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;">
-						<template #key>IP (recent)</template>
-						<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
-					</MkKeyValue>
-					-->
-					<MkKeyValue oneline style="margin: 1em 0;">
-						<template #key>{{ i18n.ts.createdAt }}</template>
-						<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
-					</MkKeyValue>
-					<MkKeyValue v-if="info" oneline style="margin: 1em 0;">
-						<template #key>{{ i18n.ts.lastActiveDate }}</template>
-						<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
-					</MkKeyValue>
-					<MkKeyValue v-if="info" oneline style="margin: 1em 0;">
-						<template #key>{{ i18n.ts.email }}</template>
-						<template #value><span class="_monospace">{{ info.email }}</span></template>
-					</MkKeyValue>
-				</div>
-
-				<FormSection>
-					<template #label>ActivityPub</template>
-
-					<div class="_formBlock">
-						<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
-							<template #key>{{ i18n.ts.instanceInfo }}</template>
-							<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="ti ti-chevron-right"></i></MkA></template>
-						</MkKeyValue>
-						<MkKeyValue v-else oneline style="margin: 1em 0;">
-							<template #key>{{ i18n.ts.instanceInfo }}</template>
-							<template #value>(Local user)</template>
-						</MkKeyValue>
-						<MkKeyValue oneline style="margin: 1em 0;">
-							<template #key>{{ i18n.ts.updatedAt }}</template>
-							<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
-						</MkKeyValue>
-						<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
-							<template #key>Type</template>
-							<template #value><span class="_monospace">{{ ap.type }}</span></template>
-						</MkKeyValue>
-					</div>
-
-					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</FormButton>
-
-					<FormFolder class="_formBlock">
-						<template #label>Raw</template>
-
-						<MkObjectView v-if="ap" tall :value="ap">
-						</MkObjectView>
-					</FormFolder>
-				</FormSection>
-			</div>
-			<div v-else-if="tab === 'moderation'" class="_formRoot">
-				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:model-value="toggleModerator">{{ i18n.ts.moderator }}</FormSwitch>
-				<FormSwitch v-model="silenced" class="_formBlock" @update:model-value="toggleSilence">{{ i18n.ts.silence }}</FormSwitch>
-				<FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</FormSwitch>
-				{{ i18n.ts.reflectMayTakeTime }}
-				<div class="_formBlock">
-					<FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</FormButton>
-					<FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</FormButton>
-				</div>
-				<FormTextarea v-model="moderationNote" manual-save class="_formBlock">
-					<template #label>Moderation note</template>
-				</FormTextarea>
-				<FormFolder class="_formBlock">
-					<template #label>IP</template>
-					<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
-					<MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
-					<template v-if="iAmAdmin && ips">
-						<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
-							<span class="date">{{ record.createdAt }}</span>
-							<span class="ip">{{ record.ip }}</span>
-						</div>
-					</template>
-				</FormFolder>
-				<FormFolder class="_formBlock">
-					<template #label>{{ i18n.ts.files }}</template>
-
-					<MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
-				</FormFolder>
-				<FormSection>
-					<template #label>Drive Capacity Override</template>
-
-					<FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride">
-						<template #label>{{ i18n.ts.driveCapOverrideLabel }}</template>
-						<template #suffix>MB</template>
-						<template #caption>
-							{{ i18n.ts.driveCapOverrideCaption }}
-						</template>
-					</FormInput>
-				</FormSection>
-			</div>
-			<div v-else-if="tab === 'chart'" class="_formRoot">
-				<div class="cmhjzshm">
-					<div class="selects">
-						<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
-							<option value="per-user-notes">{{ i18n.ts.notes }}</option>
-						</MkSelect>
-					</div>
-					<div class="charts">
-						<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
-						<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
-						<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
-						<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
-					</div>
-				</div>
-			</div>
-			<div v-else-if="tab === 'raw'" class="_formRoot">
-				<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
-				</MkObjectView>
-
-				<MkObjectView tall :value="user">
-				</MkObjectView>
-			</div>
-		</FormSuspense>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import MkChart from '@/components/MkChart.vue';
-import MkObjectView from '@/components/MkObjectView.vue';
-import FormTextarea from '@/components/form/textarea.vue';
-import FormSwitch from '@/components/form/switch.vue';
-import FormLink from '@/components/form/link.vue';
-import FormSection from '@/components/form/section.vue';
-import FormButton from '@/components/MkButton.vue';
-import FormInput from '@/components/form/input.vue';
-import FormSplit from '@/components/form/split.vue';
-import FormFolder from '@/components/form/folder.vue';
-import MkKeyValue from '@/components/MkKeyValue.vue';
-import MkSelect from '@/components/form/select.vue';
-import FormSuspense from '@/components/form/suspense.vue';
-import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import * as os from '@/os';
-import number from '@/filters/number';
-import bytes from '@/filters/bytes';
-import { url } from '@/config';
-import { userPage, acct } from '@/filters/user';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import { iAmAdmin, iAmModerator } from '@/account';
-import { instance } from '@/instance';
-
-const props = defineProps<{
-	userId: string;
-}>();
-
-let tab = $ref('overview');
-let chartSrc = $ref('per-user-notes');
-let user = $ref<null | misskey.entities.UserDetailed>();
-let init = $ref<ReturnType<typeof createFetcher>>();
-let info = $ref();
-let ips = $ref(null);
-let ap = $ref(null);
-let moderator = $ref(false);
-let silenced = $ref(false);
-let suspended = $ref(false);
-let driveCapacityOverrideMb: number | null = $ref(0);
-let moderationNote = $ref('');
-const filesPagination = {
-	endpoint: 'admin/drive/files' as const,
-	limit: 10,
-	params: computed(() => ({
-		userId: props.userId,
-	})),
-};
-
-function createFetcher() {
-	if (iAmModerator) {
-		return () => Promise.all([os.api('users/show', {
-			userId: props.userId,
-		}), os.api('admin/show-user', {
-			userId: props.userId,
-		}), iAmAdmin ? os.api('admin/get-user-ips', {
-			userId: props.userId,
-		}) : Promise.resolve(null)]).then(([_user, _info, _ips]) => {
-			user = _user;
-			info = _info;
-			ips = _ips;
-			moderator = info.isModerator;
-			silenced = info.isSilenced;
-			suspended = info.isSuspended;
-			driveCapacityOverrideMb = user.driveCapacityOverrideMb;
-			moderationNote = info.moderationNote;
-
-			watch($$(moderationNote), async () => {
-				await os.api('admin/update-user-note', { userId: user.id, text: moderationNote });
-				await refreshUser();
-			});
-		});
-	} else {
-		return () => os.api('users/show', {
-			userId: props.userId,
-		}).then((res) => {
-			user = res;
-		});
-	}
-}
-
-function refreshUser() {
-	init = createFetcher();
-}
-
-async function updateRemoteUser() {
-	await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
-	refreshUser();
-}
-
-async function resetPassword() {
-	const { password } = await os.api('admin/reset-password', {
-		userId: user.id,
-	});
-
-	os.alert({
-		type: 'success',
-		text: i18n.t('newPasswordIs', { password }),
-	});
-}
-
-async function toggleSilence(v) {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
-	});
-	if (confirm.canceled) {
-		silenced = !v;
-	} else {
-		await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
-		await refreshUser();
-	}
-}
-
-async function toggleSuspend(v) {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
-	});
-	if (confirm.canceled) {
-		suspended = !v;
-	} else {
-		await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
-		await refreshUser();
-	}
-}
-
-async function toggleModerator(v) {
-	await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id });
-	await refreshUser();
-}
-
-async function deleteAllFiles() {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.deleteAllFilesConfirm,
-	});
-	if (confirm.canceled) return;
-	const process = async () => {
-		await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
-		os.success();
-	};
-	await process().catch(err => {
-		os.alert({
-			type: 'error',
-			text: err.toString(),
-		});
-	});
-	await refreshUser();
-}
-
-async function applyDriveCapacityOverride() {
-	let driveCapOrMb = driveCapacityOverrideMb;
-	if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) {
-		driveCapOrMb = null;
-	}
-	try {
-		await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb });
-		await refreshUser();
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: err.toString(),
-		});
-	}
-}
-
-async function deleteAccount() {
-	const confirm = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.deleteAccountConfirm,
-	});
-	if (confirm.canceled) return;
-
-	const typed = await os.inputText({
-		text: i18n.t('typeToConfirm', { x: user?.username }),
-	});
-	if (typed.canceled) return;
-
-	if (typed.result === user?.username) {
-		await os.apiWithDialog('admin/delete-account', {
-			userId: user.id,
-		});
-	} else {
-		os.alert({
-			type: 'error',
-			text: 'input not match',
-		});
-	}
-}
-
-watch(() => props.userId, () => {
-	init = createFetcher();
-}, {
-	immediate: true,
-});
-
-watch($$(user), () => {
-	os.api('ap/get', {
-		uri: user.uri ?? `${url}/users/${user.id}`,
-	}).then(res => {
-		ap = res;
-	});
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => [{
-	key: 'overview',
-	title: i18n.ts.overview,
-	icon: 'ti ti-info-circle',
-}, iAmModerator ? {
-	key: 'moderation',
-	title: i18n.ts.moderation,
-	icon: 'ti ti-user-exclamation',
-} : null, {
-	key: 'chart',
-	title: i18n.ts.charts,
-	icon: 'ti ti-chart-line',
-}, {
-	key: 'raw',
-	title: 'Raw',
-	icon: 'ti ti-code',
-}].filter(x => x != null));
-
-definePageMetadata(computed(() => ({
-	title: user ? acct(user) : i18n.ts.userInfo,
-	icon: 'ti ti-info-circle',
-})));
-</script>
-
-<style lang="scss" scoped>
-.aeakzknw {
-	display: flex;
-	align-items: center;
-
-	> .avatar {
-		display: block;
-		width: 64px;
-		height: 64px;
-		margin-right: 16px;
-	}
-
-	> .body {
-		flex: 1;
-		overflow: hidden;
-
-		> .name {
-			display: block;
-			width: 100%;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-
-		> .sub {
-			display: block;
-			width: 100%;
-			font-size: 85%;
-			opacity: 0.7;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-
-		> .state {
-			display: flex;
-			gap: 8px;
-			flex-wrap: wrap;
-			margin-top: 4px;
-
-			&:empty {
-				display: none;
-			}
-
-			> .suspended, > .silenced, > .moderator {
-				display: inline-block;
-				border: solid 1px;
-				border-radius: 6px;
-				padding: 2px 6px;
-				font-size: 85%;
-			}
-
-			> .suspended {
-				color: var(--error);
-				border-color: var(--error);
-			}
-
-			> .silenced {
-				color: var(--warn);
-				border-color: var(--warn);
-			}
-
-			> .moderator {
-				color: var(--success);
-				border-color: var(--success);
-			}
-		}
-	}
-}
-
-.cmhjzshm {
-	> .selects {
-		display: flex;
-		margin: 0 0 16px 0;
-	}
-
-	> .charts {
-		> .label {
-			margin-bottom: 12px;
-			font-weight: bold;
-		}
-	}
-}
-</style>
-
-<style lang="scss" module>
-.ip {
-	display: flex;
-
-	> :global(.date) {
-		opacity: 0.7;
-	}
-
-	> :global(.ip) {
-		margin-left: auto;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
deleted file mode 100644
index fdb3167375..0000000000
--- a/packages/client/src/pages/user-list-timeline.vue
+++ /dev/null
@@ -1,121 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj">
-		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
-		<div class="tl _block">
-			<XTimeline
-				ref="tlEl" :key="listId"
-				class="tl"
-				src="list"
-				:list="listId"
-				:sound="true"
-				@queue="queueUpdated"
-			/>
-		</div>
-	</div>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch, inject } from 'vue';
-import XTimeline from '@/components/MkTimeline.vue';
-import { scroll } from '@/scripts/scroll';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const router = useRouter();
-
-const props = defineProps<{
-	listId: string;
-}>();
-
-let list = $ref(null);
-let queue = $ref(0);
-let tlEl = $ref<InstanceType<typeof XTimeline>>();
-let rootEl = $ref<HTMLElement>();
-
-watch(() => props.listId, async () => {
-	list = await os.api('users/lists/show', {
-		listId: props.listId,
-	});
-}, { immediate: true });
-
-function queueUpdated(q) {
-	queue = q;
-}
-
-function top() {
-	scroll(rootEl, { top: 0 });
-}
-
-function settings() {
-	router.push(`/my/lists/${props.listId}`);
-}
-
-async function timetravel() {
-	const { canceled, result: date } = await os.inputDate({
-		title: i18n.ts.date,
-	});
-	if (canceled) return;
-
-	tlEl.timetravel(date);
-}
-
-const headerActions = $computed(() => list ? [{
-	icon: 'fas fa-calendar-alt',
-	text: i18n.ts.jumpToSpecifiedDate,
-	handler: timetravel,
-}, {
-	icon: 'ti ti-settings',
-	text: i18n.ts.settings,
-	handler: settings,
-}] : []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => list ? {
-	title: list.name,
-	icon: 'ti ti-list',
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.eqqrhokj {
-	padding: var(--margin);
-
-	> .new {
-		position: sticky;
-		top: calc(var(--stickyTop, 0px) + 16px);
-		z-index: 1000;
-		width: 100%;
-
-		> button {
-			display: block;
-			margin: var(--margin) auto 0 auto;
-			padding: 8px 16px;
-			border-radius: 32px;
-		}
-	}
-
-	> .tl {
-		background: var(--bg);
-		border-radius: var(--radius);
-		overflow: clip;
-	}
-
-	&.min-width_800px {
-		max-width: 800px;
-		margin: 0 auto;
-	}
-}
-
-@container (min-width: 800px) {
-	.eqqrhokj {
-		max-width: 800px;
-		margin: 0 auto;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue
deleted file mode 100644
index 8c71aacb0c..0000000000
--- a/packages/client/src/pages/user/clips.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<div class="pages-user-clips">
-		<MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list">
-			<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
-				<b>{{ item.name }}</b>
-				<div v-if="item.description" class="description">{{ item.description }}</div>
-			</MkA>
-		</MkPagination>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import MkPagination from '@/components/MkPagination.vue';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-}>();
-
-const pagination = {
-	endpoint: 'users/clips' as const,
-	limit: 20,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-</script>
-
-<style lang="scss" scoped>
-.pages-user-clips {
-	> .list {
-		> .item {
-			display: block;
-			padding: 16px;
-
-			> .description {
-				margin-top: 8px;
-				padding-top: 8px;
-				border-top: solid 0.5px var(--divider);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue
deleted file mode 100644
index d42acd838f..0000000000
--- a/packages/client/src/pages/user/follow-list.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-<div>
-	<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
-		<div class="users">
-			<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/>
-		</div>
-	</MkPagination>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import MkUserInfo from '@/components/MkUserInfo.vue';
-import MkPagination from '@/components/MkPagination.vue';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-	type: 'following' | 'followers';
-}>();
-
-const followingPagination = {
-	endpoint: 'users/following' as const,
-	limit: 20,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-
-const followersPagination = {
-	endpoint: 'users/followers' as const,
-	limit: 20,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-</script>
-
-<style lang="scss" scoped>
-.mk-following-or-followers {
-	> .users {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-		grid-gap: var(--margin);
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user/followers.vue b/packages/client/src/pages/user/followers.vue
deleted file mode 100644
index 17c2843381..0000000000
--- a/packages/client/src/pages/user/followers.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="1000">
-		<transition name="fade" mode="out-in">
-			<div v-if="user">
-				<XFollowList :user="user" type="followers"/>
-			</div>
-			<MkError v-else-if="error" @retry="fetchUser()"/>
-			<MkLoading v-else/>
-		</transition>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import * as misskey from 'misskey-js';
-import XFollowList from './follow-list.vue';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	acct: string;
-}>(), {
-});
-
-let user = $ref<null | misskey.entities.UserDetailed>(null);
-let error = $ref(null);
-
-function fetchUser(): void {
-	if (props.acct == null) return;
-	user = null;
-	os.api('users/show', Acct.parse(props.acct)).then(u => {
-		user = u;
-	}).catch(err => {
-		error = err;
-	});
-}
-
-watch(() => props.acct, fetchUser, {
-	immediate: true,
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => user ? {
-	icon: 'ti ti-user',
-	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
-	subtitle: i18n.ts.followers,
-	userName: user,
-	avatar: user,
-} : null));
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/user/following.vue b/packages/client/src/pages/user/following.vue
deleted file mode 100644
index 03892ec03d..0000000000
--- a/packages/client/src/pages/user/following.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer :content-max="1000">
-		<transition name="fade" mode="out-in">
-			<div v-if="user">
-				<XFollowList :user="user" type="following"/>
-			</div>
-			<MkError v-else-if="error" @retry="fetchUser()"/>
-			<MkLoading v-else/>
-		</transition>
-	</MkSpacer>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import * as misskey from 'misskey-js';
-import XFollowList from './follow-list.vue';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	acct: string;
-}>(), {
-});
-
-let user = $ref<null | misskey.entities.UserDetailed>(null);
-let error = $ref(null);
-
-function fetchUser(): void {
-	if (props.acct == null) return;
-	user = null;
-	os.api('users/show', Acct.parse(props.acct)).then(u => {
-		user = u;
-	}).catch(err => {
-		error = err;
-	});
-}
-
-watch(() => props.acct, fetchUser, {
-	immediate: true,
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => user ? {
-	icon: 'ti ti-user',
-	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
-	subtitle: i18n.ts.following,
-	userName: user,
-	avatar: user,
-} : null));
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue
deleted file mode 100644
index b80e83fb11..0000000000
--- a/packages/client/src/pages/user/gallery.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<MkPagination v-slot="{items}" :pagination="pagination">
-		<div class="jrnovfpt">
-			<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-		</div>
-	</MkPagination>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
-import MkPagination from '@/components/MkPagination.vue';
-
-const props = withDefaults(defineProps<{
-	user: misskey.entities.User;
-}>(), {
-});
-
-const pagination = {
-	endpoint: 'users/gallery/posts' as const,
-	limit: 6,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-</script>
-
-<style lang="scss" scoped>
-.jrnovfpt {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-	grid-gap: 12px;
-	margin: var(--margin);
-}
-</style>
diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue
deleted file mode 100644
index 43c1b37e1d..0000000000
--- a/packages/client/src/pages/user/home.vue
+++ /dev/null
@@ -1,530 +0,0 @@
-<template>
-<MkSpacer :content-max="narrow ? 800 : 1100">
-	<div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
-		<div class="main">
-			<!-- TODO -->
-			<!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
-			<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
-
-			<div class="profile">
-				<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
-
-				<div :key="user.id" class="_block main">
-					<div class="banner-container" :style="style">
-						<div ref="bannerEl" class="banner" :style="style"></div>
-						<div class="fade"></div>
-						<div class="title">
-							<MkUserName class="name" :user="user" :nowrap="true"/>
-							<div class="bottom">
-								<span class="username"><MkAcct :user="user" :detail="true"/></span>
-								<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
-								<span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
-								<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
-								<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
-							</div>
-						</div>
-						<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
-						<div v-if="$i" class="actions">
-							<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
-							<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
-						</div>
-					</div>
-					<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
-					<div class="title">
-						<MkUserName :user="user" :nowrap="false" class="name"/>
-						<div class="bottom">
-							<span class="username"><MkAcct :user="user" :detail="true"/></span>
-							<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
-							<span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
-							<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
-							<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
-						</div>
-					</div>
-					<div class="description">
-						<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
-						<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
-					</div>
-					<div class="fields system">
-						<dl v-if="user.location" class="field">
-							<dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt>
-							<dd class="value">{{ user.location }}</dd>
-						</dl>
-						<dl v-if="user.birthday" class="field">
-							<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
-							<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
-						</dl>
-						<dl class="field">
-							<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
-							<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
-						</dl>
-					</div>
-					<div v-if="user.fields.length > 0" class="fields">
-						<dl v-for="(field, i) in user.fields" :key="i" class="field">
-							<dt class="name">
-								<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
-							</dt>
-							<dd class="value">
-								<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
-							</dd>
-						</dl>
-					</div>
-					<div class="status">
-						<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
-							<b>{{ number(user.notesCount) }}</b>
-							<span>{{ i18n.ts.notes }}</span>
-						</MkA>
-						<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
-							<b>{{ number(user.followingCount) }}</b>
-							<span>{{ i18n.ts.following }}</span>
-						</MkA>
-						<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
-							<b>{{ number(user.followersCount) }}</b>
-							<span>{{ i18n.ts.followers }}</span>
-						</MkA>
-					</div>
-				</div>
-			</div>
-
-			<div class="contents">
-				<div v-if="user.pinnedNotes.length > 0" class="_gap">
-					<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
-				</div>
-				<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
-				<template v-if="narrow">
-					<XPhotos :key="user.id" :user="user"/>
-					<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-				</template>
-			</div>
-			<div>
-				<XUserTimeline :user="user"/>
-			</div>
-		</div>
-		<div v-if="!narrow" class="sub">
-			<XPhotos :key="user.id" :user="user"/>
-			<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-		</div>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
-import calcAge from 's-age';
-import * as misskey from 'misskey-js';
-import XUserTimeline from './index.timeline.vue';
-import XNote from '@/components/MkNote.vue';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import MkContainer from '@/components/MkContainer.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
-import MkTab from '@/components/MkTab.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import { getScrollPosition } from '@/scripts/scroll';
-import { getUserMenu } from '@/scripts/get-user-menu';
-import number from '@/filters/number';
-import { userPage, acct as getAcct } from '@/filters/user';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-
-const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
-const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
-
-const props = withDefaults(defineProps<{
-	user: misskey.entities.UserDetailed;
-}>(), {
-});
-
-const router = useRouter();
-
-let parallaxAnimationId = $ref<null | number>(null);
-let narrow = $ref<null | boolean>(null);
-let rootEl = $ref<null | HTMLElement>(null);
-let bannerEl = $ref<null | HTMLElement>(null);
-
-const style = $computed(() => {
-	if (props.user.bannerUrl == null) return {};
-	return {
-		backgroundImage: `url(${ props.user.bannerUrl })`,
-	};
-});
-
-const age = $computed(() => {
-	return calcAge(props.user.birthday);
-});
-
-function menu(ev) {
-	os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target);
-}
-
-function parallaxLoop() {
-	parallaxAnimationId = window.requestAnimationFrame(parallaxLoop);
-	parallax();
-}
-
-function parallax() {
-	const banner = bannerEl as any;
-	if (banner == null) return;
-
-	const top = getScrollPosition(rootEl);
-
-	if (top < 0) return;
-
-	const z = 1.75; // 奥行き(小さいほど奥)
-	const pos = -(top / z);
-	banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
-}
-
-onMounted(() => {
-	window.requestAnimationFrame(parallaxLoop);
-	narrow = rootEl!.clientWidth < 1000;
-});
-
-onUnmounted(() => {
-	if (parallaxAnimationId) {
-		window.cancelAnimationFrame(parallaxAnimationId);
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.ftskorzw {
-
-	> .main {
-
-		> .punished {
-			font-size: 0.8em;
-			padding: 16px;
-		}
-
-		> .profile {
-
-			> .main {
-				position: relative;
-				overflow: hidden;
-
-				> .banner-container {
-					position: relative;
-					height: 250px;
-					overflow: hidden;
-					background-size: cover;
-					background-position: center;
-
-					> .banner {
-						height: 100%;
-						background-color: #4c5e6d;
-						background-size: cover;
-						background-position: center;
-						box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
-						will-change: background-position;
-					}
-
-					> .fade {
-						position: absolute;
-						bottom: 0;
-						left: 0;
-						width: 100%;
-						height: 78px;
-						background: linear-gradient(transparent, rgba(#000, 0.7));
-					}
-
-					> .followed {
-						position: absolute;
-						top: 12px;
-						left: 12px;
-						padding: 4px 8px;
-						color: #fff;
-						background: rgba(0, 0, 0, 0.7);
-						font-size: 0.7em;
-						border-radius: 6px;
-					}
-
-					> .actions {
-						position: absolute;
-						top: 12px;
-						right: 12px;
-						-webkit-backdrop-filter: var(--blur, blur(8px));
-						backdrop-filter: var(--blur, blur(8px));
-						background: rgba(0, 0, 0, 0.2);
-						padding: 8px;
-						border-radius: 24px;
-
-						> .menu {
-							vertical-align: bottom;
-							height: 31px;
-							width: 31px;
-							color: #fff;
-							text-shadow: 0 0 8px #000;
-							font-size: 16px;
-						}
-
-						> .koudoku {
-							margin-left: 4px;
-							vertical-align: bottom;
-						}
-					}
-
-					> .title {
-						position: absolute;
-						bottom: 0;
-						left: 0;
-						width: 100%;
-						padding: 0 0 8px 154px;
-						box-sizing: border-box;
-						color: #fff;
-
-						> .name {
-							display: block;
-							margin: 0;
-							line-height: 32px;
-							font-weight: bold;
-							font-size: 1.8em;
-							text-shadow: 0 0 8px #000;
-						}
-
-						> .bottom {
-							> * {
-								display: inline-block;
-								margin-right: 16px;
-								line-height: 20px;
-								opacity: 0.8;
-
-								&.username {
-									font-weight: bold;
-								}
-							}
-						}
-					}
-				}
-
-				> .title {
-					display: none;
-					text-align: center;
-					padding: 50px 8px 16px 8px;
-					font-weight: bold;
-					border-bottom: solid 0.5px var(--divider);
-
-					> .bottom {
-						> * {
-							display: inline-block;
-							margin-right: 8px;
-							opacity: 0.8;
-						}
-					}
-				}
-
-				> .avatar {
-					display: block;
-					position: absolute;
-					top: 170px;
-					left: 16px;
-					z-index: 2;
-					width: 120px;
-					height: 120px;
-					box-shadow: 1px 1px 3px rgba(#000, 0.2);
-				}
-
-				> .description {
-					padding: 24px 24px 24px 154px;
-					font-size: 0.95em;
-
-					> .empty {
-						margin: 0;
-						opacity: 0.5;
-					}
-				}
-
-				> .fields {
-					padding: 24px;
-					font-size: 0.9em;
-					border-top: solid 0.5px var(--divider);
-
-					> .field {
-						display: flex;
-						padding: 0;
-						margin: 0;
-						align-items: center;
-
-						&:not(:last-child) {
-							margin-bottom: 8px;
-						}
-
-						> .name {
-							width: 30%;
-							overflow: hidden;
-							white-space: nowrap;
-							text-overflow: ellipsis;
-							font-weight: bold;
-							text-align: center;
-						}
-
-						> .value {
-							width: 70%;
-							overflow: hidden;
-							white-space: nowrap;
-							text-overflow: ellipsis;
-							margin: 0;
-						}
-					}
-
-					&.system > .field > .name {
-					}
-				}
-
-				> .status {
-					display: flex;
-					padding: 24px;
-					border-top: solid 0.5px var(--divider);
-
-					> a {
-						flex: 1;
-						text-align: center;
-
-						&.active {
-							color: var(--accent);
-						}
-
-						&:hover {
-							text-decoration: none;
-						}
-
-						> b {
-							display: block;
-							line-height: 16px;
-						}
-
-						> span {
-							font-size: 70%;
-						}
-					}
-				}
-			}
-		}
-
-		> .contents {
-			> .content {
-				margin-bottom: var(--margin);
-			}
-		}
-	}
-
-	&.max-width_500px {
-		> .main {
-			> .profile > .main {
-				> .banner-container {
-					height: 140px;
-
-					> .fade {
-						display: none;
-					}
-
-					> .title {
-						display: none;
-					}
-				}
-
-				> .title {
-					display: block;
-				}
-
-				> .avatar {
-					top: 90px;
-					left: 0;
-					right: 0;
-					width: 92px;
-					height: 92px;
-					margin: auto;
-				}
-
-				> .description {
-					padding: 16px;
-					text-align: center;
-				}
-
-				> .fields {
-					padding: 16px;
-				}
-
-				> .status {
-					padding: 16px;
-				}
-			}
-
-			> .contents {
-				> .nav {
-					font-size: 80%;
-				}
-			}
-		}
-	}
-
-	&.wide {
-		display: flex;
-		width: 100%;
-
-		> .main {
-			width: 100%;
-			min-width: 0;
-		}
-
-		> .sub {
-			max-width: 350px;
-			min-width: 350px;
-			margin-left: var(--margin);
-		}
-	}
-}
-
-@container (max-width: 500px) {
-	.ftskorzw {
-		> .main {
-			> .profile > .main {
-				> .banner-container {
-					height: 140px;
-
-					> .fade {
-						display: none;
-					}
-
-					> .title {
-						display: none;
-					}
-				}
-
-				> .title {
-					display: block;
-				}
-
-				> .avatar {
-					top: 90px;
-					left: 0;
-					right: 0;
-					width: 92px;
-					height: 92px;
-					margin: auto;
-				}
-
-				> .description {
-					padding: 16px;
-					text-align: center;
-				}
-
-				> .fields {
-					padding: 16px;
-				}
-
-				> .status {
-					padding: 16px;
-				}
-			}
-
-			> .contents {
-				> .nav {
-					font-size: 80%;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue
deleted file mode 100644
index 523072d2e6..0000000000
--- a/packages/client/src/pages/user/index.activity.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<template>
-<MkContainer>
-	<template #header><i class="ti ti-chart-line" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
-	<template #func>
-		<button class="_button" @click="showMenu">
-			<i class="ti ti-dots"></i>
-		</button>
-	</template>
-
-	<div style="padding: 8px;">
-		<MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import MkContainer from '@/components/MkContainer.vue';
-import MkChart from '@/components/MkChart.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
-	user: misskey.entities.User;
-	limit?: number;
-}>(), {
-	limit: 50,
-});
-
-let chartSrc = $ref('per-user-notes');
-
-function showMenu(ev: MouseEvent) {
-	os.popupMenu([{
-		text: i18n.ts.notes,
-		active: true,
-		action: () => {
-			chartSrc = 'per-user-notes';
-		},
-	}, /*, {
-		text: i18n.ts.following,
-		action: () => {
-			chartSrc = 'per-user-following';
-		}
-	}, {
-		text: i18n.ts.followers,
-		action: () => {
-			chartSrc = 'per-user-followers';
-		}
-	}*/], ev.currentTarget ?? ev.target);
-}
-</script>
diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue
deleted file mode 100644
index b33979a79d..0000000000
--- a/packages/client/src/pages/user/index.photos.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<template>
-<MkContainer :max-height="300" :foldable="true">
-	<template #header><i class="ti ti-photo" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template>
-	<div class="ujigsodd">
-		<MkLoading v-if="fetching"/>
-		<div v-if="!fetching && images.length > 0" class="stream">
-			<MkA
-				v-for="image in images"
-				:key="image.note.id + image.file.id"
-				class="img"
-				:to="notePage(image.note)"
-			>
-				<ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/>
-			</MkA>
-		</div>
-		<p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as misskey from 'misskey-js';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import { notePage } from '@/filters/note';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store';
-
-const props = defineProps<{
-	user: misskey.entities.UserDetailed;
-}>();
-
-let fetching = $ref(true);
-let images = $ref<{
-	note: misskey.entities.Note;
-	file: misskey.entities.DriveFile;
-}[]>([]);
-
-function thumbnail(image: misskey.entities.DriveFile): string {
-	return defaultStore.state.disableShowingAnimatedImages
-		? getStaticImageUrl(image.thumbnailUrl)
-		: image.thumbnailUrl;
-}
-
-onMounted(() => {
-	const image = [
-		'image/jpeg',
-		'image/webp',
-		'image/avif',
-		'image/png',
-		'image/gif',
-		'image/apng',
-		'image/vnd.mozilla.apng',
-	];
-	os.api('users/notes', {
-		userId: props.user.id,
-		fileType: image,
-		excludeNsfw: defaultStore.state.nsfw !== 'ignore',
-		limit: 10,
-	}).then(notes => {
-		for (const note of notes) {
-			for (const file of note.files) {
-				images.push({
-					note,
-					file,
-				});
-			}
-		}
-		fetching = false;
-	});
-});
-</script>
-
-<style lang="scss" scoped>
-.ujigsodd {
-	padding: 8px;
-
-	> .stream {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
-		grid-gap: 6px;
-
-		> .img {
-			height: 128px;
-			border-radius: 6px;
-			overflow: clip;
-		}
-	}
-
-	> .empty {
-		margin: 0;
-		padding: 16px;
-		text-align: center;
-
-		> i {
-			margin-right: 4px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue
deleted file mode 100644
index 41983a5ae8..0000000000
--- a/packages/client/src/pages/user/index.timeline.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header>
-		<MkTab v-model="include" :class="$style.tab">
-			<option :value="null">{{ i18n.ts.notes }}</option>
-			<option value="replies">{{ i18n.ts.notesAndReplies }}</option>
-			<option value="files">{{ i18n.ts.withFiles }}</option>
-		</MkTab>
-	</template>
-	<XNotes :no-gap="true" :pagination="pagination"/>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed } from 'vue';
-import * as misskey from 'misskey-js';
-import XNotes from '@/components/MkNotes.vue';
-import MkTab from '@/components/MkTab.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	user: misskey.entities.UserDetailed;
-}>();
-
-const include = ref<string | null>(null);
-
-const pagination = {
-	endpoint: 'users/notes' as const,
-	limit: 10,
-	params: computed(() => ({
-		userId: props.user.id,
-		includeReplies: include.value === 'replies',
-		withFiles: include.value === 'files',
-	})),
-};
-</script>
-
-<style lang="scss" module>
-.tab {
-	margin: calc(var(--margin) / 2) 0;
-	padding: calc(var(--margin) / 2) 0;
-	background: var(--bg);
-}
-</style>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
deleted file mode 100644
index 6e895cd8d7..0000000000
--- a/packages/client/src/pages/user/index.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<template>
-<MkStickyContainer>
-	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<div>
-		<transition name="fade" mode="out-in">
-			<div v-if="user">
-				<XHome v-if="tab === 'home'" :user="user"/>
-				<XReactions v-else-if="tab === 'reactions'" :user="user"/>
-				<XClips v-else-if="tab === 'clips'" :user="user"/>
-				<XPages v-else-if="tab === 'pages'" :user="user"/>
-				<XGallery v-else-if="tab === 'gallery'" :user="user"/>
-			</div>
-			<MkError v-else-if="error" @retry="fetchUser()"/>
-			<MkLoading v-else/>
-		</transition>
-	</div>
-</MkStickyContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
-import calcAge from 's-age';
-import * as Acct from 'misskey-js/built/acct';
-import * as misskey from 'misskey-js';
-import { getScrollPosition } from '@/scripts/scroll';
-import number from '@/filters/number';
-import { userPage, acct as getAcct } from '@/filters/user';
-import * as os from '@/os';
-import { useRouter } from '@/router';
-import { definePageMetadata } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-
-const XHome = defineAsyncComponent(() => import('./home.vue'));
-const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
-const XClips = defineAsyncComponent(() => import('./clips.vue'));
-const XPages = defineAsyncComponent(() => import('./pages.vue'));
-const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
-
-const props = withDefaults(defineProps<{
-	acct: string;
-	page?: string;
-}>(), {
-	page: 'home',
-});
-
-const router = useRouter();
-
-let tab = $ref(props.page);
-let user = $ref<null | misskey.entities.UserDetailed>(null);
-let error = $ref(null);
-
-function fetchUser(): void {
-	if (props.acct == null) return;
-	user = null;
-	os.api('users/show', Acct.parse(props.acct)).then(u => {
-		user = u;
-	}).catch(err => {
-		error = err;
-	});
-}
-
-watch(() => props.acct, fetchUser, {
-	immediate: true,
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => user ? [{
-	key: 'home',
-	title: i18n.ts.overview,
-	icon: 'ti ti-home',
-}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
-	key: 'reactions',
-	title: i18n.ts.reaction,
-	icon: 'ti ti-mood-happy',
-}] : [], {
-	key: 'clips',
-	title: i18n.ts.clips,
-	icon: 'ti ti-paperclip',
-}, {
-	key: 'pages',
-	title: i18n.ts.pages,
-	icon: 'ti ti-news',
-}, {
-	key: 'gallery',
-	title: i18n.ts.gallery,
-	icon: 'ti ti-icons',
-}] : null);
-
-definePageMetadata(computed(() => user ? {
-	icon: 'ti ti-user',
-	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
-	subtitle: `@${getAcct(user)}`,
-	userName: user,
-	avatar: user,
-	path: `/@${user.username}`,
-	share: {
-		title: user.name,
-	},
-} : null));
-</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-</style>
diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue
deleted file mode 100644
index 7833d6c42c..0000000000
--- a/packages/client/src/pages/user/pages.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
-		<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
-	</MkPagination>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import MkPagePreview from '@/components/MkPagePreview.vue';
-import MkPagination from '@/components/MkPagination.vue';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-}>();
-
-const pagination = {
-	endpoint: 'users/pages' as const,
-	limit: 20,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue
deleted file mode 100644
index ab3df34301..0000000000
--- a/packages/client/src/pages/user/reactions.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
-		<div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb">
-			<div class="header">
-				<MkAvatar class="avatar" :user="user"/>
-				<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
-				<MkTime :time="item.createdAt" class="createdAt"/>
-			</div>
-			<MkNote :key="item.id" :note="item.note"/>
-		</div>
-	</MkPagination>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import * as misskey from 'misskey-js';
-import MkPagination from '@/components/MkPagination.vue';
-import MkNote from '@/components/MkNote.vue';
-import MkReactionIcon from '@/components/MkReactionIcon.vue';
-
-const props = defineProps<{
-	user: misskey.entities.User;
-}>();
-
-const pagination = {
-	endpoint: 'users/reactions' as const,
-	limit: 20,
-	params: computed(() => ({
-		userId: props.user.id,
-	})),
-};
-</script>
-
-<style lang="scss" scoped>
-.afdcfbfb {
-	> .header {
-		display: flex;
-		align-items: center;
-		padding: 8px 16px;
-		margin-bottom: 8px;
-		border-bottom: solid 2px var(--divider);
-
-		> .avatar {
-			width: 24px;
-			height: 24px;
-			margin-right: 8px;
-		}
-
-		> .reaction {
-			width: 32px;
-			height: 32px;
-		}
-
-		> .createdAt {
-			margin-left: auto;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue
deleted file mode 100644
index bfa54d39f2..0000000000
--- a/packages/client/src/pages/welcome.entrance.a.vue
+++ /dev/null
@@ -1,309 +0,0 @@
-<template>
-<div v-if="meta" class="rsqzvsbo">
-	<div class="top">
-		<MkFeaturedPhotos class="bg"/>
-		<XTimeline class="tl"/>
-		<div class="shape1"></div>
-		<div class="shape2"></div>
-		<img src="/client-assets/misskey.svg" class="misskey"/>
-		<div class="emojis">
-			<MkEmoji :normal="true" :no-style="true" emoji="👍"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="❤"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="😆"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
-		</div>
-		<div class="main">
-			<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
-			<button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
-			<div class="fg">
-				<h1>
-					<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
-					<!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
-					<span class="text">{{ instanceName }}</span>
-				</h1>
-				<div class="about">
-					<!-- eslint-disable-next-line vue/no-v-html -->
-					<div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
-				</div>
-				<div class="action">
-					<MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton>
-					<MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
-				</div>
-			</div>
-		</div>
-		<div v-if="instances" class="federation">
-			<MarqueeText :duration="40">
-				<MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window">
-					<!--<MkInstanceCardMini :instance="instance"/>-->
-					<img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
-					<span class="name _monospace">{{ instance.host }}</span>
-				</MkA>
-			</MarqueeText>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { toUnicode } from 'punycode/';
-import XTimeline from './welcome.timeline.vue';
-import MarqueeText from '@/components/MkMarquee.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import XNote from '@/components/MkNote.vue';
-import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-let meta = $ref();
-let stats = $ref();
-let tags = $ref();
-let onlineUsersCount = $ref();
-let instances = $ref();
-
-os.api('meta', { detail: true }).then(_meta => {
-	meta = _meta;
-});
-
-os.api('stats').then(_stats => {
-	stats = _stats;
-});
-
-os.api('get-online-users-count').then(res => {
-	onlineUsersCount = res.count;
-});
-
-os.api('hashtags/list', {
-	sort: '+mentionedLocalUsers',
-	limit: 8,
-}).then(_tags => {
-	tags = _tags;
-});
-
-os.api('federation/instances', {
-	sort: '+pubSub',
-	limit: 20,
-}).then(_instances => {
-	instances = _instances;
-});
-
-function signin() {
-	os.popup(XSigninDialog, {
-		autoSet: true,
-	}, {}, 'closed');
-}
-
-function signup() {
-	os.popup(XSignupDialog, {
-		autoSet: true,
-	}, {}, 'closed');
-}
-
-function showMenu(ev) {
-	os.popupMenu([{
-		text: i18n.ts.instanceInfo,
-		icon: 'ti ti-info-circle',
-		action: () => {
-			os.pageWindow('/about');
-		},
-	}, {
-		text: i18n.ts.aboutMisskey,
-		icon: 'ti ti-info-circle',
-		action: () => {
-			os.pageWindow('/about-misskey');
-		},
-	}, null, {
-		text: i18n.ts.help,
-		icon: 'ti ti-question-circle',
-		action: () => {
-			window.open('https://misskey-hub.net/help.md', '_blank');
-		},
-	}], ev.currentTarget ?? ev.target);
-}
-</script>
-
-<style lang="scss" scoped>
-.rsqzvsbo {
-	> .top {
-		display: flex;
-		text-align: center;
-		min-height: 100vh;
-		box-sizing: border-box;
-		padding: 16px;
-
-		> .bg {
-			position: absolute;
-			top: 0;
-			right: 0;
-			width: 80%; // 100%からshapeの幅を引いている
-			height: 100%;
-		}
-
-		> .tl {
-			position: absolute;
-			top: 0;
-			bottom: 0;
-			right: 64px;
-			margin: auto;
-			width: 500px;
-			height: calc(100% - 128px);
-			overflow: hidden;
-			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
-			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
-
-			@media (max-width: 1200px) {
-				display: none;
-			}
-		}
-
-		> .shape1 {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: var(--accent);
-			clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%);
-		}
-		> .shape2 {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: var(--accent);
-			clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%);
-			opacity: 0.5;
-		}
-
-		> .misskey {
-			position: absolute;
-			top: 42px;
-			left: 42px;
-			width: 140px;
-
-			@media (max-width: 450px) {
-				width: 130px;
-			}
-		}
-
-		> .emojis {
-			position: absolute;
-			bottom: 32px;
-			left: 35px;
-
-			> * {
-				margin-right: 8px;
-			}
-
-			@media (max-width: 1200px) {
-				display: none;
-			}
-		}
-
-		> .main {
-			position: relative;
-			width: min(480px, 100%);
-			margin: auto auto auto 128px;
-			background: var(--panel);
-			border-radius: var(--radius);
-			box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
-
-			@media (max-width: 1200px) {
-				margin: auto;
-			}
-
-			> .icon {
-				width: 85px;
-				margin-top: -47px;
-				border-radius: 100%;
-				vertical-align: bottom;
-			}
-
-			> .menu {
-				position: absolute;
-				top: 16px;
-				right: 16px;
-				width: 32px;
-				height: 32px;
-				border-radius: 8px;
-				font-size: 18px;
-			}
-
-			> .fg {
-				position: relative;
-				z-index: 1;
-
-				> h1 {
-					display: block;
-					margin: 0;
-					padding: 16px 32px 24px 32px;
-					font-size: 1.4em;
-
-					> .logo {
-						vertical-align: bottom;
-						max-height: 120px;
-						max-width: min(100%, 300px);
-					}
-				}
-
-				> .about {
-					padding: 0 32px;
-				}
-
-				> .action {
-					padding: 32px;
-
-					> * {
-						line-height: 28px;
-					}
-				}
-			}
-		}
-
-		> .federation {
-			position: absolute;
-			bottom: 16px;
-			left: 0;
-			right: 0;
-			margin: auto;
-			background: var(--acrylicPanel);
-			-webkit-backdrop-filter: var(--blur, blur(15px));
-			backdrop-filter: var(--blur, blur(15px));
-			border-radius: 999px;
-			overflow: clip;
-			width: 800px;
-			padding: 8px 0;
-
-			@media (max-width: 900px) {
-				display: none;
-			}
-		}
-	}
-}
-</style>
-
-<style lang="scss" module>
-.federationInstance {
-	display: inline-flex;
-	align-items: center;
-	vertical-align: bottom;
-	padding: 6px 12px 6px 6px;
-	margin: 0 10px 0 0;
-	background: var(--panel);
-	border-radius: 999px;
-
-	> :global(.icon) {
-		display: inline-block;
-		width: 20px;
-		height: 20px;
-		margin-right: 5px;
-		border-radius: 999px;
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue
deleted file mode 100644
index 8230adaf1f..0000000000
--- a/packages/client/src/pages/welcome.entrance.b.vue
+++ /dev/null
@@ -1,237 +0,0 @@
-<template>
-<div v-if="meta" class="rsqzvsbo">
-	<div class="top">
-		<MkFeaturedPhotos class="bg"/>
-		<XTimeline class="tl"/>
-		<div class="shape"></div>
-		<div class="main">
-			<h1>
-				<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
-			</h1>
-			<div class="about">
-				<!-- eslint-disable-next-line vue/no-v-html -->
-				<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
-			</div>
-			<div class="action">
-				<MkButton class="signup" inline gradate @click="signup()">{{ $ts.signup }}</MkButton>
-				<MkButton class="signin" inline @click="signin()">{{ $ts.login }}</MkButton>
-			</div>
-			<div v-if="onlineUsersCount && stats" class="status">
-				<div>
-					<I18n :src="$ts.nUsers" text-tag="span" class="users">
-						<template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
-					</I18n>
-					<I18n :src="$ts.nNotes" text-tag="span" class="notes">
-						<template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
-					</I18n>
-				</div>
-				<I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
-					<template #n><b>{{ onlineUsersCount }}</b></template>
-				</I18n>
-			</div>
-		</div>
-		<img src="/client-assets/misskey.svg" class="misskey"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { toUnicode } from 'punycode/';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import XNote from '@/components/MkNote.vue';
-import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import XTimeline from './welcome.timeline.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import number from '@/filters/number';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		XNote,
-		XTimeline,
-		MkFeaturedPhotos,
-	},
-
-	data() {
-		return {
-			host: toUnicode(host),
-			instanceName,
-			meta: null,
-			stats: null,
-			tags: [],
-			onlineUsersCount: null,
-		};
-	},
-
-	created() {
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-
-		os.api('stats').then(stats => {
-			this.stats = stats;
-		});
-
-		os.api('get-online-users-count').then(res => {
-			this.onlineUsersCount = res.count;
-		});
-
-		os.api('hashtags/list', {
-			sort: '+mentionedLocalUsers',
-			limit: 8,
-		}).then(tags => {
-			this.tags = tags;
-		});
-	},
-
-	methods: {
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		showMenu(ev) {
-			os.popupMenu([{
-				text: this.$t('aboutX', { x: instanceName }),
-				icon: 'ti ti-info-circle',
-				action: () => {
-					os.pageWindow('/about');
-				},
-			}, {
-				text: this.$ts.aboutMisskey,
-				icon: 'ti ti-info-circle',
-				action: () => {
-					os.pageWindow('/about-misskey');
-				},
-			}, null, {
-				text: this.$ts.help,
-				icon: 'ti ti-question-circle',
-				action: () => {
-					window.open(`https://misskey-hub.net/help.md`, '_blank');
-				},
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		number,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.rsqzvsbo {
-	> .top {
-		min-height: 100vh;
-		box-sizing: border-box;
-
-		> .bg {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-		}
-
-		> .tl {
-			position: absolute;
-			top: 0;
-			bottom: 0;
-			right: 64px;
-			margin: auto;
-			width: 500px;
-			height: calc(100% - 128px);
-			overflow: hidden;
-			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
-			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
-		}
-
-		> .shape {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: var(--accent);
-			clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%);
-		}
-
-		> .misskey {
-			position: absolute;
-			bottom: 64px;
-			left: 64px;
-			width: 160px;
-		}
-
-		> .main {
-			position: relative;
-			width: min(450px, 100%);
-			padding: 64px;
-			color: #fff;
-			font-size: 1.1em;
-
-			@media (max-width: 1200px) {
-				margin: auto;
-			}
-
-			> h1 {
-				display: block;
-				margin: 0 0 32px 0;
-				padding: 0;
-
-				> .logo {
-					vertical-align: bottom;
-					max-height: 100px;
-				}
-			}
-
-			> .about {
-				padding: 0;
-			}
-
-			> .action {
-				margin: 32px 0;
-
-				> * {
-					line-height: 32px;
-				}
-
-				> .signup {
-					background: var(--panel);
-					color: var(--fg);
-				}
-
-				> .signin {
-					background: var(--accent);
-					color: inherit;
-				}
-			}
-
-			> .status {
-				margin: 32px 0;
-				border-top: solid 1px rgba(255, 255, 255, 0.5);
-				font-size: 90%;
-
-				> div {
-					padding: 16px 0;
-
-					> span:not(:last-child) {
-						padding-right: 1em;
-						margin-right: 1em;
-						border-right: solid 1px rgba(255, 255, 255, 0.5);
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue
deleted file mode 100644
index d2d07bb1f0..0000000000
--- a/packages/client/src/pages/welcome.entrance.c.vue
+++ /dev/null
@@ -1,306 +0,0 @@
-<template>
-<div v-if="meta" class="rsqzvsbo">
-	<div class="top">
-		<MkFeaturedPhotos class="bg"/>
-		<div class="fade"></div>
-		<div class="emojis">
-			<MkEmoji :normal="true" :no-style="true" emoji="👍"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="❤"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="😆"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
-			<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
-		</div>
-		<div class="main">
-			<img src="/client-assets/misskey.svg" class="misskey"/>
-			<div class="form _panel">
-				<div class="bg">
-					<div class="fade"></div>
-				</div>
-				<div class="fg">
-					<h1>
-						<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
-					</h1>
-					<div class="about">
-						<!-- eslint-disable-next-line vue/no-v-html -->
-						<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
-					</div>
-					<div class="action">
-						<MkButton inline gradate @click="signup()">{{ $ts.signup }}</MkButton>
-						<MkButton inline @click="signin()">{{ $ts.login }}</MkButton>
-					</div>
-					<div v-if="onlineUsersCount && stats" class="status">
-						<div>
-							<I18n :src="$ts.nUsers" text-tag="span" class="users">
-								<template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
-							</I18n>
-							<I18n :src="$ts.nNotes" text-tag="span" class="notes">
-								<template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
-							</I18n>
-						</div>
-						<I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
-							<template #n><b>{{ onlineUsersCount }}</b></template>
-						</I18n>
-					</div>
-					<button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
-				</div>
-			</div>
-			<nav class="nav">
-				<MkA to="/announcements">{{ $ts.announcements }}</MkA>
-				<MkA to="/explore">{{ $ts.explore }}</MkA>
-				<MkA to="/channels">{{ $ts.channel }}</MkA>
-				<MkA to="/featured">{{ $ts.featured }}</MkA>
-			</nav>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { toUnicode } from 'punycode/';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import XNote from '@/components/MkNote.vue';
-import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import XTimeline from './welcome.timeline.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import number from '@/filters/number';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		XNote,
-		MkFeaturedPhotos,
-		XTimeline,
-	},
-
-	data() {
-		return {
-			host: toUnicode(host),
-			instanceName,
-			meta: null,
-			stats: null,
-			tags: [],
-			onlineUsersCount: null,
-		};
-	},
-
-	created() {
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-
-		os.api('stats').then(stats => {
-			this.stats = stats;
-		});
-
-		os.api('get-online-users-count').then(res => {
-			this.onlineUsersCount = res.count;
-		});
-
-		os.api('hashtags/list', {
-			sort: '+mentionedLocalUsers',
-			limit: 8,
-		}).then(tags => {
-			this.tags = tags;
-		});
-	},
-
-	methods: {
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		showMenu(ev) {
-			os.popupMenu([{
-				text: this.$t('aboutX', { x: instanceName }),
-				icon: 'ti ti-info-circle',
-				action: () => {
-					os.pageWindow('/about');
-				},
-			}, {
-				text: this.$ts.aboutMisskey,
-				icon: 'ti ti-info-circle',
-				action: () => {
-					os.pageWindow('/about-misskey');
-				},
-			}, null, {
-				text: this.$ts.help,
-				icon: 'ti ti-question-circle',
-				action: () => {
-					window.open(`https://misskey-hub.net/help.md`, '_blank');
-				},
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		number,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.rsqzvsbo {
-	> .top {
-		display: flex;
-		text-align: center;
-		min-height: 100vh;
-		box-sizing: border-box;
-		padding: 16px;
-
-		> .bg {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-		}
-
-		> .fade {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: rgba(0, 0, 0, 0.25);
-		}
-
-		> .emojis {
-			position: absolute;
-			bottom: 32px;
-			left: 35px;
-
-			> * {
-				margin-right: 8px;
-			}
-
-			@media (max-width: 1200px) {
-				display: none;
-			}
-		}
-
-		> .main {
-			position: relative;
-			width: min(460px, 100%);
-			margin: auto;
-
-			> .misskey {
-				width: 150px;
-				margin-bottom: 16px;
-
-				@media (max-width: 450px) {
-					width: 130px;
-				}
-			}
-
-			> .form {
-				position: relative;
-				box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
-
-				> .bg {
-					position: absolute;
-					top: 0;
-					left: 0;
-					width: 100%;
-					height: 128px;
-					background-position: center;
-					background-size: cover;
-					opacity: 0.75;
-
-					> .fade {
-						position: absolute;
-						bottom: 0;
-						left: 0;
-						width: 100%;
-						height: 128px;
-						background: linear-gradient(0deg, var(--panel), var(--X15));
-					}
-				}
-
-				> .fg {
-					position: relative;
-					z-index: 1;
-
-					> h1 {
-						display: block;
-						margin: 0;
-						padding: 32px 32px 24px 32px;
-
-						> .logo {
-							vertical-align: bottom;
-							max-height: 120px;
-						}
-					}
-
-					> .about {
-						padding: 0 32px;
-					}
-
-					> .action {
-						padding: 32px;
-
-						> * {
-							line-height: 28px;
-						}
-					}
-
-					> .status {
-						border-top: solid 0.5px var(--divider);
-						padding: 32px;
-						font-size: 90%;
-
-						> div {
-							> span:not(:last-child) {
-								padding-right: 1em;
-								margin-right: 1em;
-								border-right: solid 0.5px var(--divider);
-							}
-						}
-
-						> .online {
-							::v-deep(b) {
-								color: #41b781;
-							}
-
-							::v-deep(span) {
-								opacity: 0.7;
-							}
-						}
-					}
-
-					> .menu {
-						position: absolute;
-						top: 16px;
-						right: 16px;
-						width: 32px;
-						height: 32px;
-						border-radius: 8px;
-					}
-				}
-			}
-
-			> .nav {
-				position: relative;
-				z-index: 2;
-				margin-top: 20px;
-				color: #fff;
-				text-shadow: 0 0 8px black;
-				font-size: 0.9em;
-
-				> *:not(:last-child) {
-					margin-right: 1.5em;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue
deleted file mode 100644
index 2729d30d4b..0000000000
--- a/packages/client/src/pages/welcome.setup.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-<template>
-<form class="mk-setup" @submit.prevent="submit()">
-	<h1>Welcome to Misskey!</h1>
-	<div class="_formRoot">
-		<p>{{ $ts.intro }}</p>
-		<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
-			<template #label>{{ $ts.username }}</template>
-			<template #prefix>@</template>
-			<template #suffix>@{{ host }}</template>
-		</MkInput>
-		<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
-			<template #label>{{ $ts.password }}</template>
-			<template #prefix><i class="ti ti-lock"></i></template>
-		</MkInput>
-		<div class="bottom _formBlock">
-			<MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok>
-				{{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/>
-			</MkButton>
-		</div>
-	</div>
-</form>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/form/input.vue';
-import { host } from '@/config';
-import * as os from '@/os';
-import { login } from '@/account';
-import { i18n } from '@/i18n';
-
-let username = $ref('');
-let password = $ref('');
-let submitting = $ref(false);
-
-function submit() {
-	if (submitting) return;
-	submitting = true;
-
-	os.api('admin/accounts/create', {
-		username: username,
-		password: password,
-	}).then(res => {
-		return login(res.token);
-	}).catch(() => {
-		submitting = false;
-
-		os.alert({
-			type: 'error',
-			text: i18n.ts.somethingHappened,
-		});
-	});
-}
-</script>
-
-<style lang="scss" scoped>
-.mk-setup {
-	border-radius: var(--radius);
-	box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
-	overflow: hidden;
-	max-width: 500px;
-	margin: 32px auto;
-
-	> h1 {
-		margin: 0;
-		font-size: 1.5em;
-		text-align: center;
-		padding: 32px;
-		background: var(--accent);
-		color: #fff;
-	}
-
-	> div {
-		padding: 32px;
-		background: var(--panel);
-
-		> p {
-			margin-top: 0;
-		}
-
-		> .bottom {
-			> * {
-				margin: 0 auto;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue
deleted file mode 100644
index d6a88540d1..0000000000
--- a/packages/client/src/pages/welcome.timeline.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<template>
-<div class="civpbkhh">
-	<div ref="scroll" class="scrollbox" v-bind:class="{ scroll: isScrolling }">
-		<div v-for="note in notes" class="note">
-			<div class="content _panel">
-				<div class="body">
-					<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
-					<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
-					<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
-				</div>
-				<div v-if="note.files.length > 0" class="richcontent">
-					<XMediaList :media-list="note.files"/>
-				</div>
-				<div v-if="note.poll">
-					<XPoll :note="note" :readOnly="true"/>
-				</div>
-			</div>
-			<XReactionsViewer ref="reactionsViewer" :note="note"/>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XReactionsViewer from '@/components/MkReactionsViewer.vue';
-import XMediaList from '@/components/MkMediaList.vue';
-import XPoll from '@/components/MkPoll.vue';
-import * as os from '@/os';
-
-export default defineComponent({
-	components: {
-		XReactionsViewer,
-		XMediaList,
-		XPoll,
-	},
-
-	data() {
-		return {
-			notes: [],
-			isScrolling: false,
-		};
-	},
-
-	created() {
-		os.api('notes/featured').then(notes => {
-			this.notes = notes;
-		});
-	},
-
-	updated() {
-		if (this.$refs.scroll.clientHeight > window.innerHeight) {
-			this.isScrolling = true;
-		}
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-@keyframes scroll {
-	0% {
-		transform: translate3d(0, 0, 0);
-	}
-	5% {
-		transform: translate3d(0, 0, 0);
-	}
-	75% {
-		transform: translate3d(0, calc(-100% + 90vh), 0);
-	}
-	90% {
-		transform: translate3d(0, calc(-100% + 90vh), 0);
-	}
-}
-
-.civpbkhh {
-	text-align: right;
-
-	> .scrollbox {
-		&.scroll {
-			animation: scroll 45s linear infinite;
-		}
-
-		> .note {
-			margin: 16px 0 16px auto;
-
-			> .content {
-				padding: 16px;
-				margin: 0 0 0 auto;
-				max-width: max-content;
-				border-radius: 16px;
-
-				> .richcontent {
-					min-width: 250px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
deleted file mode 100644
index a1c3fc2abb..0000000000
--- a/packages/client/src/pages/welcome.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-<div v-if="meta">
-	<XSetup v-if="meta.requireSetup"/>
-	<XEntrance v-else/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import XSetup from './welcome.setup.vue';
-import XEntrance from './welcome.entrance.a.vue';
-import { instanceName } from '@/config';
-import * as os from '@/os';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-let meta = $ref(null);
-
-os.api('meta', { detail: true }).then(res => {
-	meta = res;
-});
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
-	title: instanceName,
-	icon: null,
-})));
-</script>
diff --git a/packages/client/src/pizzax.ts b/packages/client/src/pizzax.ts
deleted file mode 100644
index 642e1f4f7f..0000000000
--- a/packages/client/src/pizzax.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-// PIZZAX --- A lightweight store
-
-import { onUnmounted, Ref, ref, watch } from 'vue';
-import { $i } from './account';
-import { api } from './os';
-import { stream } from './stream';
-
-type StateDef = Record<string, {
-	where: 'account' | 'device' | 'deviceAccount';
-	default: any;
-}>;
-
-type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
-
-const connection = $i && stream.useChannel('main');
-
-export class Storage<T extends StateDef> {
-	public readonly key: string;
-	public readonly keyForLocalStorage: string;
-
-	public readonly def: T;
-
-	// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
-	public readonly state: { [K in keyof T]: T[K]['default'] };
-	public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> };
-
-	constructor(key: string, def: T) {
-		this.key = key;
-		this.keyForLocalStorage = 'pizzax::' + key;
-		this.def = def;
-
-		// TODO: indexedDBにする
-		const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
-		const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {};
-		const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {};
-
-		const state = {};
-		const reactiveState = {};
-		for (const [k, v] of Object.entries(def)) {
-			if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
-				state[k] = deviceState[k];
-			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
-				state[k] = registryCache[k];
-			} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
-				state[k] = deviceAccountState[k];
-			} else {
-				state[k] = v.default;
-				if (_DEV_) console.log('Use default value', k, v.default);
-			}
-		}
-		for (const [k, v] of Object.entries(state)) {
-			reactiveState[k] = ref(v);
-		}
-		this.state = state as any;
-		this.reactiveState = reactiveState as any;
-	
-		if ($i) {
-			// なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう)
-			window.setTimeout(() => {
-				api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => {
-					const cache = {};
-					for (const [k, v] of Object.entries(def)) {
-						if (v.where === 'account') {
-							if (Object.prototype.hasOwnProperty.call(kvs, k)) {
-								state[k] = kvs[k];
-								reactiveState[k].value = kvs[k];
-								cache[k] = kvs[k];
-							} else {
-								state[k] = v.default;
-								reactiveState[k].value = v.default;
-							}
-						}
-					}
-					localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
-				});
-			}, 1);
-			// streamingのuser storage updateイベントを監視して更新
-			connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => {
-				if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return;
-
-				this.state[key] = value;
-				this.reactiveState[key].value = value;
-
-				const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
-				if (cache[key] !== value) {
-					cache[key] = value;
-					localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
-				}
-			});
-		}
-	}
-
-	public set<K extends keyof T>(key: K, value: T[K]['default']): void {
-		if (_DEV_) console.log('set', key, value);
-
-		this.state[key] = value;
-		this.reactiveState[key].value = value;
-
-		switch (this.def[key].where) {
-			case 'device': {
-				const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
-				deviceState[key] = value;
-				localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState));
-				break;
-			}
-			case 'deviceAccount': {
-				if ($i == null) break;
-				const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}');
-				deviceAccountState[key] = value;
-				localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState));
-				break;
-			}
-			case 'account': {
-				if ($i == null) break;
-				const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
-				cache[key] = value;
-				localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
-				api('i/registry/set', {
-					scope: ['client', this.key],
-					key: key,
-					value: value,
-				});
-				break;
-			}
-		}
-	}
-
-	public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void {
-		const currentState = this.state[key];
-		this.set(key, [...currentState, value]);
-	}
-
-	public reset(key: keyof T) {
-		this.set(key, this.def[key].default);
-	}
-
-	/**
-	 * 特定のキーの、簡易的なgetter/setterを作ります
-	 * 主にvue場で設定コントロールのmodelとして使う用
-	 */
-	public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) {
-		const valueRef = ref(this.state[key]);
-
-		const stop = watch(this.reactiveState[key], val => {
-			valueRef.value = val;
-		});
-
-		// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
-		onUnmounted(() => {
-			stop();
-		});
-
-		// TODO: VueのcustomRef使うと良い感じになるかも
-		return {
-			get: () => {
-				if (getter) {
-					return getter(valueRef.value);
-				} else {
-					return valueRef.value;
-				}
-			},
-			set: (value: unknown) => {
-				const val = setter ? setter(value) : value;
-				this.set(key, val);
-				valueRef.value = val;
-			},
-		};
-	}
-}
diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts
deleted file mode 100644
index 3a00cd0455..0000000000
--- a/packages/client/src/plugin.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { AiScript, utils, values } from '@syuilo/aiscript';
-import { deserialize } from '@syuilo/aiscript/built/serializer';
-import { jsToVal } from '@syuilo/aiscript/built/interpreter/util';
-import { createAiScriptEnv } from '@/scripts/aiscript/api';
-import { inputText } from '@/os';
-import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store';
-
-const pluginContexts = new Map<string, AiScript>();
-
-export function install(plugin) {
-	console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
-
-	const aiscript = new AiScript(createPluginEnv({
-		plugin: plugin,
-		storageKey: 'plugins:' + plugin.id,
-	}), {
-		in: (q) => {
-			return new Promise(ok => {
-				inputText({
-					title: q,
-				}).then(({ canceled, result: a }) => {
-					ok(a);
-				});
-			});
-		},
-		out: (value) => {
-			console.log(value);
-		},
-		log: (type, params) => {
-		},
-	});
-
-	initPlugin({ plugin, aiscript });
-
-	aiscript.exec(deserialize(plugin.ast));
-}
-
-function createPluginEnv(opts) {
-	const config = new Map();
-	for (const [k, v] of Object.entries(opts.plugin.config || {})) {
-		config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
-	}
-
-	return {
-		...createAiScriptEnv({ ...opts, token: opts.plugin.token }),
-		//#region Deprecated
-		'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
-			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
-			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
-			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		//#endregion
-		'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
-			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => {
-			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => {
-			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler });
-		}),
-		'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNotePostInterruptor({ pluginId: opts.plugin.id, handler });
-		}),
-		'Plugin:open_url': values.FN_NATIVE(([url]) => {
-			window.open(url.value, '_blank');
-		}),
-		'Plugin:config': values.OBJ(config),
-	};
-}
-
-function initPlugin({ plugin, aiscript }) {
-	pluginContexts.set(plugin.id, aiscript);
-}
-
-function registerPostFormAction({ pluginId, title, handler }) {
-	postFormActions.push({
-		title, handler: (form, update) => {
-			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
-				update(key.value, value.value);
-			})]);
-		},
-	});
-}
-
-function registerUserAction({ pluginId, title, handler }) {
-	userActions.push({
-		title, handler: (user) => {
-			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]);
-		},
-	});
-}
-
-function registerNoteAction({ pluginId, title, handler }) {
-	noteActions.push({
-		title, handler: (note) => {
-			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]);
-		},
-	});
-}
-
-function registerNoteViewInterruptor({ pluginId, handler }) {
-	noteViewInterruptors.push({
-		handler: async (note) => {
-			return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
-		},
-	});
-}
-
-function registerNotePostInterruptor({ pluginId, handler }) {
-	notePostInterruptors.push({
-		handler: async (note) => {
-			return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
-		},
-	});
-}
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
deleted file mode 100644
index 111b15e0a6..0000000000
--- a/packages/client/src/router.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
-import { Router } from '@/nirax';
-import { $i, iAmModerator } from '@/account';
-import MkLoading from '@/pages/_loading_.vue';
-import MkError from '@/pages/_error_.vue';
-import { ui } from '@/config';
-
-const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
-	loader: loader,
-	loadingComponent: MkLoading,
-	errorComponent: MkError,
-});
-
-export const routes = [{
-	path: '/@:initUser/pages/:initPageName/view-source',
-	component: page(() => import('./pages/page-editor/page-editor.vue')),
-}, {
-	path: '/@:username/pages/:pageName',
-	component: page(() => import('./pages/page.vue')),
-}, {
-	path: '/@:acct/following',
-	component: page(() => import('./pages/user/following.vue')),
-}, {
-	path: '/@:acct/followers',
-	component: page(() => import('./pages/user/followers.vue')),
-}, {
-	name: 'user',
-	path: '/@:acct/:page?',
-	component: page(() => import('./pages/user/index.vue')),
-}, {
-	name: 'note',
-	path: '/notes/:noteId',
-	component: page(() => import('./pages/note.vue')),
-}, {
-	path: '/clips/:clipId',
-	component: page(() => import('./pages/clip.vue')),
-}, {
-	path: '/user-info/:userId',
-	component: page(() => import('./pages/user-info.vue')),
-}, {
-	path: '/instance-info/:host',
-	component: page(() => import('./pages/instance-info.vue')),
-}, {
-	name: 'settings',
-	path: '/settings',
-	component: page(() => import('./pages/settings/index.vue')),
-	loginRequired: true,
-	children: [{
-		path: '/profile',
-		name: 'profile',
-		component: page(() => import('./pages/settings/profile.vue')),
-	}, {
-		path: '/privacy',
-		name: 'privacy',
-		component: page(() => import('./pages/settings/privacy.vue')),
-	}, {
-		path: '/reaction',
-		name: 'reaction',
-		component: page(() => import('./pages/settings/reaction.vue')),
-	}, {
-		path: '/drive',
-		name: 'drive',
-		component: page(() => import('./pages/settings/drive.vue')),
-	}, {
-		path: '/notifications',
-		name: 'notifications',
-		component: page(() => import('./pages/settings/notifications.vue')),
-	}, {
-		path: '/email',
-		name: 'email',
-		component: page(() => import('./pages/settings/email.vue')),
-	}, {
-		path: '/integration',
-		name: 'integration',
-		component: page(() => import('./pages/settings/integration.vue')),
-	}, {
-		path: '/security',
-		name: 'security',
-		component: page(() => import('./pages/settings/security.vue')),
-	}, {
-		path: '/general',
-		name: 'general',
-		component: page(() => import('./pages/settings/general.vue')),
-	}, {
-		path: '/theme/install',
-		name: 'theme',
-		component: page(() => import('./pages/settings/theme.install.vue')),
-	}, {
-		path: '/theme/manage',
-		name: 'theme',
-		component: page(() => import('./pages/settings/theme.manage.vue')),
-	}, {
-		path: '/theme',
-		name: 'theme',
-		component: page(() => import('./pages/settings/theme.vue')),
-	}, {
-		path: '/navbar',
-		name: 'navbar',
-		component: page(() => import('./pages/settings/navbar.vue')),
-	}, {
-		path: '/statusbar',
-		name: 'statusbar',
-		component: page(() => import('./pages/settings/statusbar.vue')),
-	}, {
-		path: '/sounds',
-		name: 'sounds',
-		component: page(() => import('./pages/settings/sounds.vue')),
-	}, {
-		path: '/plugin/install',
-		name: 'plugin',
-		component: page(() => import('./pages/settings/plugin.install.vue')),
-	}, {
-		path: '/plugin',
-		name: 'plugin',
-		component: page(() => import('./pages/settings/plugin.vue')),
-	}, {
-		path: '/import-export',
-		name: 'import-export',
-		component: page(() => import('./pages/settings/import-export.vue')),
-	}, {
-		path: '/instance-mute',
-		name: 'instance-mute',
-		component: page(() => import('./pages/settings/instance-mute.vue')),
-	}, {
-		path: '/mute-block',
-		name: 'mute-block',
-		component: page(() => import('./pages/settings/mute-block.vue')),
-	}, {
-		path: '/word-mute',
-		name: 'word-mute',
-		component: page(() => import('./pages/settings/word-mute.vue')),
-	}, {
-		path: '/api',
-		name: 'api',
-		component: page(() => import('./pages/settings/api.vue')),
-	}, {
-		path: '/apps',
-		name: 'api',
-		component: page(() => import('./pages/settings/apps.vue')),
-	}, {
-		path: '/webhook/edit/:webhookId',
-		name: 'webhook',
-		component: page(() => import('./pages/settings/webhook.edit.vue')),
-	}, {
-		path: '/webhook/new',
-		name: 'webhook',
-		component: page(() => import('./pages/settings/webhook.new.vue')),
-	}, {
-		path: '/webhook',
-		name: 'webhook',
-		component: page(() => import('./pages/settings/webhook.vue')),
-	}, {
-		path: '/deck',
-		name: 'deck',
-		component: page(() => import('./pages/settings/deck.vue')),
-	}, {
-		path: '/preferences-backups',
-		name: 'preferences-backups',
-		component: page(() => import('./pages/settings/preferences-backups.vue')),
-	}, {
-		path: '/custom-css',
-		name: 'general',
-		component: page(() => import('./pages/settings/custom-css.vue')),
-	}, {
-		path: '/accounts',
-		name: 'profile',
-		component: page(() => import('./pages/settings/accounts.vue')),
-	}, {
-		path: '/account-info',
-		name: 'other',
-		component: page(() => import('./pages/settings/account-info.vue')),
-	}, {
-		path: '/delete-account',
-		name: 'other',
-		component: page(() => import('./pages/settings/delete-account.vue')),
-	}, {
-		path: '/other',
-		name: 'other',
-		component: page(() => import('./pages/settings/other.vue')),
-	}, {
-		path: '/',
-		component: page(() => import('./pages/_empty_.vue')),
-	}],
-}, {
-	path: '/reset-password/:token?',
-	component: page(() => import('./pages/reset-password.vue')),
-}, {
-	path: '/signup-complete/:code',
-	component: page(() => import('./pages/signup-complete.vue')),
-}, {
-	path: '/announcements',
-	component: page(() => import('./pages/announcements.vue')),
-}, {
-	path: '/about',
-	component: page(() => import('./pages/about.vue')),
-	hash: 'initialTab',
-}, {
-	path: '/about-misskey',
-	component: page(() => import('./pages/about-misskey.vue')),
-}, {
-	path: '/theme-editor',
-	component: page(() => import('./pages/theme-editor.vue')),
-	loginRequired: true,
-}, {
-	path: '/explore/tags/:tag',
-	component: page(() => import('./pages/explore.vue')),
-}, {
-	path: '/explore',
-	component: page(() => import('./pages/explore.vue')),
-}, {
-	path: '/search',
-	component: page(() => import('./pages/search.vue')),
-	query: {
-		q: 'query',
-		channel: 'channel',
-	},
-}, {
-	path: '/authorize-follow',
-	component: page(() => import('./pages/follow.vue')),
-	loginRequired: true,
-}, {
-	path: '/share',
-	component: page(() => import('./pages/share.vue')),
-	loginRequired: true,
-}, {
-	path: '/api-console',
-	component: page(() => import('./pages/api-console.vue')),
-	loginRequired: true,
-}, {
-	path: '/mfm-cheat-sheet',
-	component: page(() => import('./pages/mfm-cheat-sheet.vue')),
-}, {
-	path: '/scratchpad',
-	component: page(() => import('./pages/scratchpad.vue')),
-}, {
-	path: '/preview',
-	component: page(() => import('./pages/preview.vue')),
-}, {
-	path: '/auth/:token',
-	component: page(() => import('./pages/auth.vue')),
-}, {
-	path: '/miauth/:session',
-	component: page(() => import('./pages/miauth.vue')),
-	query: {
-		callback: 'callback',
-		name: 'name',
-		icon: 'icon',
-		permission: 'permission',
-	},
-}, {
-	path: '/tags/:tag',
-	component: page(() => import('./pages/tag.vue')),
-}, {
-	path: '/pages/new',
-	component: page(() => import('./pages/page-editor/page-editor.vue')),
-	loginRequired: true,
-}, {
-	path: '/pages/edit/:initPageId',
-	component: page(() => import('./pages/page-editor/page-editor.vue')),
-	loginRequired: true,
-}, {
-	path: '/pages',
-	component: page(() => import('./pages/pages.vue')),
-}, {
-	path: '/gallery/:postId/edit',
-	component: page(() => import('./pages/gallery/edit.vue')),
-	loginRequired: true,
-}, {
-	path: '/gallery/new',
-	component: page(() => import('./pages/gallery/edit.vue')),
-	loginRequired: true,
-}, {
-	path: '/gallery/:postId',
-	component: page(() => import('./pages/gallery/post.vue')),
-}, {
-	path: '/gallery',
-	component: page(() => import('./pages/gallery/index.vue')),
-}, {
-	path: '/channels/:channelId/edit',
-	component: page(() => import('./pages/channel-editor.vue')),
-	loginRequired: true,
-}, {
-	path: '/channels/new',
-	component: page(() => import('./pages/channel-editor.vue')),
-	loginRequired: true,
-}, {
-	path: '/channels/:channelId',
-	component: page(() => import('./pages/channel.vue')),
-}, {
-	path: '/channels',
-	component: page(() => import('./pages/channels.vue')),
-}, {
-	path: '/registry/keys/system/:path(*)?',
-	component: page(() => import('./pages/registry.keys.vue')),
-}, {
-	path: '/registry/value/system/:path(*)?',
-	component: page(() => import('./pages/registry.value.vue')),
-}, {
-	path: '/registry',
-	component: page(() => import('./pages/registry.vue')),
-}, {
-	path: '/admin/file/:fileId',
-	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
-}, {
-	path: '/admin',
-	component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
-	children: [{
-		path: '/overview',
-		name: 'overview',
-		component: page(() => import('./pages/admin/overview.vue')),
-	}, {
-		path: '/users',
-		name: 'users',
-		component: page(() => import('./pages/admin/users.vue')),
-	}, {
-		path: '/emojis',
-		name: 'emojis',
-		component: page(() => import('./pages/admin/emojis.vue')),
-	}, {
-		path: '/queue',
-		name: 'queue',
-		component: page(() => import('./pages/admin/queue.vue')),
-	}, {
-		path: '/files',
-		name: 'files',
-		component: page(() => import('./pages/admin/files.vue')),
-	}, {
-		path: '/announcements',
-		name: 'announcements',
-		component: page(() => import('./pages/admin/announcements.vue')),
-	}, {
-		path: '/ads',
-		name: 'ads',
-		component: page(() => import('./pages/admin/ads.vue')),
-	}, {
-		path: '/database',
-		name: 'database',
-		component: page(() => import('./pages/admin/database.vue')),
-	}, {
-		path: '/abuses',
-		name: 'abuses',
-		component: page(() => import('./pages/admin/abuses.vue')),
-	}, {
-		path: '/settings',
-		name: 'settings',
-		component: page(() => import('./pages/admin/settings.vue')),
-	}, {
-		path: '/email-settings',
-		name: 'email-settings',
-		component: page(() => import('./pages/admin/email-settings.vue')),
-	}, {
-		path: '/object-storage',
-		name: 'object-storage',
-		component: page(() => import('./pages/admin/object-storage.vue')),
-	}, {
-		path: '/security',
-		name: 'security',
-		component: page(() => import('./pages/admin/security.vue')),
-	}, {
-		path: '/relays',
-		name: 'relays',
-		component: page(() => import('./pages/admin/relays.vue')),
-	}, {
-		path: '/integrations',
-		name: 'integrations',
-		component: page(() => import('./pages/admin/integrations.vue')),
-	}, {
-		path: '/instance-block',
-		name: 'instance-block',
-		component: page(() => import('./pages/admin/instance-block.vue')),
-	}, {
-		path: '/proxy-account',
-		name: 'proxy-account',
-		component: page(() => import('./pages/admin/proxy-account.vue')),
-	}, {
-		path: '/other-settings',
-		name: 'other-settings',
-		component: page(() => import('./pages/admin/other-settings.vue')),
-	}, {
-		path: '/',
-		component: page(() => import('./pages/_empty_.vue')),
-	}],
-}, {
-	path: '/my/notifications',
-	component: page(() => import('./pages/notifications.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/favorites',
-	component: page(() => import('./pages/favorites.vue')),
-	loginRequired: true,
-}, {
-	name: 'messaging',
-	path: '/my/messaging',
-	component: page(() => import('./pages/messaging/index.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/messaging/:userAcct',
-	component: page(() => import('./pages/messaging/messaging-room.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/messaging/group/:groupId',
-	component: page(() => import('./pages/messaging/messaging-room.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/drive/folder/:folder',
-	component: page(() => import('./pages/drive.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/drive',
-	component: page(() => import('./pages/drive.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/follow-requests',
-	component: page(() => import('./pages/follow-requests.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/lists/:listId',
-	component: page(() => import('./pages/my-lists/list.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/lists',
-	component: page(() => import('./pages/my-lists/index.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/clips',
-	component: page(() => import('./pages/my-clips/index.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/antennas/create',
-	component: page(() => import('./pages/my-antennas/create.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/antennas/:antennaId',
-	component: page(() => import('./pages/my-antennas/edit.vue')),
-	loginRequired: true,
-}, {
-	path: '/my/antennas',
-	component: page(() => import('./pages/my-antennas/index.vue')),
-	loginRequired: true,
-}, {
-	path: '/timeline/list/:listId',
-	component: page(() => import('./pages/user-list-timeline.vue')),
-	loginRequired: true,
-}, {
-	path: '/timeline/antenna/:antennaId',
-	component: page(() => import('./pages/antenna-timeline.vue')),
-	loginRequired: true,
-}, {
-	name: 'index',
-	path: '/',
-	component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')),
-	globalCacheKey: 'index',
-}, {
-	path: '/:(*)',
-	component: page(() => import('./pages/not-found.vue')),
-}];
-
-export const mainRouter = new Router(routes, location.pathname + location.search + location.hash);
-
-window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
-
-// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする
-// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも
-
-const scrollPosStore = new Map<string, number>();
-
-window.setInterval(() => {
-	scrollPosStore.set(window.history.state?.key, window.scrollY);
-}, 1000);
-
-mainRouter.addListener('push', ctx => {
-	window.history.pushState({ key: ctx.key }, '', ctx.path);
-	const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
-	window.scroll({ top: scrollPos, behavior: 'instant' });
-	if (scrollPos !== 0) {
-		window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
-			window.scroll({ top: scrollPos, behavior: 'instant' });
-		}, 100);
-	}
-});
-
-mainRouter.addListener('replace', ctx => {
-	window.history.replaceState({ key: ctx.key }, '', ctx.path);
-});
-
-mainRouter.addListener('same', () => {
-	window.scroll({ top: 0, behavior: 'smooth' });
-});
-
-window.addEventListener('popstate', (event) => {
-	mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false);
-	const scrollPos = scrollPosStore.get(event.state?.key) ?? 0;
-	window.scroll({ top: scrollPos, behavior: 'instant' });
-	window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
-		window.scroll({ top: scrollPos, behavior: 'instant' });
-	}, 100);
-});
-
-export function useRouter(): Router {
-	return inject<Router | null>('router', null) ?? mainRouter;
-}
diff --git a/packages/client/src/scripts/2fa.ts b/packages/client/src/scripts/2fa.ts
deleted file mode 100644
index 62a38ff02a..0000000000
--- a/packages/client/src/scripts/2fa.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
-	switch (encoding) {
-		case 'ascii':
-			return Uint8Array.from(string, c => c.charCodeAt(0));
-		case 'base64':
-			return Uint8Array.from(
-				atob(
-					string
-						.replace(/-/g, '+')
-						.replace(/_/g, '/'),
-				),
-				c => c.charCodeAt(0),
-			);
-		case 'hex':
-			return new Uint8Array(
-				string
-					.match(/.{1,2}/g)
-					.map(byte => parseInt(byte, 16)),
-			);
-	}
-}
-
-export function hexify(buffer: ArrayBuffer) {
-	return Array.from(new Uint8Array(buffer))
-		.reduce(
-			(str, byte) => str + byte.toString(16).padStart(2, '0'),
-			'',
-		);
-}
-
-export function stringify(buffer: ArrayBuffer) {
-	return String.fromCharCode(... new Uint8Array(buffer));
-}
diff --git a/packages/client/src/scripts/aiscript/api.ts b/packages/client/src/scripts/aiscript/api.ts
deleted file mode 100644
index 6debcb8a13..0000000000
--- a/packages/client/src/scripts/aiscript/api.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { utils, values } from '@syuilo/aiscript';
-import * as os from '@/os';
-import { $i } from '@/account';
-
-export function createAiScriptEnv(opts) {
-	let apiRequests = 0;
-	return {
-		USER_ID: $i ? values.STR($i.id) : values.NULL,
-		USER_NAME: $i ? values.STR($i.name) : values.NULL,
-		USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
-		'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
-			await os.alert({
-				type: type ? type.value : 'info',
-				title: title.value,
-				text: text.value,
-			});
-		}),
-		'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
-			const confirm = await os.confirm({
-				type: type ? type.value : 'question',
-				title: title.value,
-				text: text.value,
-			});
-			return confirm.canceled ? values.FALSE : values.TRUE;
-		}),
-		'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
-			if (token) utils.assertString(token);
-			apiRequests++;
-			if (apiRequests > 16) return values.NULL;
-			const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null));
-			return utils.jsToVal(res);
-		}),
-		'Mk:save': values.FN_NATIVE(([key, value]) => {
-			utils.assertString(key);
-			localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value)));
-			return values.NULL;
-		}),
-		'Mk:load': values.FN_NATIVE(([key]) => {
-			utils.assertString(key);
-			return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value)));
-		}),
-	};
-}
diff --git a/packages/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts
deleted file mode 100644
index 4620c8b735..0000000000
--- a/packages/client/src/scripts/array.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { EndoRelation, Predicate } from './relation';
-
-/**
- * Count the number of elements that satisfy the predicate
- */
-
-export function countIf<T>(f: Predicate<T>, xs: T[]): number {
-	return xs.filter(f).length;
-}
-
-/**
- * Count the number of elements that is equal to the element
- */
-export function count<T>(a: T, xs: T[]): number {
-	return countIf(x => x === a, xs);
-}
-
-/**
- * Concatenate an array of arrays
- */
-export function concat<T>(xss: T[][]): T[] {
-	return ([] as T[]).concat(...xss);
-}
-
-/**
- * Intersperse the element between the elements of the array
- * @param sep The element to be interspersed
- */
-export function intersperse<T>(sep: T, xs: T[]): T[] {
-	return concat(xs.map(x => [sep, x])).slice(1);
-}
-
-/**
- * Returns the array of elements that is not equal to the element
- */
-export function erase<T>(a: T, xs: T[]): T[] {
-	return xs.filter(x => x !== a);
-}
-
-/**
- * Finds the array of all elements in the first array not contained in the second array.
- * The order of result values are determined by the first array.
- */
-export function difference<T>(xs: T[], ys: T[]): T[] {
-	return xs.filter(x => !ys.includes(x));
-}
-
-/**
- * Remove all but the first element from every group of equivalent elements
- */
-export function unique<T>(xs: T[]): T[] {
-	return [...new Set(xs)];
-}
-
-export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
-	const map = new Map<TKey, TValue>();
-
-	for (const value of values) {
-		const key = keySelector(value);
-		if (!map.has(key)) map.set(key, value);
-	}
-
-	return [...map.values()];
-}
-
-export function sum(xs: number[]): number {
-	return xs.reduce((a, b) => a + b, 0);
-}
-
-export function maximum(xs: number[]): number {
-	return Math.max(...xs);
-}
-
-/**
- * Splits an array based on the equivalence relation.
- * The concatenation of the result is equal to the argument.
- */
-export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
-	const groups = [] as T[][];
-	for (const x of xs) {
-		if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
-			groups[groups.length - 1].push(x);
-		} else {
-			groups.push([x]);
-		}
-	}
-	return groups;
-}
-
-/**
- * Splits an array based on the equivalence relation induced by the function.
- * The concatenation of the result is equal to the argument.
- */
-export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] {
-	return groupBy((a, b) => f(a) === f(b), xs);
-}
-
-export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
-	return collections.reduce((obj: Record<string, T[]>, item: T) => {
-		const key = keySelector(item);
-		if (typeof obj[key] === 'undefined') {
-			obj[key] = [];
-		}
-
-		obj[key].push(item);
-
-		return obj;
-	}, {});
-}
-
-/**
- * Compare two arrays by lexicographical order
- */
-export function lessThan(xs: number[], ys: number[]): boolean {
-	for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
-		if (xs[i] < ys[i]) return true;
-		if (xs[i] > ys[i]) return false;
-	}
-	return xs.length < ys.length;
-}
-
-/**
- * Returns the longest prefix of elements that satisfy the predicate
- */
-export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
-	const ys: T[] = [];
-	for (const x of xs) {
-		if (f(x)) {
-			ys.push(x);
-		} else {
-			break;
-		}
-	}
-	return ys;
-}
-
-export function cumulativeSum(xs: number[]): number[] {
-	const ys = Array.from(xs); // deep copy
-	for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
-	return ys;
-}
-
-export function toArray<T>(x: T | T[] | undefined): T[] {
-	return Array.isArray(x) ? x : x != null ? [x] : [];
-}
-
-export function toSingle<T>(x: T | T[] | undefined): T | undefined {
-	return Array.isArray(x) ? x[0] : x;
-}
diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts
deleted file mode 100644
index 1bae3790f5..0000000000
--- a/packages/client/src/scripts/autocomplete.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
-import getCaretCoordinates from 'textarea-caret';
-import { toASCII } from 'punycode/';
-import { popup } from '@/os';
-
-export class Autocomplete {
-	private suggestion: {
-		x: Ref<number>;
-		y: Ref<number>;
-		q: Ref<string | null>;
-		close: () => void;
-	} | null;
-	private textarea: HTMLInputElement | HTMLTextAreaElement;
-	private currentType: string;
-	private textRef: Ref<string>;
-	private opening: boolean;
-
-	private get text(): string {
-		// Use raw .value to get the latest value
-		// (Because v-model does not update while composition)
-		return this.textarea.value;
-	}
-
-	private set text(text: string) {
-		// Use ref value to notify other watchers
-		// (Because .value setter never fires input/change events)
-		this.textRef.value = text;
-	}
-
-	/**
-	 * 対象のテキストエリアを与えてインスタンスを初期化します。
-	 */
-	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
-		//#region BIND
-		this.onInput = this.onInput.bind(this);
-		this.complete = this.complete.bind(this);
-		this.close = this.close.bind(this);
-		//#endregion
-
-		this.suggestion = null;
-		this.textarea = textarea;
-		this.textRef = textRef;
-		this.opening = false;
-
-		this.attach();
-	}
-
-	/**
-	 * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
-	 */
-	public attach() {
-		this.textarea.addEventListener('input', this.onInput);
-	}
-
-	/**
-	 * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
-	 */
-	public detach() {
-		this.textarea.removeEventListener('input', this.onInput);
-		this.close();
-	}
-
-	/**
-	 * テキスト入力時
-	 */
-	private onInput() {
-		const caretPos = this.textarea.selectionStart;
-		const text = this.text.substr(0, caretPos).split('\n').pop()!;
-
-		const mentionIndex = text.lastIndexOf('@');
-		const hashtagIndex = text.lastIndexOf('#');
-		const emojiIndex = text.lastIndexOf(':');
-		const mfmTagIndex = text.lastIndexOf('$');
-
-		const max = Math.max(
-			mentionIndex,
-			hashtagIndex,
-			emojiIndex,
-			mfmTagIndex);
-
-		if (max === -1) {
-			this.close();
-			return;
-		}
-
-		const isMention = mentionIndex !== -1;
-		const isHashtag = hashtagIndex !== -1;
-		const isMfmTag = mfmTagIndex !== -1;
-		const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
-
-		let opened = false;
-
-		if (isMention) {
-			const username = text.substr(mentionIndex + 1);
-			if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
-				this.open('user', username);
-				opened = true;
-			} else if (username === '') {
-				this.open('user', null);
-				opened = true;
-			}
-		}
-
-		if (isHashtag && !opened) {
-			const hashtag = text.substr(hashtagIndex + 1);
-			if (!hashtag.includes(' ')) {
-				this.open('hashtag', hashtag);
-				opened = true;
-			}
-		}
-
-		if (isEmoji && !opened) {
-			const emoji = text.substr(emojiIndex + 1);
-			if (!emoji.includes(' ')) {
-				this.open('emoji', emoji);
-				opened = true;
-			}
-		}
-
-		if (isMfmTag && !opened) {
-			const mfmTag = text.substr(mfmTagIndex + 1);
-			if (!mfmTag.includes(' ')) {
-				this.open('mfmTag', mfmTag.replace('[', ''));
-				opened = true;
-			}
-		}
-
-		if (!opened) {
-			this.close();
-		}
-	}
-
-	/**
-	 * サジェストを提示します。
-	 */
-	private async open(type: string, q: string | null) {
-		if (type !== this.currentType) {
-			this.close();
-		}
-		if (this.opening) return;
-		this.opening = true;
-		this.currentType = type;
-
-		//#region サジェストを表示すべき位置を計算
-		const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
-
-		const rect = this.textarea.getBoundingClientRect();
-
-		const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
-		const y = rect.top + caretPosition.top - this.textarea.scrollTop;
-		//#endregion
-
-		if (this.suggestion) {
-			this.suggestion.x.value = x;
-			this.suggestion.y.value = y;
-			this.suggestion.q.value = q;
-
-			this.opening = false;
-		} else {
-			const _x = ref(x);
-			const _y = ref(y);
-			const _q = ref(q);
-
-			const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
-				textarea: this.textarea,
-				close: this.close,
-				type: type,
-				q: _q,
-				x: _x,
-				y: _y,
-			}, {
-				done: (res) => {
-					this.complete(res);
-				},
-			});
-
-			this.suggestion = {
-				q: _q,
-				x: _x,
-				y: _y,
-				close: () => dispose(),
-			};
-
-			this.opening = false;
-		}
-	}
-
-	/**
-	 * サジェストを閉じます。
-	 */
-	private close() {
-		if (this.suggestion == null) return;
-
-		this.suggestion.close();
-		this.suggestion = null;
-
-		this.textarea.focus();
-	}
-
-	/**
-	 * オートコンプリートする
-	 */
-	private complete({ type, value }) {
-		this.close();
-
-		const caret = this.textarea.selectionStart;
-
-		if (type === 'user') {
-			const source = this.text;
-
-			const before = source.substr(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
-			const after = source.substr(caret);
-
-			const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
-
-			// 挿入
-			this.text = `${trimmedBefore}@${acct} ${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (acct.length + 2);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'hashtag') {
-			const source = this.text;
-
-			const before = source.substr(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
-			const after = source.substr(caret);
-
-			// 挿入
-			this.text = `${trimmedBefore}#${value} ${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (value.length + 2);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'emoji') {
-			const source = this.text;
-
-			const before = source.substr(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
-			const after = source.substr(caret);
-
-			// 挿入
-			this.text = trimmedBefore + value + after;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + value.length;
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'mfmTag') {
-			const source = this.text;
-
-			const before = source.substr(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
-			const after = source.substr(caret);
-
-			// 挿入
-			this.text = `${trimmedBefore}$[${value} ]${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (value.length + 3);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		}
-	}
-}
diff --git a/packages/client/src/scripts/chart-vline.ts b/packages/client/src/scripts/chart-vline.ts
deleted file mode 100644
index 8e3c4436b2..0000000000
--- a/packages/client/src/scripts/chart-vline.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export const chartVLine = (vLineColor: string) => ({
-	id: 'vLine',
-	beforeDraw(chart, args, options) {
-		if (chart.tooltip?._active?.length) {
-			const activePoint = chart.tooltip._active[0];
-			const ctx = chart.ctx;
-			const x = activePoint.element.x;
-			const topY = chart.scales.y.top;
-			const bottomY = chart.scales.y.bottom;
-
-			ctx.save();
-			ctx.beginPath();
-			ctx.moveTo(x, bottomY);
-			ctx.lineTo(x, topY);
-			ctx.lineWidth = 1;
-			ctx.strokeStyle = vLineColor;
-			ctx.stroke();
-			ctx.restore();
-		}
-	},
-});
diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts
deleted file mode 100644
index 35d40a6e08..0000000000
--- a/packages/client/src/scripts/check-word-mute.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean {
-	// 自分自身
-	if (me && (note.userId === me.id)) return false;
-
-	if (mutedWords.length > 0) {
-		const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
-
-		if (text === '') return false;
-
-		const matched = mutedWords.some(filter => {
-			if (Array.isArray(filter)) {
-				// Clean up
-				const filteredFilter = filter.filter(keyword => keyword !== '');
-				if (filteredFilter.length === 0) return false;
-
-				return filteredFilter.every(keyword => text.includes(keyword));
-			} else {
-				// represents RegExp
-				const regexp = filter.match(/^\/(.+)\/(.*)$/);
-
-				// This should never happen due to input sanitisation.
-				if (!regexp) return false;
-
-				try {
-					return new RegExp(regexp[1], regexp[2]).test(text);
-				} catch (err) {
-					// This should never happen due to input sanitisation.
-					return false;
-				}
-			}
-		});
-
-		if (matched) return true;
-	}
-
-	return false;
-}
diff --git a/packages/client/src/scripts/clone.ts b/packages/client/src/scripts/clone.ts
deleted file mode 100644
index 16fad24129..0000000000
--- a/packages/client/src/scripts/clone.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// structredCloneが遅いため
-// SEE: http://var.blog.jp/archives/86038606.html
-
-type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
-
-export function deepClone<T extends Cloneable>(x: T): T {
-	if (typeof x === 'object') {
-		if (x === null) return x;
-		if (Array.isArray(x)) return x.map(deepClone) as T;
-		const obj = {} as Record<string, Cloneable>;
-		for (const [k, v] of Object.entries(x)) {
-			obj[k] = deepClone(v);
-		}
-		return obj as T;
-	} else {
-		return x;
-	}
-}
diff --git a/packages/client/src/scripts/collect-page-vars.ts b/packages/client/src/scripts/collect-page-vars.ts
deleted file mode 100644
index 76b68beaf6..0000000000
--- a/packages/client/src/scripts/collect-page-vars.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-interface StringPageVar {
-	name: string,
-	type: 'string',
-	value: string
-}
-
-interface NumberPageVar {
-	name: string,
-	type: 'number',
-	value: number
-}
-
-interface BooleanPageVar {
-	name: string,
-	type: 'boolean',
-	value: boolean
-}
-
-type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
-
-export function collectPageVars(content): PageVar[] {
-	const pageVars: PageVar[] = [];
-	const collect = (xs: any[]): void => {
-		for (const x of xs) {
-			if (x.type === 'textInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.type === 'textareaInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.type === 'numberInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'number',
-					value: x.default || 0,
-				});
-			} else if (x.type === 'switch') {
-				pageVars.push({
-					name: x.name,
-					type: 'boolean',
-					value: x.default || false,
-				});
-			} else if (x.type === 'counter') {
-				pageVars.push({
-					name: x.name,
-					type: 'number',
-					value: 0,
-				});
-			} else if (x.type === 'radioButton') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.children) {
-				collect(x.children);
-			}
-		}
-	};
-	collect(content);
-	return pageVars;
-}
diff --git a/packages/client/src/scripts/contains.ts b/packages/client/src/scripts/contains.ts
deleted file mode 100644
index 256e09d293..0000000000
--- a/packages/client/src/scripts/contains.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export default (parent, child, checkSame = true) => {
-	if (checkSame && parent === child) return true;
-	let node = child.parentNode;
-	while (node) {
-		if (node === parent) return true;
-		node = node.parentNode;
-	}
-	return false;
-};
diff --git a/packages/client/src/scripts/copy-to-clipboard.ts b/packages/client/src/scripts/copy-to-clipboard.ts
deleted file mode 100644
index ab13cab970..0000000000
--- a/packages/client/src/scripts/copy-to-clipboard.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Clipboardに値をコピー(TODO: 文字列以外も対応)
- */
-export default val => {
-	// 空div 生成
-	const tmp = document.createElement('div');
-	// 選択用のタグ生成
-	const pre = document.createElement('pre');
-
-	// 親要素のCSSで user-select: none だとコピーできないので書き換える
-	pre.style.webkitUserSelect = 'auto';
-	pre.style.userSelect = 'auto';
-
-	tmp.appendChild(pre).textContent = val;
-
-	// 要素を画面外へ
-	const s = tmp.style;
-	s.position = 'fixed';
-	s.right = '200%';
-
-	// body に追加
-	document.body.appendChild(tmp);
-	// 要素を選択
-	document.getSelection().selectAllChildren(tmp);
-
-	// クリップボードにコピー
-	const result = document.execCommand('copy');
-
-	// 要素削除
-	document.body.removeChild(tmp);
-
-	return result;
-};
diff --git a/packages/client/src/scripts/device-kind.ts b/packages/client/src/scripts/device-kind.ts
deleted file mode 100644
index 544cac0604..0000000000
--- a/packages/client/src/scripts/device-kind.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defaultStore } from '@/store';
-
-const ua = navigator.userAgent.toLowerCase();
-const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
-const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
-
-export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
-	: isSmartphone ? 'smartphone'
-	: isTablet ? 'tablet'
-	: 'desktop';
diff --git a/packages/client/src/scripts/emoji-base.ts b/packages/client/src/scripts/emoji-base.ts
deleted file mode 100644
index 3f05642d57..0000000000
--- a/packages/client/src/scripts/emoji-base.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-const twemojiSvgBase = '/twemoji';
-const fluentEmojiPngBase = '/fluent-emoji';
-
-export function char2twemojiFilePath(char: string): string {
-	let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
-	if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
-	codes = codes.filter(x => x && x.length);
-	const fileName = codes.join('-');
-	return `${twemojiSvgBase}/${fileName}.svg`;
-}
-
-export function char2fluentEmojiFilePath(char: string): string {
-	let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
-	// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
-	if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
-	if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
-	codes = codes.filter(x => x && x.length);
-	const fileName = codes.map(x => x!.padStart(4, '0')).join('-');
-	return `${fluentEmojiPngBase}/${fileName}.png`;
-}
diff --git a/packages/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts
deleted file mode 100644
index bc52fa7a43..0000000000
--- a/packages/client/src/scripts/emojilist.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const;
-
-export type UnicodeEmojiDef = {
-	name: string;
-	keywords: string[];
-	char: string;
-	category: typeof unicodeEmojiCategories[number];
-}
-
-// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
-import _emojilist from '../emojilist.json';
-
-export const emojilist = _emojilist as UnicodeEmojiDef[];
-
-export function getEmojiName(char: string): string | undefined {
-	return emojilist.find(emo => emo.char === char)?.name;
-}
diff --git a/packages/client/src/scripts/extract-avg-color-from-blurhash.ts b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts
deleted file mode 100644
index af517f2672..0000000000
--- a/packages/client/src/scripts/extract-avg-color-from-blurhash.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export function extractAvgColorFromBlurhash(hash: string) {
-	return typeof hash === 'string'
-		? '#' + [...hash.slice(2, 6)]
-				.map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x))
-				.reduce((a, c) => a * 83 + c, 0)
-				.toString(16)
-				.padStart(6, '0')
-		: undefined;
-}
diff --git a/packages/client/src/scripts/extract-mentions.ts b/packages/client/src/scripts/extract-mentions.ts
deleted file mode 100644
index cc19b161a8..0000000000
--- a/packages/client/src/scripts/extract-mentions.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// test is located in test/extract-mentions
-
-import * as mfm from 'mfm-js';
-
-export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
-	// TODO: 重複を削除
-	const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
-	const mentions = mentionNodes.map(x => x.props);
-
-	return mentions;
-}
diff --git a/packages/client/src/scripts/extract-url-from-mfm.ts b/packages/client/src/scripts/extract-url-from-mfm.ts
deleted file mode 100644
index 34e3eb6c19..0000000000
--- a/packages/client/src/scripts/extract-url-from-mfm.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as mfm from 'mfm-js';
-import { unique } from '@/scripts/array';
-
-// unique without hash
-// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
-const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
-
-export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
-	const urlNodes = mfm.extract(nodes, (node) => {
-		return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
-	});
-	const urls: string[] = unique(urlNodes.map(x => x.props.url));
-
-	return urls.reduce((array, url) => {
-		const urlWithoutHash = removeHash(url);
-		if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
-		return array;
-	}, [] as string[]);
-}
diff --git a/packages/client/src/scripts/focus.ts b/packages/client/src/scripts/focus.ts
deleted file mode 100644
index d6802fa322..0000000000
--- a/packages/client/src/scripts/focus.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-export function focusPrev(el: Element | null, self = false, scroll = true) {
-	if (el == null) return;
-	if (!self) el = el.previousElementSibling;
-	if (el) {
-		if (el.hasAttribute('tabindex')) {
-			(el as HTMLElement).focus({
-				preventScroll: !scroll,
-			});
-		} else {
-			focusPrev(el.previousElementSibling, true);
-		}
-	}
-}
-
-export function focusNext(el: Element | null, self = false, scroll = true) {
-	if (el == null) return;
-	if (!self) el = el.nextElementSibling;
-	if (el) {
-		if (el.hasAttribute('tabindex')) {
-			(el as HTMLElement).focus({
-				preventScroll: !scroll,
-			});
-		} else {
-			focusPrev(el.nextElementSibling, true);
-		}
-	}
-}
diff --git a/packages/client/src/scripts/form.ts b/packages/client/src/scripts/form.ts
deleted file mode 100644
index 7f321cc0ae..0000000000
--- a/packages/client/src/scripts/form.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-export type FormItem = {
-	label?: string;
-	type: 'string';
-	default: string | null;
-	hidden?: boolean;
-	multiline?: boolean;
-} | {
-	label?: string;
-	type: 'number';
-	default: number | null;
-	hidden?: boolean;
-	step?: number;
-} | {
-	label?: string;
-	type: 'boolean';
-	default: boolean | null;
-	hidden?: boolean;
-} | {
-	label?: string;
-	type: 'enum';
-	default: string | null;
-	hidden?: boolean;
-	enum: string[];
-} | {
-	label?: string;
-	type: 'radio';
-	default: unknown | null;
-	hidden?: boolean;
-	options: {
-		label: string;
-		value: unknown;
-	}[];
-} | {
-	label?: string;
-	type: 'object';
-	default: Record<string, unknown> | null;
-	hidden: true;
-} | {
-	label?: string;
-	type: 'array';
-	default: unknown[] | null;
-	hidden: true;
-};
-
-export type Form = Record<string, FormItem>;
-
-type GetItemType<Item extends FormItem> =
-	Item['type'] extends 'string' ? string :
-	Item['type'] extends 'number' ? number :
-	Item['type'] extends 'boolean' ? boolean :
-	Item['type'] extends 'radio' ? unknown :
-	Item['type'] extends 'enum' ? string :
-	Item['type'] extends 'array' ? unknown[] :
-	Item['type'] extends 'object' ? Record<string, unknown>
-	: never;
-
-export type GetFormResultType<F extends Form> = {
-	[P in keyof F]: GetItemType<F[P]>;
-};
diff --git a/packages/client/src/scripts/format-time-string.ts b/packages/client/src/scripts/format-time-string.ts
deleted file mode 100644
index c20db5e827..0000000000
--- a/packages/client/src/scripts/format-time-string.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-const defaultLocaleStringFormats: {[index: string]: string} = {
-	'weekday': 'narrow',
-	'era': 'narrow',
-	'year': 'numeric',
-	'month': 'numeric',
-	'day': 'numeric',
-	'hour': 'numeric',
-	'minute': 'numeric',
-	'second': 'numeric',
-	'timeZoneName': 'short',
-};
-
-function formatLocaleString(date: Date, format: string): string {
-	return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
-		if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
-			return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] });
-		} else {
-			return match;
-		}
-	});
-}
-
-export function formatDateTimeString(date: Date, format: string): string {
-	return format
-		.replace(/yyyy/g, date.getFullYear().toString())
-		.replace(/yy/g, date.getFullYear().toString().slice(-2))
-		.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' }))
-		.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' }))
-		.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
-		.replace(/M/g, (date.getMonth() + 1).toString())
-		.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
-		.replace(/d/g, date.getDate().toString())
-		.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
-		.replace(/H/g, date.getHours().toString())
-		.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
-		.replace(/h/g, ((date.getHours() % 12) || 12).toString())
-		.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
-		.replace(/m/g, date.getMinutes().toString())
-		.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
-		.replace(/s/g, date.getSeconds().toString())
-		.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
-}
-
-export function formatTimeString(date: Date, format: string): string {
-	return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
-		if (localeformat) return formatLocaleString(date, localeformat);
-		if (datetimeformat) return formatDateTimeString(date, datetimeformat);
-		return match;
-	});
-}
diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts
deleted file mode 100644
index da7d622632..0000000000
--- a/packages/client/src/scripts/gen-search-query.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as Acct from 'misskey-js/built/acct';
-import { host as localHost } from '@/config';
-
-export async function genSearchQuery(v: any, q: string) {
-	let host: string;
-	let userId: string;
-	if (q.split(' ').some(x => x.startsWith('@'))) {
-		for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) {
-			if (at.includes('.')) {
-				if (at === localHost || at === '.') {
-					host = null;
-				} else {
-					host = at;
-				}
-			} else {
-				const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null);
-				if (user) {
-					userId = user.id;
-				} else {
-					// todo: show error
-				}
-			}
-		}
-	}
-	return {
-		query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
-		host: host,
-		userId: userId,
-	};
-}
diff --git a/packages/client/src/scripts/get-account-from-id.ts b/packages/client/src/scripts/get-account-from-id.ts
deleted file mode 100644
index 1da897f176..0000000000
--- a/packages/client/src/scripts/get-account-from-id.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { get } from '@/scripts/idb-proxy';
-
-export async function getAccountFromId(id: string) {
-	const accounts = await get('accounts') as { token: string; id: string; }[];
-	if (!accounts) console.log('Accounts are not recorded');
-	return accounts.find(account => account.id === id);
-}
diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts
deleted file mode 100644
index 7656770894..0000000000
--- a/packages/client/src/scripts/get-note-menu.ts
+++ /dev/null
@@ -1,341 +0,0 @@
-import { defineAsyncComponent, Ref, inject } from 'vue';
-import * as misskey from 'misskey-js';
-import { pleaseLogin } from './please-login';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import * as os from '@/os';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { url } from '@/config';
-import { noteActions } from '@/store';
-import { notePage } from '@/filters/note';
-
-export function getNoteMenu(props: {
-	note: misskey.entities.Note;
-	menuButton: Ref<HTMLElement>;
-	translation: Ref<any>;
-	translating: Ref<boolean>;
-	isDeleted: Ref<boolean>;
-	currentClipPage?: Ref<misskey.entities.Clip>;
-}) {
-	const isRenote = (
-		props.note.renote != null &&
-		props.note.text == null &&
-		props.note.fileIds.length === 0 &&
-		props.note.poll == null
-	);
-
-	const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
-
-	function del(): void {
-		os.confirm({
-			type: 'warning',
-			text: i18n.ts.noteDeleteConfirm,
-		}).then(({ canceled }) => {
-			if (canceled) return;
-
-			os.api('notes/delete', {
-				noteId: appearNote.id,
-			});
-		});
-	}
-
-	function delEdit(): void {
-		os.confirm({
-			type: 'warning',
-			text: i18n.ts.deleteAndEditConfirm,
-		}).then(({ canceled }) => {
-			if (canceled) return;
-
-			os.api('notes/delete', {
-				noteId: appearNote.id,
-			});
-
-			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
-		});
-	}
-
-	function toggleFavorite(favorite: boolean): void {
-		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
-			noteId: appearNote.id,
-		});
-	}
-
-	function toggleThreadMute(mute: boolean): void {
-		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
-			noteId: appearNote.id,
-		});
-	}
-
-	function copyContent(): void {
-		copyToClipboard(appearNote.text);
-		os.success();
-	}
-
-	function copyLink(): void {
-		copyToClipboard(`${url}/notes/${appearNote.id}`);
-		os.success();
-	}
-
-	function togglePin(pin: boolean): void {
-		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
-			noteId: appearNote.id,
-		}, undefined, null, res => {
-			if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
-				os.alert({
-					type: 'error',
-					text: i18n.ts.pinLimitExceeded,
-				});
-			}
-		});
-	}
-
-	async function clip(): Promise<void> {
-		const clips = await os.api('clips/list');
-		os.popupMenu([{
-			icon: 'ti ti-plus',
-			text: i18n.ts.createNew,
-			action: async () => {
-				const { canceled, result } = await os.form(i18n.ts.createNewClip, {
-					name: {
-						type: 'string',
-						label: i18n.ts.name,
-					},
-					description: {
-						type: 'string',
-						required: false,
-						multiline: true,
-						label: i18n.ts.description,
-					},
-					isPublic: {
-						type: 'boolean',
-						label: i18n.ts.public,
-						default: false,
-					},
-				});
-				if (canceled) return;
-
-				const clip = await os.apiWithDialog('clips/create', result);
-
-				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
-			},
-		}, null, ...clips.map(clip => ({
-			text: clip.name,
-			action: () => {
-				os.promiseDialog(
-					os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
-					null,
-					async (err) => {
-						if (err.id === '734806c4-542c-463a-9311-15c512803965') {
-							const confirm = await os.confirm({
-								type: 'warning',
-								text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
-							});
-							if (!confirm.canceled) {
-								os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
-								if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
-							}
-						} else {
-							os.alert({
-								type: 'error',
-								text: err.message + '\n' + err.id,
-							});
-						}
-					},
-				);
-			},
-		}))], props.menuButton.value, {
-		}).then(focus);
-	}
-
-	async function unclip(): Promise<void> {
-		os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
-		props.isDeleted.value = true;
-	}
-
-	async function promote(): Promise<void> {
-		const { canceled, result: days } = await os.inputNumber({
-			title: i18n.ts.numberOfDays,
-		});
-
-		if (canceled) return;
-
-		os.apiWithDialog('admin/promo/create', {
-			noteId: appearNote.id,
-			expiresAt: Date.now() + (86400000 * days),
-		});
-	}
-
-	function share(): void {
-		navigator.share({
-			title: i18n.t('noteOf', { user: appearNote.user.name }),
-			text: appearNote.text,
-			url: `${url}/notes/${appearNote.id}`,
-		});
-	}
-	function notedetails(): void {
-		os.pageWindow(`/notes/${appearNote.id}`);
-	}
-	async function translate(): Promise<void> {
-		if (props.translation.value != null) return;
-		props.translating.value = true;
-		const res = await os.api('notes/translate', {
-			noteId: appearNote.id,
-			targetLang: localStorage.getItem('lang') || navigator.language,
-		});
-		props.translating.value = false;
-		props.translation.value = res;
-	}
-
-	let menu;
-	if ($i) {
-		const statePromise = os.api('notes/state', {
-			noteId: appearNote.id,
-		});
-
-		menu = [
-			...(
-				props.currentClipPage?.value.userId === $i.id ? [{
-					icon: 'ti ti-backspace',
-					text: i18n.ts.unclip,
-					danger: true,
-					action: unclip,
-				}, null] : []
-			), {
-				icon: 'ti ti-external-link',
-				text: i18n.ts.details,
-				action: notedetails,
-			}, {
-				icon: 'ti ti-copy',
-				text: i18n.ts.copyContent,
-				action: copyContent,
-			}, {
-				icon: 'ti ti-link',
-				text: i18n.ts.copyLink,
-				action: copyLink,
-			}, (appearNote.url || appearNote.uri) ? {
-				icon: 'ti ti-external-link',
-				text: i18n.ts.showOnRemote,
-				action: () => {
-					window.open(appearNote.url || appearNote.uri, '_blank');
-				},
-			} : undefined,
-			{
-				icon: 'ti ti-share',
-				text: i18n.ts.share,
-				action: share,
-			},
-			instance.translatorAvailable ? {
-				icon: 'ti ti-language-hiragana',
-				text: i18n.ts.translate,
-				action: translate,
-			} : undefined,
-			null,
-			statePromise.then(state => state.isFavorited ? {
-				icon: 'ti ti-star-off',
-				text: i18n.ts.unfavorite,
-				action: () => toggleFavorite(false),
-			} : {
-				icon: 'ti ti-star',
-				text: i18n.ts.favorite,
-				action: () => toggleFavorite(true),
-			}),
-			{
-				icon: 'ti ti-paperclip',
-				text: i18n.ts.clip,
-				action: () => clip(),
-			},
-			statePromise.then(state => state.isMutedThread ? {
-				icon: 'ti ti-message-off',
-				text: i18n.ts.unmuteThread,
-				action: () => toggleThreadMute(false),
-			} : {
-				icon: 'ti ti-message-off',
-				text: i18n.ts.muteThread,
-				action: () => toggleThreadMute(true),
-			}),
-			appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
-				icon: 'ti ti-pinned-off',
-				text: i18n.ts.unpin,
-				action: () => togglePin(false),
-			} : {
-				icon: 'ti ti-pin',
-				text: i18n.ts.pin,
-				action: () => togglePin(true),
-			} : undefined,
-			/*
-		...($i.isModerator || $i.isAdmin ? [
-			null,
-			{
-				icon: 'fas fa-bullhorn',
-				text: i18n.ts.promote,
-				action: promote
-			}]
-			: []
-		),*/
-			...(appearNote.userId !== $i.id ? [
-				null,
-				{
-					icon: 'ti ti-exclamation-circle',
-					text: i18n.ts.reportAbuse,
-					action: () => {
-						const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`;
-						os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
-							user: appearNote.user,
-							initialComment: `Note: ${u}\n-----\n`,
-						}, {}, 'closed');
-					},
-				}]
-			: []
-			),
-			...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
-				null,
-				appearNote.userId === $i.id ? {
-					icon: 'ti ti-edit',
-					text: i18n.ts.deleteAndEdit,
-					action: delEdit,
-				} : undefined,
-				{
-					icon: 'ti ti-trash',
-					text: i18n.ts.delete,
-					danger: true,
-					action: del,
-				}]
-			: []
-			)]
-			.filter(x => x !== undefined);
-	} else {
-		menu = [{
-			icon: 'ti ti-external-link',
-			text: i18n.ts.detailed,
-			action: openDetail,
-		}, {
-			icon: 'ti ti-copy',
-			text: i18n.ts.copyContent,
-			action: copyContent,
-		}, {
-			icon: 'ti ti-link',
-			text: i18n.ts.copyLink,
-			action: copyLink,
-		}, (appearNote.url || appearNote.uri) ? {
-			icon: 'ti ti-external-link',
-			text: i18n.ts.showOnRemote,
-			action: () => {
-				window.open(appearNote.url || appearNote.uri, '_blank');
-			},
-		} : undefined]
-			.filter(x => x !== undefined);
-	}
-
-	if (noteActions.length > 0) {
-		menu = menu.concat([null, ...noteActions.map(action => ({
-			icon: 'ti ti-plug',
-			text: action.title,
-			action: () => {
-				action.handler(appearNote);
-			},
-		}))]);
-	}
-
-	return menu;
-}
diff --git a/packages/client/src/scripts/get-note-summary.ts b/packages/client/src/scripts/get-note-summary.ts
deleted file mode 100644
index d57e1c3029..0000000000
--- a/packages/client/src/scripts/get-note-summary.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import * as misskey from 'misskey-js';
-import { i18n } from '@/i18n';
-
-/**
- * 投稿を表す文字列を取得します。
- * @param {*} note (packされた)投稿
- */
-export const getNoteSummary = (note: misskey.entities.Note): string => {
-	if (note.deletedAt) {
-		return `(${i18n.ts.deletedNote})`;
-	}
-
-	if (note.isHidden) {
-		return `(${i18n.ts.invisibleNote})`;
-	}
-
-	let summary = '';
-
-	// 本文
-	if (note.cw != null) {
-		summary += note.cw;
-	} else {
-		summary += note.text ? note.text : '';
-	}
-
-	// ファイルが添付されているとき
-	if ((note.files || []).length !== 0) {
-		summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
-	}
-
-	// 投票が添付されているとき
-	if (note.poll) {
-		summary += ` (${i18n.ts.poll})`;
-	}
-
-	// 返信のとき
-	if (note.replyId) {
-		if (note.reply) {
-			summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
-		} else {
-			summary += '\n\nRE: ...';
-		}
-	}
-
-	// Renoteのとき
-	if (note.renoteId) {
-		if (note.renote) {
-			summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
-		} else {
-			summary += '\n\nRN: ...';
-		}
-	}
-
-	return summary.trim();
-};
diff --git a/packages/client/src/scripts/get-static-image-url.ts b/packages/client/src/scripts/get-static-image-url.ts
deleted file mode 100644
index cbd1761983..0000000000
--- a/packages/client/src/scripts/get-static-image-url.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { url as instanceUrl } from '@/config';
-import * as url from '@/scripts/url';
-
-export function getStaticImageUrl(baseUrl: string): string {
-	const u = new URL(baseUrl);
-	if (u.href.startsWith(`${instanceUrl}/proxy/`)) {
-		// もう既にproxyっぽそうだったらsearchParams付けるだけ
-		u.searchParams.set('static', '1');
-		return u.href;
-	}
-
-	// 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する
-	const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`;
-
-	return `${instanceUrl}/proxy/${dummy}?${url.query({
-		url: u.href,
-		static: '1',
-	})}`;
-}
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
deleted file mode 100644
index 2faacffdfc..0000000000
--- a/packages/client/src/scripts/get-user-menu.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-import * as Acct from 'misskey-js/built/acct';
-import { defineAsyncComponent } from 'vue';
-import { i18n } from '@/i18n';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { host } from '@/config';
-import * as os from '@/os';
-import { userActions } from '@/store';
-import { $i, iAmModerator } from '@/account';
-import { mainRouter } from '@/router';
-import { Router } from '@/nirax';
-
-export function getUserMenu(user, router: Router = mainRouter) {
-	const meId = $i ? $i.id : null;
-
-	async function pushList() {
-		const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
-		const lists = await os.api('users/lists/list');
-		if (lists.length === 0) {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.youHaveNoLists,
-			});
-			return;
-		}
-		const { canceled, result: listId } = await os.select({
-			title: t,
-			items: lists.map(list => ({
-				value: list.id, text: list.name,
-			})),
-		});
-		if (canceled) return;
-		os.apiWithDialog('users/lists/push', {
-			listId: listId,
-			userId: user.id,
-		});
-	}
-
-	async function inviteGroup() {
-		const groups = await os.api('users/groups/owned');
-		if (groups.length === 0) {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.youHaveNoGroups,
-			});
-			return;
-		}
-		const { canceled, result: groupId } = await os.select({
-			title: i18n.ts.group,
-			items: groups.map(group => ({
-				value: group.id, text: group.name,
-			})),
-		});
-		if (canceled) return;
-		os.apiWithDialog('users/groups/invite', {
-			groupId: groupId,
-			userId: user.id,
-		});
-	}
-
-	async function toggleMute() {
-		if (user.isMuted) {
-			os.apiWithDialog('mute/delete', {
-				userId: user.id,
-			}).then(() => {
-				user.isMuted = false;
-			});
-		} else {
-			const { canceled, result: period } = await os.select({
-				title: i18n.ts.mutePeriod,
-				items: [{
-					value: 'indefinitely', text: i18n.ts.indefinitely,
-				}, {
-					value: 'tenMinutes', text: i18n.ts.tenMinutes,
-				}, {
-					value: 'oneHour', text: i18n.ts.oneHour,
-				}, {
-					value: 'oneDay', text: i18n.ts.oneDay,
-				}, {
-					value: 'oneWeek', text: i18n.ts.oneWeek,
-				}],
-				default: 'indefinitely',
-			});
-			if (canceled) return;
-
-			const expiresAt = period === 'indefinitely' ? null
-				: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
-				: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
-				: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
-				: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
-				: null;
-
-			os.apiWithDialog('mute/create', {
-				userId: user.id,
-				expiresAt,
-			}).then(() => {
-				user.isMuted = true;
-			});
-		}
-	}
-
-	async function toggleBlock() {
-		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
-
-		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
-			userId: user.id,
-		}).then(() => {
-			user.isBlocking = !user.isBlocking;
-		});
-	}
-
-	async function toggleSilence() {
-		if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
-
-		os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
-			userId: user.id,
-		}).then(() => {
-			user.isSilenced = !user.isSilenced;
-		});
-	}
-
-	async function toggleSuspend() {
-		if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
-
-		os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
-			userId: user.id,
-		}).then(() => {
-			user.isSuspended = !user.isSuspended;
-		});
-	}
-
-	function reportAbuse() {
-		os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
-			user: user,
-		}, {}, 'closed');
-	}
-
-	async function getConfirmed(text: string): Promise<boolean> {
-		const confirm = await os.confirm({
-			type: 'warning',
-			title: 'confirm',
-			text,
-		});
-
-		return !confirm.canceled;
-	}
-
-	async function invalidateFollow() {
-		os.apiWithDialog('following/invalidate', {
-			userId: user.id,
-		}).then(() => {
-			user.isFollowed = !user.isFollowed;
-		});
-	}
-
-	let menu = [{
-		icon: 'ti ti-at',
-		text: i18n.ts.copyUsername,
-		action: () => {
-			copyToClipboard(`@${user.username}@${user.host || host}`);
-		},
-	}, {
-		icon: 'ti ti-rss',
-		text: i18n.ts.copyRSS,
-		action: () => {
-			copyToClipboard(`${user.host || host}/@${user.username}.atom`);
-		}
-	}, {
-		icon: 'ti ti-info-circle',
-		text: i18n.ts.info,
-		action: () => {
-			router.push(`/user-info/${user.id}`);
-		},
-	}, {
-		icon: 'ti ti-mail',
-		text: i18n.ts.sendMessage,
-		action: () => {
-			os.post({ specified: user });
-		},
-	}, meId !== user.id ? {
-		type: 'link',
-		icon: 'ti ti-messages',
-		text: i18n.ts.startMessaging,
-		to: '/my/messaging/' + Acct.toString(user),
-	} : undefined, null, {
-		icon: 'ti ti-list',
-		text: i18n.ts.addToList,
-		action: pushList,
-	}, meId !== user.id ? {
-		icon: 'ti ti-users',
-		text: i18n.ts.inviteToGroup,
-		action: inviteGroup,
-	} : undefined] as any;
-
-	if ($i && meId !== user.id) {
-		menu = menu.concat([null, {
-			icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
-			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
-			action: toggleMute,
-		}, {
-			icon: 'ti ti-ban',
-			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
-			action: toggleBlock,
-		}]);
-
-		if (user.isFollowed) {
-			menu = menu.concat([{
-				icon: 'ti ti-link-off',
-				text: i18n.ts.breakFollow,
-				action: invalidateFollow,
-			}]);
-		}
-
-		menu = menu.concat([null, {
-			icon: 'ti ti-exclamation-circle',
-			text: i18n.ts.reportAbuse,
-			action: reportAbuse,
-		}]);
-
-		if (iAmModerator) {
-			menu = menu.concat([null, {
-				icon: 'ti ti-microphone-2-off',
-				text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
-				action: toggleSilence,
-			}, {
-				icon: 'ti ti-snowflake',
-				text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
-				action: toggleSuspend,
-			}]);
-		}
-	}
-
-	if ($i && meId === user.id) {
-		menu = menu.concat([null, {
-			icon: 'ti ti-pencil',
-			text: i18n.ts.editProfile,
-			action: () => {
-				router.push('/settings/profile');
-			},
-		}]);
-	}
-
-	if (userActions.length > 0) {
-		menu = menu.concat([null, ...userActions.map(action => ({
-			icon: 'ti ti-plug',
-			text: action.title,
-			action: () => {
-				action.handler(user);
-			},
-		}))]);
-	}
-
-	return menu;
-}
diff --git a/packages/client/src/scripts/get-user-name.ts b/packages/client/src/scripts/get-user-name.ts
deleted file mode 100644
index d499ea0203..0000000000
--- a/packages/client/src/scripts/get-user-name.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function(user: { name?: string | null, username: string }): string {
-	return user.name || user.username;
-}
diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts
deleted file mode 100644
index 4a0ded637d..0000000000
--- a/packages/client/src/scripts/hotkey.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import keyCode from './keycode';
-
-type Callback = (ev: KeyboardEvent) => void;
-
-type Keymap = Record<string, Callback>;
-
-type Pattern = {
-	which: string[];
-	ctrl?: boolean;
-	shift?: boolean;
-	alt?: boolean;
-};
-
-type Action = {
-	patterns: Pattern[];
-	callback: Callback;
-	allowRepeat: boolean;
-};
-
-const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
-	const result = {
-		patterns: [],
-		callback,
-		allowRepeat: true,
-	} as Action;
-
-	if (patterns.match(/^\(.*\)$/) !== null) {
-		result.allowRepeat = false;
-		patterns = patterns.slice(1, -1);
-	}
-
-	result.patterns = patterns.split('|').map(part => {
-		const pattern = {
-			which: [],
-			ctrl: false,
-			alt: false,
-			shift: false,
-		} as Pattern;
-
-		const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
-		for (const key of keys) {
-			switch (key) {
-				case 'ctrl': pattern.ctrl = true; break;
-				case 'alt': pattern.alt = true; break;
-				case 'shift': pattern.shift = true; break;
-				default: pattern.which = keyCode(key).map(k => k.toLowerCase());
-			}
-		}
-
-		return pattern;
-	});
-
-	return result;
-});
-
-const ignoreElemens = ['input', 'textarea'];
-
-function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
-	const key = ev.code.toLowerCase();
-	return patterns.some(pattern => pattern.which.includes(key) &&
-		pattern.ctrl === ev.ctrlKey &&
-		pattern.shift === ev.shiftKey &&
-		pattern.alt === ev.altKey &&
-		!ev.metaKey,
-	);
-}
-
-export const makeHotkey = (keymap: Keymap) => {
-	const actions = parseKeymap(keymap);
-
-	return (ev: KeyboardEvent) => {
-		if (document.activeElement) {
-			if (ignoreElemens.some(el => document.activeElement!.matches(el))) return;
-			if (document.activeElement.attributes['contenteditable']) return;
-		}
-
-		for (const action of actions) {
-			const matched = match(ev, action.patterns);
-
-			if (matched) {
-				if (!action.allowRepeat && ev.repeat) return;
-
-				ev.preventDefault();
-				ev.stopPropagation();
-				action.callback(ev);
-				break;
-			}
-		}
-	};
-};
diff --git a/packages/client/src/scripts/hpml/block.ts b/packages/client/src/scripts/hpml/block.ts
deleted file mode 100644
index 804c5c1124..0000000000
--- a/packages/client/src/scripts/hpml/block.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// blocks
-
-export type BlockBase = {
-	id: string;
-	type: string;
-};
-
-export type TextBlock = BlockBase & {
-	type: 'text';
-	text: string;
-};
-
-export type SectionBlock = BlockBase & {
-	type: 'section';
-	title: string;
-	children: (Block | VarBlock)[];
-};
-
-export type ImageBlock = BlockBase & {
-	type: 'image';
-	fileId: string | null;
-};
-
-export type ButtonBlock = BlockBase & {
-	type: 'button';
-	text: any;
-	primary: boolean;
-	action: string;
-	content: string;
-	event: string;
-	message: string;
-	var: string;
-	fn: string;
-};
-
-export type IfBlock = BlockBase & {
-	type: 'if';
-	var: string;
-	children: Block[];
-};
-
-export type TextareaBlock = BlockBase & {
-	type: 'textarea';
-	text: string;
-};
-
-export type PostBlock = BlockBase & {
-	type: 'post';
-	text: string;
-	attachCanvasImage: boolean;
-	canvasId: string;
-};
-
-export type CanvasBlock = BlockBase & {
-	type: 'canvas';
-	name: string; // canvas id
-	width: number;
-	height: number;
-};
-
-export type NoteBlock = BlockBase & {
-	type: 'note';
-	detailed: boolean;
-	note: string | null;
-};
-
-export type Block =
-	TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock;
-
-// variable blocks
-
-export type VarBlockBase = BlockBase & {
-	name: string;
-};
-
-export type NumberInputVarBlock = VarBlockBase & {
-	type: 'numberInput';
-	text: string;
-};
-
-export type TextInputVarBlock = VarBlockBase & {
-	type: 'textInput';
-	text: string;
-};
-
-export type SwitchVarBlock = VarBlockBase & {
-	type: 'switch';
-	text: string;
-};
-
-export type RadioButtonVarBlock = VarBlockBase & {
-	type: 'radioButton';
-	title: string;
-	values: string[];
-};
-
-export type CounterVarBlock = VarBlockBase & {
-	type: 'counter';
-	text: string;
-	inc: number;
-};
-
-export type VarBlock =
-	NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock;
-
-const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter'];
-export function isVarBlock(block: Block): block is VarBlock {
-	return varBlock.includes(block.type);
-}
diff --git a/packages/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts
deleted file mode 100644
index 196b3142a1..0000000000
--- a/packages/client/src/scripts/hpml/evaluator.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-import autobind from 'autobind-decorator';
-import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.';
-import { version } from '@/config';
-import { AiScript, utils, values } from '@syuilo/aiscript';
-import { createAiScriptEnv } from '../aiscript/api';
-import { collectPageVars } from '../collect-page-vars';
-import { initHpmlLib, initAiLib } from './lib';
-import * as os from '@/os';
-import { markRaw, ref, Ref, unref } from 'vue';
-import { Expr, isLiteralValue, Variable } from './expr';
-
-/**
- * Hpml evaluator
- */
-export class Hpml {
-	private variables: Variable[];
-	private pageVars: PageVar[];
-	private envVars: Record<keyof typeof envVarsDef, any>;
-	public aiscript?: AiScript;
-	public pageVarUpdatedCallback?: values.VFn;
-	public canvases: Record<string, HTMLCanvasElement> = {};
-	public vars: Ref<Record<string, any>> = ref({});
-	public page: Record<string, any>;
-
-	private opts: {
-		randomSeed: string; visitor?: any; url?: string;
-		enableAiScript: boolean;
-	};
-
-	constructor(page: Hpml['page'], opts: Hpml['opts']) {
-		this.page = page;
-		this.variables = this.page.variables;
-		this.pageVars = collectPageVars(this.page.content);
-		this.opts = opts;
-
-		if (this.opts.enableAiScript) {
-			this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({
-				storageKey: 'pages:' + this.page.id,
-			}), ...initAiLib(this) }, {
-				in: (q) => {
-					return new Promise(ok => {
-						os.inputText({
-							title: q,
-						}).then(({ canceled, result: a }) => {
-							ok(a);
-						});
-					});
-				},
-				out: (value) => {
-					console.log(value);
-				},
-				log: (type, params) => {
-				},
-			}));
-
-			this.aiscript.scope.opts.onUpdated = (name, value) => {
-				this.eval();
-			};
-		}
-
-		const date = new Date();
-
-		this.envVars = {
-			AI: 'kawaii',
-			VERSION: version,
-			URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '',
-			LOGIN: opts.visitor != null,
-			NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '',
-			USERNAME: opts.visitor ? opts.visitor.username : '',
-			USERID: opts.visitor ? opts.visitor.id : '',
-			NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
-			FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
-			FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
-			IS_CAT: opts.visitor ? opts.visitor.isCat : false,
-			SEED: opts.randomSeed ? opts.randomSeed : '',
-			YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
-			AISCRIPT_DISABLED: !this.opts.enableAiScript,
-			NULL: null,
-		};
-
-		this.eval();
-	}
-
-	@autobind
-	public eval() {
-		try {
-			this.vars.value = this.evaluateVars();
-		} catch (err) {
-			//this.onError(e);
-		}
-	}
-
-	@autobind
-	public interpolate(str: string) {
-		if (str == null) return null;
-		return str.replace(/{(.+?)}/g, match => {
-			const v = unref(this.vars)[match.slice(1, -1).trim()];
-			return v == null ? 'NULL' : v.toString();
-		});
-	}
-
-	@autobind
-	public callAiScript(fn: string) {
-		try {
-			if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []);
-		} catch (err) {}
-	}
-
-	@autobind
-	public registerCanvas(id: string, canvas: any) {
-		this.canvases[id] = canvas;
-	}
-
-	@autobind
-	public updatePageVar(name: string, value: any) {
-		const pageVar = this.pageVars.find(v => v.name === name);
-		if (pageVar !== undefined) {
-			pageVar.value = value;
-			if (this.pageVarUpdatedCallback) {
-				if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]);
-			}
-		} else {
-			throw new HpmlError(`No such page var '${name}'`);
-		}
-	}
-
-	@autobind
-	public updateRandomSeed(seed: string) {
-		this.opts.randomSeed = seed;
-		this.envVars.SEED = seed;
-	}
-
-	@autobind
-	private _interpolateScope(str: string, scope: HpmlScope) {
-		return str.replace(/{(.+?)}/g, match => {
-			const v = scope.getState(match.slice(1, -1).trim());
-			return v == null ? 'NULL' : v.toString();
-		});
-	}
-
-	@autobind
-	public evaluateVars(): Record<string, any> {
-		const values: Record<string, any> = {};
-
-		for (const [k, v] of Object.entries(this.envVars)) {
-			values[k] = v;
-		}
-
-		for (const v of this.pageVars) {
-			values[v.name] = v.value;
-		}
-
-		for (const v of this.variables) {
-			values[v.name] = this.evaluate(v, new HpmlScope([values]));
-		}
-
-		return values;
-	}
-
-	@autobind
-	private evaluate(expr: Expr, scope: HpmlScope): any {
-		if (isLiteralValue(expr)) {
-			if (expr.type === null) {
-				return null;
-			}
-
-			if (expr.type === 'number') {
-				return parseInt((expr.value as any), 10);
-			}
-
-			if (expr.type === 'text' || expr.type === 'multiLineText') {
-				return this._interpolateScope(expr.value || '', scope);
-			}
-
-			if (expr.type === 'textList') {
-				return this._interpolateScope(expr.value || '', scope).trim().split('\n');
-			}
-
-			if (expr.type === 'ref') {
-				return scope.getState(expr.value);
-			}
-
-			if (expr.type === 'aiScriptVar') {
-				if (this.aiscript) {
-					try {
-						return utils.valToJs(this.aiscript.scope.get(expr.value));
-					} catch (err) {
-						return null;
-					}
-				} else {
-					return null;
-				}
-			}
-
-			// Define user function
-			if (expr.type === 'fn') {
-				return {
-					slots: expr.value.slots.map(x => x.name),
-					exec: (slotArg: Record<string, any>) => {
-						return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id));
-					},
-				} as Fn;
-			}
-			return;
-		}
-
-		// Call user function
-		if (expr.type.startsWith('fn:')) {
-			const fnName = expr.type.split(':')[1];
-			const fn = scope.getState(fnName);
-			const args = {} as Record<string, any>;
-			for (let i = 0; i < fn.slots.length; i++) {
-				const name = fn.slots[i];
-				args[name] = this.evaluate(expr.args[i], scope);
-			}
-			return fn.exec(args);
-		}
-
-		if (expr.args === undefined) return null;
-
-		const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor);
-
-		// Call function
-		const fnName = expr.type;
-		const fn = (funcs as any)[fnName];
-		if (fn == null) {
-			throw new HpmlError(`No such function '${fnName}'`);
-		} else {
-			return fn(...expr.args.map(x => this.evaluate(x, scope)));
-		}
-	}
-}
diff --git a/packages/client/src/scripts/hpml/expr.ts b/packages/client/src/scripts/hpml/expr.ts
deleted file mode 100644
index 18c7c2a14b..0000000000
--- a/packages/client/src/scripts/hpml/expr.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { literalDefs, Type } from '.';
-
-export type ExprBase = {
-	id: string;
-};
-
-// value
-
-export type EmptyValue = ExprBase & {
-	type: null;
-	value: null;
-};
-
-export type TextValue = ExprBase & {
-	type: 'text';
-	value: string;
-};
-
-export type MultiLineTextValue = ExprBase & {
-	type: 'multiLineText';
-	value: string;
-};
-
-export type TextListValue = ExprBase & {
-	type: 'textList';
-	value: string;
-};
-
-export type NumberValue = ExprBase & {
-	type: 'number';
-	value: number;
-};
-
-export type RefValue = ExprBase & {
-	type: 'ref';
-	value: string; // value is variable name
-};
-
-export type AiScriptRefValue = ExprBase & {
-	type: 'aiScriptVar';
-	value: string; // value is variable name
-};
-
-export type UserFnValue = ExprBase & {
-	type: 'fn';
-	value: UserFnInnerValue;
-};
-type UserFnInnerValue = {
-	slots: {
-		name: string;
-		type: Type;
-	}[];
-	expression: Expr;
-};
-
-export type Value =
-	EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue;
-
-export function isLiteralValue(expr: Expr): expr is Value {
-	if (expr.type == null) return true;
-	if (literalDefs[expr.type]) return true;
-	return false;
-}
-
-// call function
-
-export type CallFn = ExprBase & { // "fn:hoge" or string
-	type: string;
-	args: Expr[];
-	value: null;
-};
-
-// variable
-export type Variable = (Value | CallFn) & {
-	name: string;
-};
-
-// expression
-export type Expr = Variable | Value | CallFn;
diff --git a/packages/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts
deleted file mode 100644
index 9a55a5c286..0000000000
--- a/packages/client/src/scripts/hpml/index.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Hpml
- */
-
-import autobind from 'autobind-decorator';
-import { Hpml } from './evaluator';
-import { funcDefs } from './lib';
-
-export type Fn = {
-	slots: string[];
-	exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>;
-};
-
-export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
-
-export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
-	text: { out: 'string', category: 'value', icon: 'ti ti-quote' },
-	multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' },
-	textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' },
-	number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' },
-	ref: { out: null, category: 'value', icon: 'fas fa-magic' },
-	aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' },
-	fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' },
-};
-
-export const blockDefs = [
-	...Object.entries(literalDefs).map(([k, v]) => ({
-		type: k, out: v.out, category: v.category, icon: v.icon,
-	})),
-	...Object.entries(funcDefs).map(([k, v]) => ({
-		type: k, out: v.out, category: v.category, icon: v.icon,
-	})),
-];
-
-export type PageVar = { name: string; value: any; type: Type; };
-
-export const envVarsDef: Record<string, Type> = {
-	AI: 'string',
-	URL: 'string',
-	VERSION: 'string',
-	LOGIN: 'boolean',
-	NAME: 'string',
-	USERNAME: 'string',
-	USERID: 'string',
-	NOTES_COUNT: 'number',
-	FOLLOWERS_COUNT: 'number',
-	FOLLOWING_COUNT: 'number',
-	IS_CAT: 'boolean',
-	SEED: null,
-	YMD: 'string',
-	AISCRIPT_DISABLED: 'boolean',
-	NULL: null,
-};
-
-export class HpmlScope {
-	private layerdStates: Record<string, any>[];
-	public name: string;
-
-	constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
-		this.layerdStates = layerdStates;
-		this.name = name || 'anonymous';
-	}
-
-	@autobind
-	public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
-		const layer = [states, ...this.layerdStates];
-		return new HpmlScope(layer, name);
-	}
-
-	/**
-	 * 指定した名前の変数の値を取得します
-	 * @param name 変数名
-	 */
-	@autobind
-	public getState(name: string): any {
-		for (const later of this.layerdStates) {
-			const state = later[name];
-			if (state !== undefined) {
-				return state;
-			}
-		}
-
-		throw new HpmlError(
-			`No such variable '${name}' in scope '${this.name}'`, {
-				scope: this.layerdStates,
-			});
-	}
-}
-
-export class HpmlError extends Error {
-	public info?: any;
-
-	constructor(message: string, info?: any) {
-		super(message);
-
-		this.info = info;
-
-		// Maintains proper stack trace for where our error was thrown (only available on V8)
-		if (Error.captureStackTrace) {
-			Error.captureStackTrace(this, HpmlError);
-		}
-	}
-}
diff --git a/packages/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts
deleted file mode 100644
index b684876a7f..0000000000
--- a/packages/client/src/scripts/hpml/lib.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-import tinycolor from 'tinycolor2';
-import { Hpml } from './evaluator';
-import { values, utils } from '@syuilo/aiscript';
-import { Fn, HpmlScope } from '.';
-import { Expr } from './expr';
-import seedrandom from 'seedrandom';
-
-/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
-// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
-Chart.pluginService.register({
-	beforeDraw: (chart, easing) => {
-		if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
-			const ctx = chart.chart.ctx;
-			ctx.save();
-			ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
-			ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
-			ctx.restore();
-		}
-	}
-});
-*/
-
-export function initAiLib(hpml: Hpml) {
-	return {
-		'MkPages:updated': values.FN_NATIVE(([callback]) => {
-			hpml.pageVarUpdatedCallback = (callback as values.VFn);
-		}),
-		'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
-			utils.assertString(id);
-			const canvas = hpml.canvases[id.value];
-			const ctx = canvas.getContext('2d');
-			return values.OBJ(new Map([
-				['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })],
-				['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })],
-				['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })],
-				['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })],
-				['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })],
-				['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })],
-				['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })],
-				['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })],
-				['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })],
-				['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })],
-				['close_path', values.FN_NATIVE(() => { ctx.closePath(); })],
-				['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })],
-				['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })],
-				['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })],
-				['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })],
-				['fill', values.FN_NATIVE(() => { ctx.fill(); })],
-				['stroke', values.FN_NATIVE(() => { ctx.stroke(); })],
-			]));
-		}),
-		'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
-			/* TODO
-			utils.assertString(id);
-			utils.assertObject(opts);
-			const canvas = hpml.canvases[id.value];
-			const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
-			Chart.defaults.color = '#555';
-			const chart = new Chart(canvas, {
-				type: opts.value.get('type').value,
-				data: {
-					labels: opts.value.get('labels').value.map(x => x.value),
-					datasets: opts.value.get('datasets').value.map(x => ({
-						label: x.value.has('label') ? x.value.get('label').value : '',
-						data: x.value.get('data').value.map(x => x.value),
-						pointRadius: 0,
-						lineTension: 0,
-						borderWidth: 2,
-						borderColor: x.value.has('color') ? x.value.get('color') : color,
-						backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
-					}))
-				},
-				options: {
-					responsive: false,
-					devicePixelRatio: 1.5,
-					title: {
-						display: opts.value.has('title'),
-						text: opts.value.has('title') ? opts.value.get('title').value : '',
-						fontSize: 14,
-					},
-					layout: {
-						padding: {
-							left: 32,
-							right: 32,
-							top: opts.value.has('title') ? 16 : 32,
-							bottom: 16
-						}
-					},
-					legend: {
-						display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
-						position: 'bottom',
-						labels: {
-							boxWidth: 16,
-						}
-					},
-					tooltips: {
-						enabled: false,
-					},
-					chartArea: {
-						backgroundColor: '#fff'
-					},
-					...(opts.value.get('type').value === 'radar' ? {
-						scale: {
-							ticks: {
-								display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
-								min: opts.value.has('min') ? opts.value.get('min').value : undefined,
-								max: opts.value.has('max') ? opts.value.get('max').value : undefined,
-								maxTicksLimit: 8,
-							},
-							pointLabels: {
-								fontSize: 12
-							}
-						}
-					} : {
-						scales: {
-							yAxes: [{
-								ticks: {
-									display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
-									min: opts.value.has('min') ? opts.value.get('min').value : undefined,
-									max: opts.value.has('max') ? opts.value.get('max').value : undefined,
-								}
-							}]
-						}
-					})
-				}
-			});
-			*/
-		}),
-	};
-}
-
-export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
-	if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' },
-	for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' },
-	not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
-	or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
-	and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
-	add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' },
-	subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' },
-	multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' },
-	divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
-	mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
-	round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' },
-	eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' },
-	notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' },
-	gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' },
-	lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' },
-	gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' },
-	ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' },
-	strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' },
-	strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' },
-	strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
-	strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
-	join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
-	stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' },
-	numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' },
-	splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' },
-	pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' },
-	listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' },
-	rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
-	dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
-	seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
-	random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
-	dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
-	seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
-	randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
-	dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
-	seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' },
-	DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping
-};
-
-export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) {
-	const date = new Date();
-	const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
-
-	// SHOULD be fine to ignore since it's intended + function shape isn't defined
-	// eslint-disable-next-line @typescript-eslint/ban-types
-	const funcs: Record<string, Function> = {
-		not: (a: boolean) => !a,
-		or: (a: boolean, b: boolean) => a || b,
-		and: (a: boolean, b: boolean) => a && b,
-		eq: (a: any, b: any) => a === b,
-		notEq: (a: any, b: any) => a !== b,
-		gt: (a: number, b: number) => a > b,
-		lt: (a: number, b: number) => a < b,
-		gtEq: (a: number, b: number) => a >= b,
-		ltEq: (a: number, b: number) => a <= b,
-		if: (bool: boolean, a: any, b: any) => bool ? a : b,
-		for: (times: number, fn: Fn) => {
-			const result: any[] = [];
-			for (let i = 0; i < times; i++) {
-				result.push(fn.exec({
-					[fn.slots[0]]: i + 1,
-				}));
-			}
-			return result;
-		},
-		add: (a: number, b: number) => a + b,
-		subtract: (a: number, b: number) => a - b,
-		multiply: (a: number, b: number) => a * b,
-		divide: (a: number, b: number) => a / b,
-		mod: (a: number, b: number) => a % b,
-		round: (a: number) => Math.round(a),
-		strLen: (a: string) => a.length,
-		strPick: (a: string, b: number) => a[b - 1],
-		strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
-		strReverse: (a: string) => a.split('').reverse().join(''),
-		join: (texts: string[], separator: string) => texts.join(separator || ''),
-		stringToNumber: (a: string) => parseInt(a),
-		numberToString: (a: number) => a.toString(),
-		splitStrByLine: (a: string) => a.split('\n'),
-		pick: (list: any[], i: number) => list[i - 1],
-		listLen: (list: any[]) => list.length,
-		random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability,
-		rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)),
-		randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)],
-		dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
-		dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
-		dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
-		seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability,
-		seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)),
-		seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)],
-		DRPWPM: (list: string[]) => {
-			const xs: any[] = [];
-			let totalFactor = 0;
-			for (const x of list) {
-				const parts = x.split(' ');
-				const factor = parseInt(parts.pop()!, 10);
-				const text = parts.join(' ');
-				totalFactor += factor;
-				xs.push({ factor, text });
-			}
-			const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
-			let stackedFactor = 0;
-			for (const x of xs) {
-				if (r >= stackedFactor && r <= stackedFactor + x.factor) {
-					return x.text;
-				} else {
-					stackedFactor += x.factor;
-				}
-			}
-			return xs[0].text;
-		},
-	};
-
-	return funcs;
-}
diff --git a/packages/client/src/scripts/hpml/type-checker.ts b/packages/client/src/scripts/hpml/type-checker.ts
deleted file mode 100644
index 24c9ed8bcb..0000000000
--- a/packages/client/src/scripts/hpml/type-checker.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import autobind from 'autobind-decorator';
-import { isLiteralValue } from './expr';
-import { funcDefs } from './lib';
-import { envVarsDef } from '.';
-import type { Type, PageVar } from '.';
-import type { Expr, Variable } from './expr';
-
-type TypeError = {
-	arg: number;
-	expect: Type;
-	actual: Type;
-};
-
-/**
- * Hpml type checker
- */
-export class HpmlTypeChecker {
-	public variables: Variable[];
-	public pageVars: PageVar[];
-
-	constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
-		this.variables = variables;
-		this.pageVars = pageVars;
-	}
-
-	@autobind
-	public typeCheck(v: Expr): TypeError | null {
-		if (isLiteralValue(v)) return null;
-
-		const def = funcDefs[v.type || ''];
-		if (def == null) {
-			throw new Error('Unknown type: ' + v.type);
-		}
-
-		const generic: Type[] = [];
-
-		for (let i = 0; i < def.in.length; i++) {
-			const arg = def.in[i];
-			const type = this.infer(v.args[i]);
-			if (type === null) continue;
-
-			if (typeof arg === 'number') {
-				if (generic[arg] === undefined) {
-					generic[arg] = type;
-				} else if (type !== generic[arg]) {
-					return {
-						arg: i,
-						expect: generic[arg],
-						actual: type,
-					};
-				}
-			} else if (type !== arg) {
-				return {
-					arg: i,
-					expect: arg,
-					actual: type,
-				};
-			}
-		}
-
-		return null;
-	}
-
-	@autobind
-	public getExpectedType(v: Expr, slot: number): Type {
-		const def = funcDefs[v.type || ''];
-		if (def == null) {
-			throw new Error('Unknown type: ' + v.type);
-		}
-
-		const generic: Type[] = [];
-
-		for (let i = 0; i < def.in.length; i++) {
-			const arg = def.in[i];
-			const type = this.infer(v.args[i]);
-			if (type === null) continue;
-
-			if (typeof arg === 'number') {
-				if (generic[arg] === undefined) {
-					generic[arg] = type;
-				}
-			}
-		}
-
-		if (typeof def.in[slot] === 'number') {
-			return generic[def.in[slot]] ?? null;
-		} else {
-			return def.in[slot];
-		}
-	}
-
-	@autobind
-	public infer(v: Expr): Type {
-		if (v.type === null) return null;
-		if (v.type === 'text') return 'string';
-		if (v.type === 'multiLineText') return 'string';
-		if (v.type === 'textList') return 'stringArray';
-		if (v.type === 'number') return 'number';
-		if (v.type === 'ref') {
-			const variable = this.variables.find(va => va.name === v.value);
-			if (variable) {
-				return this.infer(variable);
-			}
-
-			const pageVar = this.pageVars.find(va => va.name === v.value);
-			if (pageVar) {
-				return pageVar.type;
-			}
-
-			const envVar = envVarsDef[v.value || ''];
-			if (envVar !== undefined) {
-				return envVar;
-			}
-
-			return null;
-		}
-		if (v.type === 'aiScriptVar') return null;
-		if (v.type === 'fn') return null; // todo
-		if (v.type.startsWith('fn:')) return null; // todo
-
-		const generic: Type[] = [];
-
-		const def = funcDefs[v.type];
-
-		for (let i = 0; i < def.in.length; i++) {
-			const arg = def.in[i];
-			if (typeof arg === 'number') {
-				const type = this.infer(v.args[i]);
-
-				if (generic[arg] === undefined) {
-					generic[arg] = type;
-				} else {
-					if (type !== generic[arg]) {
-						generic[arg] = null;
-					}
-				}
-			}
-		}
-
-		if (typeof def.out === 'number') {
-			return generic[def.out];
-		} else {
-			return def.out;
-		}
-	}
-
-	@autobind
-	public getVarByName(name: string): Variable {
-		const v = this.variables.find(x => x.name === name);
-		if (v !== undefined) {
-			return v;
-		} else {
-			throw new Error(`No such variable '${name}'`);
-		}
-	}
-
-	@autobind
-	public getVarsByType(type: Type): Variable[] {
-		if (type == null) return this.variables;
-		return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
-	}
-
-	@autobind
-	public getEnvVarsByType(type: Type): string[] {
-		if (type == null) return Object.keys(envVarsDef);
-		return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
-	}
-
-	@autobind
-	public getPageVarsByType(type: Type): string[] {
-		if (type == null) return this.pageVars.map(v => v.name);
-		return this.pageVars.filter(v => type === v.type).map(v => v.name);
-	}
-
-	@autobind
-	public isUsedName(name: string) {
-		if (this.variables.some(v => v.name === name)) {
-			return true;
-		}
-
-		if (this.pageVars.some(v => v.name === name)) {
-			return true;
-		}
-
-		if (envVarsDef[name]) {
-			return true;
-		}
-
-		return false;
-	}
-}
diff --git a/packages/client/src/scripts/i18n.ts b/packages/client/src/scripts/i18n.ts
deleted file mode 100644
index 54184386da..0000000000
--- a/packages/client/src/scripts/i18n.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export class I18n<T extends Record<string, any>> {
-	public ts: T;
-
-	constructor(locale: T) {
-		this.ts = locale;
-
-		//#region BIND
-		this.t = this.t.bind(this);
-		//#endregion
-	}
-
-	// string にしているのは、ドット区切りでのパス指定を許可するため
-	// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
-	public t(key: string, args?: Record<string, string | number>): string {
-		try {
-			let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
-
-			if (args) {
-				for (const [k, v] of Object.entries(args)) {
-					str = str.replace(`{${k}}`, v.toString());
-				}
-			}
-			return str;
-		} catch (err) {
-			console.warn(`missing localization '${key}'`);
-			return key;
-		}
-	}
-}
diff --git a/packages/client/src/scripts/idb-proxy.ts b/packages/client/src/scripts/idb-proxy.ts
deleted file mode 100644
index 77bb84463c..0000000000
--- a/packages/client/src/scripts/idb-proxy.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
-// indexedDBが使えない環境ではlocalStorageを使う
-import {
-	get as iget,
-	set as iset,
-	del as idel,
-} from 'idb-keyval';
-
-const fallbackName = (key: string) => `idbfallback::${key}`;
-
-let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true;
-
-if (idbAvailable) {
-	iset('idb-test', 'test').catch(err => {
-		console.error('idb error', err);
-		console.error('indexedDB is unavailable. It will use localStorage.');
-		idbAvailable = false;
-	});
-} else {
-	console.error('indexedDB is unavailable. It will use localStorage.');
-}
-
-export async function get(key: string) {
-	if (idbAvailable) return iget(key);
-	return JSON.parse(localStorage.getItem(fallbackName(key)));
-}
-
-export async function set(key: string, val: any) {
-	if (idbAvailable) return iset(key, val);
-	return localStorage.setItem(fallbackName(key), JSON.stringify(val));
-}
-
-export async function del(key: string) {
-	if (idbAvailable) return idel(key);
-	return localStorage.removeItem(fallbackName(key));
-}
diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts
deleted file mode 100644
index de52f30523..0000000000
--- a/packages/client/src/scripts/initialize-sw.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { lang } from '@/config';
-
-export async function initializeSw() {
-	if (!('serviceWorker' in navigator)) return;
-
-	navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' });
-	navigator.serviceWorker.ready.then(registration => {
-		registration.active?.postMessage({
-			msg: 'initialize',
-			lang,
-		});
-	});
-}
diff --git a/packages/client/src/scripts/is-device-darkmode.ts b/packages/client/src/scripts/is-device-darkmode.ts
deleted file mode 100644
index 854f38e517..0000000000
--- a/packages/client/src/scripts/is-device-darkmode.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function isDeviceDarkmode() {
-	return window.matchMedia('(prefers-color-scheme: dark)').matches;
-}
diff --git a/packages/client/src/scripts/keycode.ts b/packages/client/src/scripts/keycode.ts
deleted file mode 100644
index 69f6a82803..0000000000
--- a/packages/client/src/scripts/keycode.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export default (input: string): string[] => {
-	if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) {
-		const codes = aliases[input];
-		return Array.isArray(codes) ? codes : [codes];
-	} else {
-		return [input];
-	}
-};
-
-export const aliases = {
-	'esc': 'Escape',
-	'enter': ['Enter', 'NumpadEnter'],
-	'up': 'ArrowUp',
-	'down': 'ArrowDown',
-	'left': 'ArrowLeft',
-	'right': 'ArrowRight',
-	'plus': ['NumpadAdd', 'Semicolon'],
-};
-
-/*!
-* Programmatically add the following
-*/
-
-// lower case chars
-for (let i = 97; i < 123; i++) {
-	const char = String.fromCharCode(i);
-	aliases[char] = `Key${char.toUpperCase()}`;
-}
-
-// numbers
-for (let i = 0; i < 10; i++) {
-	aliases[i] = [`Numpad${i}`, `Digit${i}`];
-}
diff --git a/packages/client/src/scripts/langmap.ts b/packages/client/src/scripts/langmap.ts
deleted file mode 100644
index 25f5b366c8..0000000000
--- a/packages/client/src/scripts/langmap.ts
+++ /dev/null
@@ -1,666 +0,0 @@
-// TODO: sharedに置いてバックエンドのと統合したい
-export const langmap = {
-	'ach': {
-		nativeName: 'Lwo',
-	},
-	'ady': {
-		nativeName: 'Адыгэбзэ',
-	},
-	'af': {
-		nativeName: 'Afrikaans',
-	},
-	'af-NA': {
-		nativeName: 'Afrikaans (Namibia)',
-	},
-	'af-ZA': {
-		nativeName: 'Afrikaans (South Africa)',
-	},
-	'ak': {
-		nativeName: 'Tɕɥi',
-	},
-	'ar': {
-		nativeName: 'العربية',
-	},
-	'ar-AR': {
-		nativeName: 'العربية',
-	},
-	'ar-MA': {
-		nativeName: 'العربية',
-	},
-	'ar-SA': {
-		nativeName: 'العربية (السعودية)',
-	},
-	'ay-BO': {
-		nativeName: 'Aymar aru',
-	},
-	'az': {
-		nativeName: 'Azərbaycan dili',
-	},
-	'az-AZ': {
-		nativeName: 'Azərbaycan dili',
-	},
-	'be-BY': {
-		nativeName: 'Беларуская',
-	},
-	'bg': {
-		nativeName: 'Български',
-	},
-	'bg-BG': {
-		nativeName: 'Български',
-	},
-	'bn': {
-		nativeName: 'বাংলা',
-	},
-	'bn-IN': {
-		nativeName: 'বাংলা (ভারত)',
-	},
-	'bn-BD': {
-		nativeName: 'বাংলা(বাংলাদেশ)',
-	},
-	'br': {
-		nativeName: 'Brezhoneg',
-	},
-	'bs-BA': {
-		nativeName: 'Bosanski',
-	},
-	'ca': {
-		nativeName: 'Català',
-	},
-	'ca-ES': {
-		nativeName: 'Català',
-	},
-	'cak': {
-		nativeName: 'Maya Kaqchikel',
-	},
-	'ck-US': {
-		nativeName: 'ᏣᎳᎩ (tsalagi)',
-	},
-	'cs': {
-		nativeName: 'Čeština',
-	},
-	'cs-CZ': {
-		nativeName: 'Čeština',
-	},
-	'cy': {
-		nativeName: 'Cymraeg',
-	},
-	'cy-GB': {
-		nativeName: 'Cymraeg',
-	},
-	'da': {
-		nativeName: 'Dansk',
-	},
-	'da-DK': {
-		nativeName: 'Dansk',
-	},
-	'de': {
-		nativeName: 'Deutsch',
-	},
-	'de-AT': {
-		nativeName: 'Deutsch (Österreich)',
-	},
-	'de-DE': {
-		nativeName: 'Deutsch (Deutschland)',
-	},
-	'de-CH': {
-		nativeName: 'Deutsch (Schweiz)',
-	},
-	'dsb': {
-		nativeName: 'Dolnoserbšćina',
-	},
-	'el': {
-		nativeName: 'Ελληνικά',
-	},
-	'el-GR': {
-		nativeName: 'Ελληνικά',
-	},
-	'en': {
-		nativeName: 'English',
-	},
-	'en-GB': {
-		nativeName: 'English (UK)',
-	},
-	'en-AU': {
-		nativeName: 'English (Australia)',
-	},
-	'en-CA': {
-		nativeName: 'English (Canada)',
-	},
-	'en-IE': {
-		nativeName: 'English (Ireland)',
-	},
-	'en-IN': {
-		nativeName: 'English (India)',
-	},
-	'en-PI': {
-		nativeName: 'English (Pirate)',
-	},
-	'en-SG': {
-		nativeName: 'English (Singapore)',
-	},
-	'en-UD': {
-		nativeName: 'English (Upside Down)',
-	},
-	'en-US': {
-		nativeName: 'English (US)',
-	},
-	'en-ZA': {
-		nativeName: 'English (South Africa)',
-	},
-	'en@pirate': {
-		nativeName: 'English (Pirate)',
-	},
-	'eo': {
-		nativeName: 'Esperanto',
-	},
-	'eo-EO': {
-		nativeName: 'Esperanto',
-	},
-	'es': {
-		nativeName: 'Español',
-	},
-	'es-AR': {
-		nativeName: 'Español (Argentine)',
-	},
-	'es-419': {
-		nativeName: 'Español (Latinoamérica)',
-	},
-	'es-CL': {
-		nativeName: 'Español (Chile)',
-	},
-	'es-CO': {
-		nativeName: 'Español (Colombia)',
-	},
-	'es-EC': {
-		nativeName: 'Español (Ecuador)',
-	},
-	'es-ES': {
-		nativeName: 'Español (España)',
-	},
-	'es-LA': {
-		nativeName: 'Español (Latinoamérica)',
-	},
-	'es-NI': {
-		nativeName: 'Español (Nicaragua)',
-	},
-	'es-MX': {
-		nativeName: 'Español (México)',
-	},
-	'es-US': {
-		nativeName: 'Español (Estados Unidos)',
-	},
-	'es-VE': {
-		nativeName: 'Español (Venezuela)',
-	},
-	'et': {
-		nativeName: 'eesti keel',
-	},
-	'et-EE': {
-		nativeName: 'Eesti (Estonia)',
-	},
-	'eu': {
-		nativeName: 'Euskara',
-	},
-	'eu-ES': {
-		nativeName: 'Euskara',
-	},
-	'fa': {
-		nativeName: 'فارسی',
-	},
-	'fa-IR': {
-		nativeName: 'فارسی',
-	},
-	'fb-LT': {
-		nativeName: 'Leet Speak',
-	},
-	'ff': {
-		nativeName: 'Fulah',
-	},
-	'fi': {
-		nativeName: 'Suomi',
-	},
-	'fi-FI': {
-		nativeName: 'Suomi',
-	},
-	'fo': {
-		nativeName: 'Føroyskt',
-	},
-	'fo-FO': {
-		nativeName: 'Føroyskt (Færeyjar)',
-	},
-	'fr': {
-		nativeName: 'Français',
-	},
-	'fr-CA': {
-		nativeName: 'Français (Canada)',
-	},
-	'fr-FR': {
-		nativeName: 'Français (France)',
-	},
-	'fr-BE': {
-		nativeName: 'Français (Belgique)',
-	},
-	'fr-CH': {
-		nativeName: 'Français (Suisse)',
-	},
-	'fy-NL': {
-		nativeName: 'Frysk',
-	},
-	'ga': {
-		nativeName: 'Gaeilge',
-	},
-	'ga-IE': {
-		nativeName: 'Gaeilge',
-	},
-	'gd': {
-		nativeName: 'Gàidhlig',
-	},
-	'gl': {
-		nativeName: 'Galego',
-	},
-	'gl-ES': {
-		nativeName: 'Galego',
-	},
-	'gn-PY': {
-		nativeName: 'Avañe\'ẽ',
-	},
-	'gu-IN': {
-		nativeName: 'ગુજરાતી',
-	},
-	'gv': {
-		nativeName: 'Gaelg',
-	},
-	'gx-GR': {
-		nativeName: 'Ἑλληνική ἀρχαία',
-	},
-	'he': {
-		nativeName: 'עברית‏',
-	},
-	'he-IL': {
-		nativeName: 'עברית‏',
-	},
-	'hi': {
-		nativeName: 'हिन्दी',
-	},
-	'hi-IN': {
-		nativeName: 'हिन्दी',
-	},
-	'hr': {
-		nativeName: 'Hrvatski',
-	},
-	'hr-HR': {
-		nativeName: 'Hrvatski',
-	},
-	'hsb': {
-		nativeName: 'Hornjoserbšćina',
-	},
-	'ht': {
-		nativeName: 'Kreyòl',
-	},
-	'hu': {
-		nativeName: 'Magyar',
-	},
-	'hu-HU': {
-		nativeName: 'Magyar',
-	},
-	'hy': {
-		nativeName: 'Հայերեն',
-	},
-	'hy-AM': {
-		nativeName: 'Հայերեն (Հայաստան)',
-	},
-	'id': {
-		nativeName: 'Bahasa Indonesia',
-	},
-	'id-ID': {
-		nativeName: 'Bahasa Indonesia',
-	},
-	'is': {
-		nativeName: 'Íslenska',
-	},
-	'is-IS': {
-		nativeName: 'Íslenska (Iceland)',
-	},
-	'it': {
-		nativeName: 'Italiano',
-	},
-	'it-IT': {
-		nativeName: 'Italiano',
-	},
-	'ja': {
-		nativeName: '日本語',
-	},
-	'ja-JP': {
-		nativeName: '日本語 (日本)',
-	},
-	'jv-ID': {
-		nativeName: 'Basa Jawa',
-	},
-	'ka-GE': {
-		nativeName: 'ქართული',
-	},
-	'kk-KZ': {
-		nativeName: 'Қазақша',
-	},
-	'km': {
-		nativeName: 'ភាសាខ្មែរ',
-	},
-	'kl': {
-		nativeName: 'kalaallisut',
-	},
-	'km-KH': {
-		nativeName: 'ភាសាខ្មែរ',
-	},
-	'kab': {
-		nativeName: 'Taqbaylit',
-	},
-	'kn': {
-		nativeName: 'ಕನ್ನಡ',
-	},
-	'kn-IN': {
-		nativeName: 'ಕನ್ನಡ (India)',
-	},
-	'ko': {
-		nativeName: '한국어',
-	},
-	'ko-KR': {
-		nativeName: '한국어 (한국)',
-	},
-	'ku-TR': {
-		nativeName: 'Kurdî',
-	},
-	'kw': {
-		nativeName: 'Kernewek',
-	},
-	'la': {
-		nativeName: 'Latin',
-	},
-	'la-VA': {
-		nativeName: 'Latin',
-	},
-	'lb': {
-		nativeName: 'Lëtzebuergesch',
-	},
-	'li-NL': {
-		nativeName: 'Lèmbörgs',
-	},
-	'lt': {
-		nativeName: 'Lietuvių',
-	},
-	'lt-LT': {
-		nativeName: 'Lietuvių',
-	},
-	'lv': {
-		nativeName: 'Latviešu',
-	},
-	'lv-LV': {
-		nativeName: 'Latviešu',
-	},
-	'mai': {
-		nativeName: 'मैथिली, মৈথিলী',
-	},
-	'mg-MG': {
-		nativeName: 'Malagasy',
-	},
-	'mk': {
-		nativeName: 'Македонски',
-	},
-	'mk-MK': {
-		nativeName: 'Македонски (Македонски)',
-	},
-	'ml': {
-		nativeName: 'മലയാളം',
-	},
-	'ml-IN': {
-		nativeName: 'മലയാളം',
-	},
-	'mn-MN': {
-		nativeName: 'Монгол',
-	},
-	'mr': {
-		nativeName: 'मराठी',
-	},
-	'mr-IN': {
-		nativeName: 'मराठी',
-	},
-	'ms': {
-		nativeName: 'Bahasa Melayu',
-	},
-	'ms-MY': {
-		nativeName: 'Bahasa Melayu',
-	},
-	'mt': {
-		nativeName: 'Malti',
-	},
-	'mt-MT': {
-		nativeName: 'Malti',
-	},
-	'my': {
-		nativeName: 'ဗမာစကာ',
-	},
-	'no': {
-		nativeName: 'Norsk',
-	},
-	'nb': {
-		nativeName: 'Norsk (bokmål)',
-	},
-	'nb-NO': {
-		nativeName: 'Norsk (bokmål)',
-	},
-	'ne': {
-		nativeName: 'नेपाली',
-	},
-	'ne-NP': {
-		nativeName: 'नेपाली',
-	},
-	'nl': {
-		nativeName: 'Nederlands',
-	},
-	'nl-BE': {
-		nativeName: 'Nederlands (België)',
-	},
-	'nl-NL': {
-		nativeName: 'Nederlands (Nederland)',
-	},
-	'nn-NO': {
-		nativeName: 'Norsk (nynorsk)',
-	},
-	'oc': {
-		nativeName: 'Occitan',
-	},
-	'or-IN': {
-		nativeName: 'ଓଡ଼ିଆ',
-	},
-	'pa': {
-		nativeName: 'ਪੰਜਾਬੀ',
-	},
-	'pa-IN': {
-		nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
-	},
-	'pl': {
-		nativeName: 'Polski',
-	},
-	'pl-PL': {
-		nativeName: 'Polski',
-	},
-	'ps-AF': {
-		nativeName: 'پښتو',
-	},
-	'pt': {
-		nativeName: 'Português',
-	},
-	'pt-BR': {
-		nativeName: 'Português (Brasil)',
-	},
-	'pt-PT': {
-		nativeName: 'Português (Portugal)',
-	},
-	'qu-PE': {
-		nativeName: 'Qhichwa',
-	},
-	'rm-CH': {
-		nativeName: 'Rumantsch',
-	},
-	'ro': {
-		nativeName: 'Română',
-	},
-	'ro-RO': {
-		nativeName: 'Română',
-	},
-	'ru': {
-		nativeName: 'Русский',
-	},
-	'ru-RU': {
-		nativeName: 'Русский',
-	},
-	'sa-IN': {
-		nativeName: 'संस्कृतम्',
-	},
-	'se-NO': {
-		nativeName: 'Davvisámegiella',
-	},
-	'sh': {
-		nativeName: 'српскохрватски',
-	},
-	'si-LK': {
-		nativeName: 'සිංහල',
-	},
-	'sk': {
-		nativeName: 'Slovenčina',
-	},
-	'sk-SK': {
-		nativeName: 'Slovenčina (Slovakia)',
-	},
-	'sl': {
-		nativeName: 'Slovenščina',
-	},
-	'sl-SI': {
-		nativeName: 'Slovenščina',
-	},
-	'so-SO': {
-		nativeName: 'Soomaaliga',
-	},
-	'sq': {
-		nativeName: 'Shqip',
-	},
-	'sq-AL': {
-		nativeName: 'Shqip',
-	},
-	'sr': {
-		nativeName: 'Српски',
-	},
-	'sr-RS': {
-		nativeName: 'Српски (Serbia)',
-	},
-	'su': {
-		nativeName: 'Basa Sunda',
-	},
-	'sv': {
-		nativeName: 'Svenska',
-	},
-	'sv-SE': {
-		nativeName: 'Svenska',
-	},
-	'sw': {
-		nativeName: 'Kiswahili',
-	},
-	'sw-KE': {
-		nativeName: 'Kiswahili',
-	},
-	'ta': {
-		nativeName: 'தமிழ்',
-	},
-	'ta-IN': {
-		nativeName: 'தமிழ்',
-	},
-	'te': {
-		nativeName: 'తెలుగు',
-	},
-	'te-IN': {
-		nativeName: 'తెలుగు',
-	},
-	'tg': {
-		nativeName: 'забо́ни тоҷикӣ́',
-	},
-	'tg-TJ': {
-		nativeName: 'тоҷикӣ',
-	},
-	'th': {
-		nativeName: 'ภาษาไทย',
-	},
-	'th-TH': {
-		nativeName: 'ภาษาไทย (ประเทศไทย)',
-	},
-	'fil': {
-		nativeName: 'Filipino',
-	},
-	'tlh': {
-		nativeName: 'tlhIngan-Hol',
-	},
-	'tr': {
-		nativeName: 'Türkçe',
-	},
-	'tr-TR': {
-		nativeName: 'Türkçe',
-	},
-	'tt-RU': {
-		nativeName: 'татарча',
-	},
-	'uk': {
-		nativeName: 'Українська',
-	},
-	'uk-UA': {
-		nativeName: 'Українська',
-	},
-	'ur': {
-		nativeName: 'اردو',
-	},
-	'ur-PK': {
-		nativeName: 'اردو',
-	},
-	'uz': {
-		nativeName: 'O\'zbek',
-	},
-	'uz-UZ': {
-		nativeName: 'O\'zbek',
-	},
-	'vi': {
-		nativeName: 'Tiếng Việt',
-	},
-	'vi-VN': {
-		nativeName: 'Tiếng Việt',
-	},
-	'xh-ZA': {
-		nativeName: 'isiXhosa',
-	},
-	'yi': {
-		nativeName: 'ייִדיש',
-	},
-	'yi-DE': {
-		nativeName: 'ייִדיש (German)',
-	},
-	'zh': {
-		nativeName: '中文',
-	},
-	'zh-Hans': {
-		nativeName: '中文简体',
-	},
-	'zh-Hant': {
-		nativeName: '中文繁體',
-	},
-	'zh-CN': {
-		nativeName: '中文（中国大陆）',
-	},
-	'zh-HK': {
-		nativeName: '中文（香港）',
-	},
-	'zh-SG': {
-		nativeName: '中文（新加坡）',
-	},
-	'zh-TW': {
-		nativeName: '中文（台灣）',
-	},
-	'zu-ZA': {
-		nativeName: 'isiZulu',
-	},
-};
diff --git a/packages/client/src/scripts/login-id.ts b/packages/client/src/scripts/login-id.ts
deleted file mode 100644
index 0f9c6be4a9..0000000000
--- a/packages/client/src/scripts/login-id.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export function getUrlWithLoginId(url: string, loginId: string) {
-	const u = new URL(url, origin);
-	u.searchParams.append('loginId', loginId);
-	return u.toString();
-}
-
-export function getUrlWithoutLoginId(url: string) {
-	const u = new URL(url);
-	u.searchParams.delete('loginId');
-	return u.toString();
-}
diff --git a/packages/client/src/scripts/lookup-user.ts b/packages/client/src/scripts/lookup-user.ts
deleted file mode 100644
index 3ab9d55300..0000000000
--- a/packages/client/src/scripts/lookup-user.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as Acct from 'misskey-js/built/acct';
-import { i18n } from '@/i18n';
-import * as os from '@/os';
-
-export async function lookupUser() {
-	const { canceled, result } = await os.inputText({
-		title: i18n.ts.usernameOrUserId,
-	});
-	if (canceled) return;
-
-	const show = (user) => {
-		os.pageWindow(`/user-info/${user.id}`);
-	};
-
-	const usernamePromise = os.api('users/show', Acct.parse(result));
-	const idPromise = os.api('users/show', { userId: result });
-	let _notFound = false;
-	const notFound = () => {
-		if (_notFound) {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.noSuchUser,
-			});
-		} else {
-			_notFound = true;
-		}
-	};
-	usernamePromise.then(show).catch(err => {
-		if (err.code === 'NO_SUCH_USER') {
-			notFound();
-		}
-	});
-	idPromise.then(show).catch(err => {
-		notFound();
-	});
-}
diff --git a/packages/client/src/scripts/media-proxy.ts b/packages/client/src/scripts/media-proxy.ts
deleted file mode 100644
index aaf7f9e610..0000000000
--- a/packages/client/src/scripts/media-proxy.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { query } from '@/scripts/url';
-import { url } from '@/config';
-
-export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
-	return `${url}/proxy/image.webp?${query({
-		url: imageUrl,
-		fallback: '1',
-		...(type ? { [type]: '1' } : {}),
-	})}`;
-}
-
-export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
-	if (imageUrl == null) return null;
-	return getProxiedImageUrl(imageUrl, type);
-}
diff --git a/packages/client/src/scripts/mfm-tags.ts b/packages/client/src/scripts/mfm-tags.ts
deleted file mode 100644
index 18e8d7038a..0000000000
--- a/packages/client/src/scripts/mfm-tags.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
diff --git a/packages/client/src/scripts/page-metadata.ts b/packages/client/src/scripts/page-metadata.ts
deleted file mode 100644
index 0db8369f9d..0000000000
--- a/packages/client/src/scripts/page-metadata.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import * as misskey from 'misskey-js';
-import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
-
-export const setPageMetadata = Symbol('setPageMetadata');
-export const pageMetadataProvider = Symbol('pageMetadataProvider');
-
-export type PageMetadata = {
-	title: string;
-	subtitle?: string;
-	icon?: string | null;
-	avatar?: misskey.entities.User | null;
-	userName?: misskey.entities.User | null;
-	bg?: string;
-};
-
-export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
-	const _metadata = isRef(metadata) ? metadata : ref(metadata);
-
-	provide(pageMetadataProvider, _metadata);
-
-	const set = inject(setPageMetadata) as any;
-	if (set) {
-		set(_metadata);
-
-		onMounted(() => {
-			set(_metadata);
-		});
-
-		onActivated(() => {
-			set(_metadata);
-		});
-	}
-}
-
-export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
-	provide(setPageMetadata, callback);
-}
-
-export function injectPageMetadata(): PageMetadata | undefined {
-	return inject(pageMetadataProvider);
-}
diff --git a/packages/client/src/scripts/physics.ts b/packages/client/src/scripts/physics.ts
deleted file mode 100644
index efda80f074..0000000000
--- a/packages/client/src/scripts/physics.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import * as Matter from 'matter-js';
-
-export function physics(container: HTMLElement) {
-	const containerWidth = container.offsetWidth;
-	const containerHeight = container.offsetHeight;
-	const containerCenterX = containerWidth / 2;
-
-	// サイズ固定化(要らないかも？)
-	container.style.position = 'relative';
-	container.style.boxSizing = 'border-box';
-	container.style.width = `${containerWidth}px`;
-	container.style.height = `${containerHeight}px`;
-
-	// create engine
-	const engine = Matter.Engine.create({
-		constraintIterations: 4,
-		positionIterations: 8,
-		velocityIterations: 8,
-	});
-
-	const world = engine.world;
-
-	// create renderer
-	const render = Matter.Render.create({
-		engine: engine,
-		//element: document.getElementById('debug'),
-		options: {
-			width: containerWidth,
-			height: containerHeight,
-			background: 'transparent', // transparent to hide
-			wireframeBackground: 'transparent', // transparent to hide
-		},
-	});
-
-	// Disable to hide debug
-	Matter.Render.run(render);
-
-	// create runner
-	const runner = Matter.Runner.create();
-	Matter.Runner.run(runner, engine);
-
-	const groundThickness = 1024;
-	const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
-		isStatic: true,
-		restitution: 0.1,
-		friction: 2,
-	});
-
-	//const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
-	//const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
-
-	Matter.World.add(world, [
-		ground,
-		//wallRight,
-		//wallLeft,
-	]);
-
-	const objEls = Array.from(container.children) as HTMLElement[];
-	const objs: Matter.Body[] = [];
-	for (const objEl of objEls) {
-		const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
-		const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
-
-		let obj: Matter.Body;
-		if (objEl.classList.contains('_physics_circle_')) {
-			obj = Matter.Bodies.circle(
-				left + (objEl.offsetWidth / 2),
-				top + (objEl.offsetHeight / 2),
-				Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
-				{
-					restitution: 0.5,
-				},
-			);
-		} else {
-			const style = window.getComputedStyle(objEl);
-			obj = Matter.Bodies.rectangle(
-				left + (objEl.offsetWidth / 2),
-				top + (objEl.offsetHeight / 2),
-				objEl.offsetWidth,
-				objEl.offsetHeight,
-				{
-					chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
-					restitution: 0.5,
-				},
-			);
-		}
-		objEl.id = obj.id.toString();
-		objs.push(obj);
-	}
-
-	Matter.World.add(engine.world, objs);
-
-	// Add mouse control
-
-	const mouse = Matter.Mouse.create(container);
-	const mouseConstraint = Matter.MouseConstraint.create(engine, {
-		mouse: mouse,
-		constraint: {
-			stiffness: 0.1,
-			render: {
-				visible: false,
-			},
-		},
-	});
-
-	Matter.World.add(engine.world, mouseConstraint);
-
-	// keep the mouse in sync with rendering
-	render.mouse = mouse;
-
-	for (const objEl of objEls) {
-		objEl.style.position = 'absolute';
-		objEl.style.top = '0';
-		objEl.style.left = '0';
-		objEl.style.margin = '0';
-	}
-
-	window.requestAnimationFrame(update);
-
-	let stop = false;
-
-	function update() {
-		for (const objEl of objEls) {
-			const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
-			if (obj == null) continue;
-
-			const x = (obj.position.x - objEl.offsetWidth / 2);
-			const y = (obj.position.y - objEl.offsetHeight / 2);
-			const angle = obj.angle;
-			objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
-		}
-
-		if (!stop) {
-			window.requestAnimationFrame(update);
-		}
-	}
-
-	// 奈落に落ちたオブジェクトは消す
-	const intervalId = window.setInterval(() => {
-		for (const obj of objs) {
-			if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
-		}
-	}, 1000 * 10);
-
-	return {
-		stop: () => {
-			stop = true;
-			Matter.Runner.stop(runner);
-			window.clearInterval(intervalId);
-		},
-	};
-}
diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts
deleted file mode 100644
index b8fb853cc1..0000000000
--- a/packages/client/src/scripts/please-login.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { defineAsyncComponent } from 'vue';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { popup } from '@/os';
-
-export function pleaseLogin(path?: string) {
-	if ($i) return;
-
-	popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
-		autoSet: true,
-		message: i18n.ts.signinRequired,
-	}, {
-		cancelled: () => {
-			if (path) {
-				window.location.href = path;
-			}
-		},
-	}, 'closed');
-
-	if (!path) throw new Error('signin required');
-}
diff --git a/packages/client/src/scripts/popout.ts b/packages/client/src/scripts/popout.ts
deleted file mode 100644
index 580031d0a3..0000000000
--- a/packages/client/src/scripts/popout.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as config from '@/config';
-import { appendQuery } from './url';
-
-export function popout(path: string, w?: HTMLElement) {
-	let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
-	url = appendQuery(url, 'zen');
-	if (w) {
-		const position = w.getBoundingClientRect();
-		const width = parseInt(getComputedStyle(w, '').width, 10);
-		const height = parseInt(getComputedStyle(w, '').height, 10);
-		const x = window.screenX + position.left;
-		const y = window.screenY + position.top;
-		window.open(url, url,
-			`width=${width}, height=${height}, top=${y}, left=${x}`);
-	} else {
-		const width = 400;
-		const height = 500;
-		const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
-		const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
-		window.open(url, url,
-			`width=${width}, height=${height}, top=${x}, left=${y}`);
-	}
-}
diff --git a/packages/client/src/scripts/popup-position.ts b/packages/client/src/scripts/popup-position.ts
deleted file mode 100644
index e84eebf103..0000000000
--- a/packages/client/src/scripts/popup-position.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { Ref } from 'vue';
-
-export function calcPopupPosition(el: HTMLElement, props: {
-	anchorElement: HTMLElement | null;
-	innerMargin: number;
-	direction: 'top' | 'bottom' | 'left' | 'right';
-	align: 'top' | 'bottom' | 'left' | 'right' | 'center';
-	alignOffset?: number;
-	x?: number;
-	y?: number;
-}): { top: number; left: number; transformOrigin: string; } {
-	const contentWidth = el.offsetWidth;
-	const contentHeight = el.offsetHeight;
-
-	let rect: DOMRect;
-
-	if (props.anchorElement) {
-		rect = props.anchorElement.getBoundingClientRect();
-	}
-
-	const calcPosWhenTop = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
-		} else {
-			left = props.x;
-			top = (props.y - contentHeight) - props.innerMargin;
-		}
-
-		left -= (el.offsetWidth / 2);
-
-		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenBottom = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
-		} else {
-			left = props.x;
-			top = (props.y) + props.innerMargin;
-		}
-
-		left -= (el.offsetWidth / 2);
-
-		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenLeft = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
-			top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
-		} else {
-			left = (props.x - contentWidth) - props.innerMargin;
-			top = props.y;
-		}
-
-		top -= (el.offsetHeight / 2);
-
-		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenRight = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
-
-			if (props.align === 'top') {
-				top = rect.top + window.pageYOffset;
-				if (props.alignOffset != null) top += props.alignOffset;
-			} else if (props.align === 'bottom') {
-				// TODO
-			} else { // center
-				top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
-				top -= (el.offsetHeight / 2);
-			}
-		} else {
-			left = props.x + props.innerMargin;
-			top = props.y;
-			top -= (el.offsetHeight / 2);
-		}
-
-		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calc = (): {
-		left: number;
-		top: number;
-		transformOrigin: string;
-	} => {
-		switch (props.direction) {
-			case 'top': {
-				const [left, top] = calcPosWhenTop();
-
-				// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
-				if (top - window.pageYOffset < 0) {
-					const [left, top] = calcPosWhenBottom();
-					return { left, top, transformOrigin: 'center top' };
-				}
-
-				return { left, top, transformOrigin: 'center bottom' };
-			}
-
-			case 'bottom': {
-				const [left, top] = calcPosWhenBottom();
-				// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
-				return { left, top, transformOrigin: 'center top' };
-			}
-
-			case 'left': {
-				const [left, top] = calcPosWhenLeft();
-
-				// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
-				if (left - window.pageXOffset < 0) {
-					const [left, top] = calcPosWhenRight();
-					return { left, top, transformOrigin: 'left center' };
-				}
-
-				return { left, top, transformOrigin: 'right center' };
-			}
-
-			case 'right': {
-				const [left, top] = calcPosWhenRight();
-				// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
-				return { left, top, transformOrigin: 'left center' };
-			}
-		}
-	};
-
-	return calc();
-}
diff --git a/packages/client/src/scripts/reaction-picker.ts b/packages/client/src/scripts/reaction-picker.ts
deleted file mode 100644
index fe32e719da..0000000000
--- a/packages/client/src/scripts/reaction-picker.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { defineAsyncComponent, Ref, ref } from 'vue';
-import { popup } from '@/os';
-
-class ReactionPicker {
-	private src: Ref<HTMLElement | null> = ref(null);
-	private manualShowing = ref(false);
-	private onChosen?: (reaction: string) => void;
-	private onClosed?: () => void;
-
-	constructor() {
-		// nop
-	}
-
-	public async init() {
-		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
-			src: this.src,
-			asReactionPicker: true,
-			manualShowing: this.manualShowing,
-		}, {
-			done: reaction => {
-				this.onChosen!(reaction);
-			},
-			close: () => {
-				this.manualShowing.value = false;
-			},
-			closed: () => {
-				this.src.value = null;
-				this.onClosed!();
-			},
-		});
-	}
-
-	public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
-		this.src.value = src;
-		this.manualShowing.value = true;
-		this.onChosen = onChosen;
-		this.onClosed = onClosed;
-	}
-}
-
-export const reactionPicker = new ReactionPicker();
diff --git a/packages/client/src/scripts/safe-uri-decode.ts b/packages/client/src/scripts/safe-uri-decode.ts
deleted file mode 100644
index 301b56d7fd..0000000000
--- a/packages/client/src/scripts/safe-uri-decode.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export function safeURIDecode(str: string): string {
-	try {
-		return decodeURIComponent(str);
-	} catch {
-		return str;
-	}
-}
diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts
deleted file mode 100644
index f5bc6bf9ce..0000000000
--- a/packages/client/src/scripts/scroll.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-type ScrollBehavior = 'auto' | 'smooth' | 'instant';
-
-export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
-	if (el == null || el.tagName === 'HTML') return null;
-	const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y');
-	if (overflow === 'scroll' || overflow === 'auto') {
-		return el;
-	} else {
-		return getScrollContainer(el.parentElement);
-	}
-}
-
-export function getScrollPosition(el: Element | null): number {
-	const container = getScrollContainer(el);
-	return container == null ? window.scrollY : container.scrollTop;
-}
-
-export function isTopVisible(el: Element | null): boolean {
-	const scrollTop = getScrollPosition(el);
-	const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
-
-	return scrollTop <= topPosition;
-}
-
-export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
-	if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
-	return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
-}
-
-export function onScrollTop(el: Element, cb) {
-	const container = getScrollContainer(el) || window;
-	const onScroll = ev => {
-		if (!document.body.contains(el)) return;
-		if (isTopVisible(el)) {
-			cb();
-			container.removeEventListener('scroll', onScroll);
-		}
-	};
-	container.addEventListener('scroll', onScroll, { passive: true });
-}
-
-export function onScrollBottom(el: Element, cb) {
-	const container = getScrollContainer(el) || window;
-	const onScroll = ev => {
-		if (!document.body.contains(el)) return;
-		const pos = getScrollPosition(el);
-		if (pos + el.clientHeight > el.scrollHeight - 1) {
-			cb();
-			container.removeEventListener('scroll', onScroll);
-		}
-	};
-	container.addEventListener('scroll', onScroll, { passive: true });
-}
-
-export function scroll(el: Element, options: {
-	top?: number;
-	left?: number;
-	behavior?: ScrollBehavior;
-}) {
-	const container = getScrollContainer(el);
-	if (container == null) {
-		window.scroll(options);
-	} else {
-		container.scroll(options);
-	}
-}
-
-export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
-	scroll(el, { top: 0, ...options });
-}
-
-export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
-	scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
-}
-
-export function isBottom(el: Element, asobi = 0) {
-	const container = getScrollContainer(el);
-	const current = container
-		? el.scrollTop + el.offsetHeight
-		: window.scrollY + window.innerHeight;
-	const max = container
-		? el.scrollHeight
-		: document.body.offsetHeight;
-	return current >= (max - asobi);
-}
diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts
deleted file mode 100644
index 64914d3d65..0000000000
--- a/packages/client/src/scripts/search.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { mainRouter } from '@/router';
-
-export async function search() {
-	const { canceled, result: query } = await os.inputText({
-		title: i18n.ts.search,
-	});
-	if (canceled || query == null || query === '') return;
-
-	const q = query.trim();
-
-	if (q.startsWith('@') && !q.includes(' ')) {
-		mainRouter.push(`/${q}`);
-		return;
-	}
-
-	if (q.startsWith('#')) {
-		mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
-		return;
-	}
-
-	// like 2018/03/12
-	if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
-		const date = new Date(q.replace(/-/g, '/'));
-
-		// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
-		// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
-		// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
-		// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
-		if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
-			date.setHours(23, 59, 59, 999);
-		}
-
-		// TODO
-		//v.$root.$emit('warp', date);
-		os.alert({
-			icon: 'fas fa-history',
-			iconOnly: true, autoClose: true,
-		});
-		return;
-	}
-
-	if (q.startsWith('https://')) {
-		const promise = os.api('ap/show', {
-			uri: q,
-		});
-
-		os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
-
-		const res = await promise;
-
-		if (res.type === 'User') {
-			mainRouter.push(`/@${res.object.username}@${res.object.host}`);
-		} else if (res.type === 'Note') {
-			mainRouter.push(`/notes/${res.object.id}`);
-		}
-
-		return;
-	}
-
-	mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
-}
diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts
deleted file mode 100644
index ec5f8f65e9..0000000000
--- a/packages/client/src/scripts/select-file.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { ref } from 'vue';
-import { DriveFile } from 'misskey-js/built/entities';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-import { uploadFile } from '@/scripts/upload';
-
-function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
-	return new Promise((res, rej) => {
-		const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
-
-		const chooseFileFromPc = () => {
-			const input = document.createElement('input');
-			input.type = 'file';
-			input.multiple = multiple;
-			input.onchange = () => {
-				const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
-
-				Promise.all(promises).then(driveFiles => {
-					res(multiple ? driveFiles : driveFiles[0]);
-				}).catch(err => {
-					// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
-				});
-
-				// 一応廃棄
-				(window as any).__misskey_input_ref__ = null;
-			};
-
-			// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
-			// iOS Safari で正常に動かす為のおまじない
-			(window as any).__misskey_input_ref__ = input;
-
-			input.click();
-		};
-
-		const chooseFileFromDrive = () => {
-			os.selectDriveFile(multiple).then(files => {
-				res(files);
-			});
-		};
-
-		const chooseFileFromUrl = () => {
-			os.inputText({
-				title: i18n.ts.uploadFromUrl,
-				type: 'url',
-				placeholder: i18n.ts.uploadFromUrlDescription,
-			}).then(({ canceled, result: url }) => {
-				if (canceled) return;
-
-				const marker = Math.random().toString(); // TODO: UUIDとか使う
-
-				const connection = stream.useChannel('main');
-				connection.on('urlUploadFinished', urlResponse => {
-					if (urlResponse.marker === marker) {
-						res(multiple ? [urlResponse.file] : urlResponse.file);
-						connection.dispose();
-					}
-				});
-
-				os.api('drive/files/upload-from-url', {
-					url: url,
-					folderId: defaultStore.state.uploadFolder,
-					marker,
-				});
-
-				os.alert({
-					title: i18n.ts.uploadFromUrlRequested,
-					text: i18n.ts.uploadFromUrlMayTakeTime,
-				});
-			});
-		};
-
-		os.popupMenu([label ? {
-			text: label,
-			type: 'label',
-		} : undefined, {
-			type: 'switch',
-			text: i18n.ts.keepOriginalUploading,
-			ref: keepOriginal,
-		}, {
-			text: i18n.ts.upload,
-			icon: 'ti ti-upload',
-			action: chooseFileFromPc,
-		}, {
-			text: i18n.ts.fromDrive,
-			icon: 'ti ti-cloud',
-			action: chooseFileFromDrive,
-		}, {
-			text: i18n.ts.fromUrl,
-			icon: 'ti ti-link',
-			action: chooseFileFromUrl,
-		}], src);
-	});
-}
-
-export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
-	return select(src, label, false) as Promise<DriveFile>;
-}
-
-export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
-	return select(src, label, true) as Promise<DriveFile[]>;
-}
diff --git a/packages/client/src/scripts/show-suspended-dialog.ts b/packages/client/src/scripts/show-suspended-dialog.ts
deleted file mode 100644
index e11569ecd4..0000000000
--- a/packages/client/src/scripts/show-suspended-dialog.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-export function showSuspendedDialog() {
-	return os.alert({
-		type: 'error',
-		title: i18n.ts.yourAccountSuspendedTitle,
-		text: i18n.ts.yourAccountSuspendedDescription,
-	});
-}
diff --git a/packages/client/src/scripts/shuffle.ts b/packages/client/src/scripts/shuffle.ts
deleted file mode 100644
index 05e6cdfbcf..0000000000
--- a/packages/client/src/scripts/shuffle.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * 配列をシャッフル (破壊的)
- */
-export function shuffle<T extends any[]>(array: T): T {
-	let currentIndex = array.length, randomIndex;
-
-	// While there remain elements to shuffle.
-	while (currentIndex !== 0) {
-		// Pick a remaining element.
-		randomIndex = Math.floor(Math.random() * currentIndex);
-		currentIndex--;
-
-		// And swap it with the current element.
-		[array[currentIndex], array[randomIndex]] = [
-			array[randomIndex], array[currentIndex]];
-	}
-
-	return array;
-}
diff --git a/packages/client/src/scripts/sound.ts b/packages/client/src/scripts/sound.ts
deleted file mode 100644
index 9d1f603235..0000000000
--- a/packages/client/src/scripts/sound.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { ColdDeviceStorage } from '@/store';
-
-const cache = new Map<string, HTMLAudioElement>();
-
-export const soundsTypes = [
-	null,
-	'syuilo/up',
-	'syuilo/down',
-	'syuilo/pope1',
-	'syuilo/pope2',
-	'syuilo/waon',
-	'syuilo/popo',
-	'syuilo/triple',
-	'syuilo/poi1',
-	'syuilo/poi2',
-	'syuilo/pirori',
-	'syuilo/pirori-wet',
-	'syuilo/pirori-square-wet',
-	'syuilo/square-pico',
-	'syuilo/reverved',
-	'syuilo/ryukyu',
-	'syuilo/kick',
-	'syuilo/snare',
-	'syuilo/queue-jammed',
-	'aisha/1',
-	'aisha/2',
-	'aisha/3',
-	'noizenecio/kick_gaba1',
-	'noizenecio/kick_gaba2',
-	'noizenecio/kick_gaba3',
-	'noizenecio/kick_gaba4',
-	'noizenecio/kick_gaba5',
-	'noizenecio/kick_gaba6',
-	'noizenecio/kick_gaba7',
-] as const;
-
-export function getAudio(file: string, useCache = true): HTMLAudioElement {
-	let audio: HTMLAudioElement;
-	if (useCache && cache.has(file)) {
-		audio = cache.get(file);
-	} else {
-		audio = new Audio(`/client-assets/sounds/${file}.mp3`);
-		if (useCache) cache.set(file, audio);
-	}
-	return audio;
-}
-
-export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
-	const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
-	audio.volume = masterVolume - ((1 - volume) * masterVolume);
-	return audio;
-}
-
-export function play(type: string) {
-	const sound = ColdDeviceStorage.get('sound_' + type as any);
-	if (sound.type == null) return;
-	playFile(sound.type, sound.volume);
-}
-
-export function playFile(file: string, volume: number) {
-	const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
-	if (masterVolume === 0) return;
-
-	const audio = setVolume(getAudio(file), volume);
-	audio.play();
-}
diff --git a/packages/client/src/scripts/sticky-sidebar.ts b/packages/client/src/scripts/sticky-sidebar.ts
deleted file mode 100644
index c67b8f37ac..0000000000
--- a/packages/client/src/scripts/sticky-sidebar.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-export class StickySidebar {
-	private lastScrollTop = 0;
-	private container: HTMLElement;
-	private el: HTMLElement;
-	private spacer: HTMLElement;
-	private marginTop: number;
-	private isTop = false;
-	private isBottom = false;
-	private offsetTop: number;
-	private globalHeaderHeight: number = 59;
-
-	constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
-		this.container = container;
-		this.el = this.container.children[0] as HTMLElement;
-		this.el.style.position = 'sticky';
-		this.spacer = document.createElement('div');
-		this.container.prepend(this.spacer);
-		this.marginTop = marginTop;
-		this.offsetTop = this.container.getBoundingClientRect().top;
-		this.globalHeaderHeight = globalHeaderHeight;
-	}
-
-	public calc(scrollTop: number) {
-		if (scrollTop > this.lastScrollTop) { // downscroll
-			const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
-			this.el.style.bottom = null;
-			this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
-
-			this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
-
-			if (this.isTop) {
-				this.isTop = false;
-				this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
-			}
-		} else { // upscroll
-			const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
-			this.el.style.top = null;
-			this.el.style.bottom = `${-overflow}px`;
-
-			this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
-
-			if (this.isBottom) {
-				this.isBottom = false;
-				this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
-			}
-		}
-
-		this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
-	}
-}
diff --git a/packages/client/src/scripts/theme-editor.ts b/packages/client/src/scripts/theme-editor.ts
deleted file mode 100644
index 944875ff15..0000000000
--- a/packages/client/src/scripts/theme-editor.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { v4 as uuid } from 'uuid';
-
-import { themeProps, Theme } from './theme';
-
-export type Default = null;
-export type Color = string;
-export type FuncName = 'alpha' | 'darken' | 'lighten';
-export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
-export type RefProp = { type: 'refProp'; key: string; };
-export type RefConst = { type: 'refConst'; key: string; };
-export type Css = { type: 'css'; value: string; };
-
-export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
-
-export type ThemeViewModel = [ string, ThemeValue ][];
-
-export const fromThemeString = (str?: string) : ThemeValue => {
-	if (!str) return null;
-	if (str.startsWith(':')) {
-		const parts = str.slice(1).split('<');
-		const name = parts[0] as FuncName;
-		const arg = parseFloat(parts[1]);
-		const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
-		return { type: 'func', name, arg, value };
-	} else if (str.startsWith('@')) {
-		return {
-			type: 'refProp',
-			key: str.slice(1),
-		};
-	} else if (str.startsWith('$')) {
-		return {
-			type: 'refConst',
-			key: str.slice(1),
-		};
-	} else if (str.startsWith('"')) {
-		return {
-			type: 'css',
-			value: str.substr(1).trim(),
-		};
-	} else {
-		return str;
-	}
-};
-
-export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
-	if (typeof value === 'string') return value;
-	switch (value.type) {
-		case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
-		case 'refProp': return `@${value.key}`;
-		case 'refConst': return `$${value.key}`;
-		case 'css': return `" ${value.value}`;
-	}
-};
-
-export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
-	const props = { } as { [key: string]: string };
-	for (const [key, value] of vm) {
-		if (value === null) continue;
-		props[key] = toThemeString(value);
-	}
-
-	return {
-		id: uuid(),
-		name, desc, author, props, base,
-	};
-};
-
-export const convertToViewModel = (theme: Theme): ThemeViewModel => {
-	const vm: ThemeViewModel = [];
-	// プロパティの登録
-	vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
-
-	// 定数の登録
-	const consts = Object
-		.keys(theme.props)
-		.filter(k => k.startsWith('$'))
-		.map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
-
-		vm.push(...consts);
-	return vm;
-};
diff --git a/packages/client/src/scripts/theme.ts b/packages/client/src/scripts/theme.ts
deleted file mode 100644
index 62a2b9459a..0000000000
--- a/packages/client/src/scripts/theme.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { ref } from 'vue';
-import tinycolor from 'tinycolor2';
-import { globalEvents } from '@/events';
-
-export type Theme = {
-	id: string;
-	name: string;
-	author: string;
-	desc?: string;
-	base?: 'dark' | 'light';
-	props: Record<string, string>;
-};
-
-import lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
-import { deepClone } from './clone';
-
-export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
-
-export const getBuiltinThemes = () => Promise.all(
-	[
-		'l-light',
-		'l-coffee',
-		'l-apricot',
-		'l-rainy',
-		'l-vivid',
-		'l-cherry',
-		'l-sushi',
-		'l-u0',
-
-		'd-dark',
-		'd-persimmon',
-		'd-astro',
-		'd-future',
-		'd-botanical',
-		'd-green-lime',
-		'd-green-orange',
-		'd-cherry',
-		'd-ice',
-		'd-u0',
-	].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
-);
-
-export const getBuiltinThemesRef = () => {
-	const builtinThemes = ref<Theme[]>([]);
-	getBuiltinThemes().then(themes => builtinThemes.value = themes);
-	return builtinThemes;
-};
-
-let timeout = null;
-
-export function applyTheme(theme: Theme, persist = true) {
-	if (timeout) window.clearTimeout(timeout);
-
-	document.documentElement.classList.add('_themeChanging_');
-
-	timeout = window.setTimeout(() => {
-		document.documentElement.classList.remove('_themeChanging_');
-	}, 1000);
-
-	const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
-
-	// Deep copy
-	const _theme = deepClone(theme);
-
-	if (_theme.base) {
-		const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
-		if (base) _theme.props = Object.assign({}, base.props, _theme.props);
-	}
-
-	const props = compile(_theme);
-
-	for (const tag of document.head.children) {
-		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
-			tag.setAttribute('content', props['htmlThemeColor']);
-			break;
-		}
-	}
-
-	for (const [k, v] of Object.entries(props)) {
-		document.documentElement.style.setProperty(`--${k}`, v.toString());
-	}
-
-	document.documentElement.style.setProperty('color-schema', colorSchema);
-
-	if (persist) {
-		localStorage.setItem('theme', JSON.stringify(props));
-		localStorage.setItem('colorSchema', colorSchema);
-	}
-
-	// 色計算など再度行えるようにクライアント全体に通知
-	globalEvents.emit('themeChanged');
-}
-
-function compile(theme: Theme): Record<string, string> {
-	function getColor(val: string): tinycolor.Instance {
-		// ref (prop)
-		if (val[0] === '@') {
-			return getColor(theme.props[val.substr(1)]);
-		}
-
-		// ref (const)
-		else if (val[0] === '$') {
-			return getColor(theme.props[val]);
-		}
-
-		// func
-		else if (val[0] === ':') {
-			const parts = val.split('<');
-			const func = parts.shift().substr(1);
-			const arg = parseFloat(parts.shift());
-			const color = getColor(parts.join('<'));
-
-			switch (func) {
-				case 'darken': return color.darken(arg);
-				case 'lighten': return color.lighten(arg);
-				case 'alpha': return color.setAlpha(arg);
-				case 'hue': return color.spin(arg);
-				case 'saturate': return color.saturate(arg);
-			}
-		}
-
-		// other case
-		return tinycolor(val);
-	}
-
-	const props = {};
-
-	for (const [k, v] of Object.entries(theme.props)) {
-		if (k.startsWith('$')) continue; // ignore const
-
-		props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
-	}
-
-	return props;
-}
-
-function genValue(c: tinycolor.Instance): string {
-	return c.toRgbString();
-}
-
-export function validateTheme(theme: Record<string, any>): boolean {
-	if (theme.id == null || typeof theme.id !== 'string') return false;
-	if (theme.name == null || typeof theme.name !== 'string') return false;
-	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
-	if (theme.props == null || typeof theme.props !== 'object') return false;
-	return true;
-}
diff --git a/packages/client/src/scripts/time.ts b/packages/client/src/scripts/time.ts
deleted file mode 100644
index 34e8b6b17c..0000000000
--- a/packages/client/src/scripts/time.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-const dateTimeIntervals = {
-	'day': 86400000,
-	'hour': 3600000,
-	'ms': 1,
-};
-
-export function dateUTC(time: number[]): Date {
-	const d = time.length === 2 ? Date.UTC(time[0], time[1])
-					: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
-					: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
-					: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
-					: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
-					: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
-					: null;
-
-	if (!d) throw 'wrong number of arguments';
-
-	return new Date(d);
-}
-
-export function isTimeSame(a: Date, b: Date): boolean {
-	return a.getTime() === b.getTime();
-}
-
-export function isTimeBefore(a: Date, b: Date): boolean {
-	return (a.getTime() - b.getTime()) < 0;
-}
-
-export function isTimeAfter(a: Date, b: Date): boolean {
-	return (a.getTime() - b.getTime()) > 0;
-}
-
-export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
-	return new Date(x.getTime() + (value * dateTimeIntervals[span]));
-}
-
-export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
-	return new Date(x.getTime() - (value * dateTimeIntervals[span]));
-}
diff --git a/packages/client/src/scripts/timezones.ts b/packages/client/src/scripts/timezones.ts
deleted file mode 100644
index 8ce07323f6..0000000000
--- a/packages/client/src/scripts/timezones.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-export const timezones = [{
-	name: 'UTC',
-	abbrev: 'UTC',
-	offset: 0,
-}, {
-	name: 'Europe/Berlin',
-	abbrev: 'CET',
-	offset: 60,
-}, {
-	name: 'Asia/Tokyo',
-	abbrev: 'JST',
-	offset: 540,
-}, {
-	name: 'Asia/Seoul',
-	abbrev: 'KST',
-	offset: 540,
-}, {
-	name: 'Asia/Shanghai',
-	abbrev: 'CST',
-	offset: 480,
-}, {
-	name: 'Australia/Sydney',
-	abbrev: 'AEST',
-	offset: 600,
-}, {
-	name: 'Australia/Darwin',
-	abbrev: 'ACST',
-	offset: 570,
-}, {
-	name: 'Australia/Perth',
-	abbrev: 'AWST',
-	offset: 480,
-}, {
-	name: 'America/New_York',
-	abbrev: 'EST',
-	offset: -300,
-}, {
-	name: 'America/Mexico_City',
-	abbrev: 'CST',
-	offset: -360,
-}, {
-	name: 'America/Phoenix',
-	abbrev: 'MST',
-	offset: -420,
-}, {
-	name: 'America/Los_Angeles',
-	abbrev: 'PST',
-	offset: -480,
-}];
diff --git a/packages/client/src/scripts/touch.ts b/packages/client/src/scripts/touch.ts
deleted file mode 100644
index 5251bc2e27..0000000000
--- a/packages/client/src/scripts/touch.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
-
-export let isTouchUsing = false;
-
-export let isScreenTouching = false;
-
-if (isTouchSupported) {
-	window.addEventListener('touchstart', () => {
-		// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
-		// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
-		isTouchUsing = true;
-
-		isScreenTouching = true;
-	}, { passive: true });
-	
-	window.addEventListener('touchend', () => {
-		// 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、
-		// touchendイベントでもtouchstartイベントと同様にtrueにする
-		isTouchUsing = true;
-
-		isScreenTouching = false;
-	}, { passive: true });
-}
diff --git a/packages/client/src/scripts/unison-reload.ts b/packages/client/src/scripts/unison-reload.ts
deleted file mode 100644
index 59af584c1b..0000000000
--- a/packages/client/src/scripts/unison-reload.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// SafariがBroadcastChannel未実装なのでライブラリを使う
-import { BroadcastChannel } from 'broadcast-channel';
-
-export const reloadChannel = new BroadcastChannel<string | null>('reload');
-
-// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
-export function unisonReload(path?: string) {
-	if (path !== undefined) {
-		reloadChannel.postMessage(path);
-		location.href = path;
-	} else {
-		reloadChannel.postMessage(null);
-		location.reload();
-	}
-}
diff --git a/packages/client/src/scripts/upload.ts b/packages/client/src/scripts/upload.ts
deleted file mode 100644
index 9a39652ef5..0000000000
--- a/packages/client/src/scripts/upload.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { reactive, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { readAndCompressImage } from 'browser-image-resizer';
-import { getCompressionConfig } from './upload/compress-config';
-import { defaultStore } from '@/store';
-import { apiUrl } from '@/config';
-import { $i } from '@/account';
-import { alert } from '@/os';
-import { i18n } from '@/i18n';
-
-type Uploading = {
-	id: string;
-	name: string;
-	progressMax: number | undefined;
-	progressValue: number | undefined;
-	img: string;
-};
-export const uploads = ref<Uploading[]>([]);
-
-const mimeTypeMap = {
-	'image/webp': 'webp',
-	'image/jpeg': 'jpg',
-	'image/png': 'png',
-} as const;
-
-export function uploadFile(
-	file: File,
-	folder?: any,
-	name?: string,
-	keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
-): Promise<Misskey.entities.DriveFile> {
-	if ($i == null) throw new Error('Not logged in');
-
-	if (folder && typeof folder === 'object') folder = folder.id;
-
-	return new Promise((resolve, reject) => {
-		const id = Math.random().toString();
-
-		const reader = new FileReader();
-		reader.onload = async (): Promise<void> => {
-			const ctx = reactive<Uploading>({
-				id: id,
-				name: name ?? file.name ?? 'untitled',
-				progressMax: undefined,
-				progressValue: undefined,
-				img: window.URL.createObjectURL(file),
-			});
-
-			uploads.value.push(ctx);
-
-			const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
-			let resizedImage: Blob | undefined;
-			if (config) {
-				try {
-					const resized = await readAndCompressImage(file, config);
-					if (resized.size < file.size || file.type === 'image/webp') {
-						// The compression may not always reduce the file size
-						// (and WebP is not browser safe yet)
-						resizedImage = resized;
-					}
-					if (_DEV_) {
-						const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
-						console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
-					}
-
-					ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
-				} catch (err) {
-					console.error('Failed to resize image', err);
-				}
-			}
-
-			const formData = new FormData();
-			formData.append('i', $i.token);
-			formData.append('force', 'true');
-			formData.append('file', resizedImage ?? file);
-			formData.append('name', ctx.name);
-			if (folder) formData.append('folderId', folder);
-
-			const xhr = new XMLHttpRequest();
-			xhr.open('POST', apiUrl + '/drive/files/create', true);
-			xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
-				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
-					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
-					uploads.value = uploads.value.filter(x => x.id !== id);
-
-					if (ev.target?.response) {
-						const res = JSON.parse(ev.target.response);
-						if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: i18n.ts.cannotUploadBecauseInappropriate,
-							});
-						} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: i18n.ts.cannotUploadBecauseNoFreeSpace,
-							});
-						} else {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
-							});
-						}
-					} else {
-						alert({
-							type: 'error',
-							title: 'Failed to upload',
-							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
-						});
-					}
-
-					reject();
-					return;
-				}
-
-				const driveFile = JSON.parse(ev.target.response);
-
-				resolve(driveFile);
-
-				uploads.value = uploads.value.filter(x => x.id !== id);
-			}) as (ev: ProgressEvent<EventTarget>) => any;
-
-			xhr.upload.onprogress = ev => {
-				if (ev.lengthComputable) {
-					ctx.progressMax = ev.total;
-					ctx.progressValue = ev.loaded;
-				}
-			};
-
-			xhr.send(formData);
-		};
-		reader.readAsArrayBuffer(file);
-	});
-}
diff --git a/packages/client/src/scripts/upload/compress-config.ts b/packages/client/src/scripts/upload/compress-config.ts
deleted file mode 100644
index 793c78ad20..0000000000
--- a/packages/client/src/scripts/upload/compress-config.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import isAnimated from 'is-file-animated';
-import type { BrowserImageResizerConfig } from 'browser-image-resizer';
-
-const compressTypeMap = {
-	'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
-	'image/png': { quality: 1, mimeType: 'image/png' },
-	'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
-	'image/svg+xml': { quality: 1, mimeType: 'image/png' },
-} as const;
-
-export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> {
-	const imgConfig = compressTypeMap[file.type];
-	if (!imgConfig || await isAnimated(file)) {
-		return;
-	}
-
-	return {
-		maxWidth: 2048,
-		maxHeight: 2048,
-		debug: true,
-		...imgConfig,
-	};
-}
diff --git a/packages/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts
deleted file mode 100644
index 86735de9f0..0000000000
--- a/packages/client/src/scripts/url.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export function query(obj: Record<string, any>): string {
-	const params = Object.entries(obj)
-		.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
-		.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
-
-	return Object.entries(params)
-		.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
-		.join('&');
-}
-
-export function appendQuery(url: string, query: string): string {
-	return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
-}
diff --git a/packages/client/src/scripts/use-chart-tooltip.ts b/packages/client/src/scripts/use-chart-tooltip.ts
deleted file mode 100644
index 881e5e9ad5..0000000000
--- a/packages/client/src/scripts/use-chart-tooltip.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { onUnmounted, ref } from 'vue';
-import * as os from '@/os';
-import MkChartTooltip from '@/components/MkChartTooltip.vue';
-
-export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
-	const tooltipShowing = ref(false);
-	const tooltipX = ref(0);
-	const tooltipY = ref(0);
-	const tooltipTitle = ref(null);
-	const tooltipSeries = ref(null);
-	let disposeTooltipComponent;
-
-	os.popup(MkChartTooltip, {
-		showing: tooltipShowing,
-		x: tooltipX,
-		y: tooltipY,
-		title: tooltipTitle,
-		series: tooltipSeries,
-	}, {}).then(({ dispose }) => {
-		disposeTooltipComponent = dispose;
-	});
-
-	onUnmounted(() => {
-		if (disposeTooltipComponent) disposeTooltipComponent();
-	});
-
-	function handler(context) {
-		if (context.tooltip.opacity === 0) {
-			tooltipShowing.value = false;
-			return;
-		}
-
-		tooltipTitle.value = context.tooltip.title[0];
-		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
-			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
-			borderColor: context.tooltip.labelColors[i].borderColor,
-			text: b.lines[0],
-		}));
-
-		const rect = context.chart.canvas.getBoundingClientRect();
-
-		tooltipShowing.value = true;
-		tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
-		if (opts.position === 'top') {
-			tooltipY.value = rect.top + window.pageYOffset;
-		} else if (opts.position === 'middle') {
-			tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
-		}
-	}
-
-	return {
-		handler,
-	};
-}
diff --git a/packages/client/src/scripts/use-interval.ts b/packages/client/src/scripts/use-interval.ts
deleted file mode 100644
index 201ba417ef..0000000000
--- a/packages/client/src/scripts/use-interval.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { onMounted, onUnmounted } from 'vue';
-
-export function useInterval(fn: () => void, interval: number, options: {
-	immediate: boolean;
-	afterMounted: boolean;
-}): void {
-	if (Number.isNaN(interval)) return;
-
-	let intervalId: number | null = null;
-
-	if (options.afterMounted) {
-		onMounted(() => {
-			if (options.immediate) fn();
-			intervalId = window.setInterval(fn, interval);
-		});
-	} else {
-		if (options.immediate) fn();
-		intervalId = window.setInterval(fn, interval);
-	}
-
-	onUnmounted(() => {
-		if (intervalId) window.clearInterval(intervalId);
-	});
-}
diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts
deleted file mode 100644
index a93b84d1fe..0000000000
--- a/packages/client/src/scripts/use-leave-guard.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { inject, onUnmounted, Ref } from 'vue';
-import { i18n } from '@/i18n';
-import * as os from '@/os';
-
-export function useLeaveGuard(enabled: Ref<boolean>) {
-	/* TODO
-	const setLeaveGuard = inject('setLeaveGuard');
-
-	if (setLeaveGuard) {
-		setLeaveGuard(async () => {
-			if (!enabled.value) return false;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return canceled;
-		});
-	} else {
-		onBeforeRouteLeave(async (to, from) => {
-			if (!enabled.value) return true;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return !canceled;
-		});
-	}
-	*/
-
-	/*
-	function onBeforeLeave(ev: BeforeUnloadEvent) {
-		if (enabled.value) {
-			ev.preventDefault();
-			ev.returnValue = '';
-		}
-	}
-
-	window.addEventListener('beforeunload', onBeforeLeave);
-	onUnmounted(() => {
-		window.removeEventListener('beforeunload', onBeforeLeave);
-	});
-	*/
-}
diff --git a/packages/client/src/scripts/use-note-capture.ts b/packages/client/src/scripts/use-note-capture.ts
deleted file mode 100644
index e6bdb345c4..0000000000
--- a/packages/client/src/scripts/use-note-capture.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { onUnmounted, Ref } from 'vue';
-import * as misskey from 'misskey-js';
-import { stream } from '@/stream';
-import { $i } from '@/account';
-
-export function useNoteCapture(props: {
-	rootEl: Ref<HTMLElement>;
-	note: Ref<misskey.entities.Note>;
-	isDeletedRef: Ref<boolean>;
-}) {
-	const note = props.note;
-	const connection = $i ? stream : null;
-
-	function onStreamNoteUpdated(noteData): void {
-		const { type, id, body } = noteData;
-
-		if (id !== note.value.id) return;
-
-		switch (type) {
-			case 'reacted': {
-				const reaction = body.reaction;
-
-				if (body.emoji) {
-					const emojis = note.value.emojis || [];
-					if (!emojis.includes(body.emoji)) {
-						note.value.emojis = [...emojis, body.emoji];
-					}
-				}
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = currentCount + 1;
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = reaction;
-				}
-				break;
-			}
-
-			case 'unreacted': {
-				const reaction = body.reaction;
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = null;
-				}
-				break;
-			}
-
-			case 'pollVoted': {
-				const choice = body.choice;
-
-				const choices = [...note.value.poll.choices];
-				choices[choice] = {
-					...choices[choice],
-					votes: choices[choice].votes + 1,
-					...($i && (body.userId === $i.id) ? {
-						isVoted: true,
-					} : {}),
-				};
-
-				note.value.poll.choices = choices;
-				break;
-			}
-
-			case 'deleted': {
-				props.isDeletedRef.value = true;
-				break;
-			}
-		}
-	}
-
-	function capture(withHandler = false): void {
-		if (connection) {
-			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-			connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id });
-			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function decapture(withHandler = false): void {
-		if (connection) {
-			connection.send('un', {
-				id: note.value.id,
-			});
-			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function onStreamConnected() {
-		capture(false);
-	}
-	
-	capture(true);
-	if (connection) {
-		connection.on('_connected_', onStreamConnected);
-	}
-	
-	onUnmounted(() => {
-		decapture(true);
-		if (connection) {
-			connection.off('_connected_', onStreamConnected);
-		}
-	});
-}
diff --git a/packages/client/src/scripts/use-tooltip.ts b/packages/client/src/scripts/use-tooltip.ts
deleted file mode 100644
index 1f6e0fb6ce..0000000000
--- a/packages/client/src/scripts/use-tooltip.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Ref, ref, watch, onUnmounted } from 'vue';
-
-export function useTooltip(
-	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
-	onShow: (showing: Ref<boolean>) => void,
-	delay = 300,
-): void {
-	let isHovering = false;
-
-	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
-	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
-	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
-	let shouldIgnoreMouseover = false;
-
-	let timeoutId: number;
-
-	let changeShowingState: (() => void) | null;
-
-	const open = () => {
-		close();
-		if (!isHovering) return;
-		if (elRef.value == null) return;
-		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
-
-		const showing = ref(true);
-		onShow(showing);
-		changeShowingState = () => {
-			showing.value = false;
-		};
-	};
-
-	const close = () => {
-		if (changeShowingState != null) {
-			changeShowingState();
-			changeShowingState = null;
-		}
-	};
-
-	const onMouseover = () => {
-		if (isHovering) return;
-		if (shouldIgnoreMouseover) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onMouseleave = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		close();
-	};
-
-	const onTouchstart = () => {
-		shouldIgnoreMouseover = true;
-		if (isHovering) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onTouchend = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		close();
-	};
-
-	const stop = watch(elRef, () => {
-		if (elRef.value) {
-			stop();
-			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-			el.addEventListener('mouseover', onMouseover, { passive: true });
-			el.addEventListener('mouseleave', onMouseleave, { passive: true });
-			el.addEventListener('touchstart', onTouchstart, { passive: true });
-			el.addEventListener('touchend', onTouchend, { passive: true });
-			el.addEventListener('click', close, { passive: true });
-		}
-	}, {
-		immediate: true,
-		flush: 'post',
-	});
-
-	onUnmounted(() => {
-		close();
-	});
-}
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
deleted file mode 100644
index 1bedab5fad..0000000000
--- a/packages/client/src/store.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-import { markRaw, ref } from 'vue';
-import { Storage } from './pizzax';
-import { Theme } from './scripts/theme';
-
-interface PostFormAction {
-	title: string,
-	handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
-}
-
-interface UserAction {
-	title: string,
-	handler: (user: UserDetailed) => void;
-}
-
-interface NoteAction {
-	title: string,
-	handler: (note: Note) => void;
-}
-
-interface NoteViewInterruptor {
-	handler: (note: Note) => unknown;
-}
-
-interface NotePostInterruptor {
-	handler: (note: FIXME) => unknown;
-}
-
-export const postFormActions: PostFormAction[] = [];
-export const userActions: UserAction[] = [];
-export const noteActions: NoteAction[] = [];
-export const noteViewInterruptors: NoteViewInterruptor[] = [];
-export const notePostInterruptors: NotePostInterruptor[] = [];
-
-// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
-//       あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
-export const defaultStore = markRaw(new Storage('base', {
-	tutorial: {
-		where: 'account',
-		default: 0,
-	},
-	keepCw: {
-		where: 'account',
-		default: true,
-	},
-	showFullAcct: {
-		where: 'account',
-		default: false,
-	},
-	rememberNoteVisibility: {
-		where: 'account',
-		default: false,
-	},
-	defaultNoteVisibility: {
-		where: 'account',
-		default: 'public',
-	},
-	defaultNoteLocalOnly: {
-		where: 'account',
-		default: false,
-	},
-	uploadFolder: {
-		where: 'account',
-		default: null as string | null,
-	},
-	pastedFileName: {
-		where: 'account',
-		default: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
-	},
-	keepOriginalUploading: {
-		where: 'account',
-		default: false,
-	},
-	memo: {
-		where: 'account',
-		default: null,
-	},
-	reactions: {
-		where: 'account',
-		default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
-	},
-	mutedWords: {
-		where: 'account',
-		default: [],
-	},
-	mutedAds: {
-		where: 'account',
-		default: [] as string[],
-	},
-
-	menu: {
-		where: 'deviceAccount',
-		default: [
-			'notifications',
-			'favorites',
-			'drive',
-			'followRequests',
-			'-',
-			'explore',
-			'announcements',
-			'search',
-			'-',
-			'ui',
-		],
-	},
-	visibility: {
-		where: 'deviceAccount',
-		default: 'public' as 'public' | 'home' | 'followers' | 'specified',
-	},
-	localOnly: {
-		where: 'deviceAccount',
-		default: false,
-	},
-	statusbars: {
-		where: 'deviceAccount',
-		default: [] as {
-			name: string;
-			id: string;
-			type: string;
-			size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
-			black: boolean;
-			props: Record<string, any>;
-		}[],
-	},
-	widgets: {
-		where: 'deviceAccount',
-		default: [] as {
-			name: string;
-			id: string;
-			place: string | null;
-			data: Record<string, any>;
-		}[],
-	},
-	tl: {
-		where: 'deviceAccount',
-		default: {
-			src: 'home' as 'home' | 'local' | 'social' | 'global',
-			arg: null,
-		},
-	},
-
-	overridedDeviceKind: {
-		where: 'device',
-		default: null as null | 'smartphone' | 'tablet' | 'desktop',
-	},
-	serverDisconnectedBehavior: {
-		where: 'device',
-		default: 'quiet' as 'quiet' | 'reload' | 'dialog',
-	},
-	nsfw: {
-		where: 'device',
-		default: 'respect' as 'respect' | 'force' | 'ignore',
-	},
-	animation: {
-		where: 'device',
-		default: true,
-	},
-	animatedMfm: {
-		where: 'device',
-		default: false,
-	},
-	loadRawImages: {
-		where: 'device',
-		default: false,
-	},
-	imageNewTab: {
-		where: 'device',
-		default: false,
-	},
-	disableShowingAnimatedImages: {
-		where: 'device',
-		default: false,
-	},
-	disablePagesScript: {
-		where: 'device',
-		default: false,
-	},
-	emojiStyle: {
-		where: 'device',
-		default: 'twemoji', // twemoji / fluentEmoji / native
-	},
-	disableDrawer: {
-		where: 'device',
-		default: false,
-	},
-	useBlurEffectForModal: {
-		where: 'device',
-		default: true,
-	},
-	useBlurEffect: {
-		where: 'device',
-		default: true,
-	},
-	showFixedPostForm: {
-		where: 'device',
-		default: false,
-	},
-	enableInfiniteScroll: {
-		where: 'device',
-		default: true,
-	},
-	useReactionPickerForContextMenu: {
-		where: 'device',
-		default: false,
-	},
-	showGapBetweenNotesInTimeline: {
-		where: 'device',
-		default: false,
-	},
-	darkMode: {
-		where: 'device',
-		default: false,
-	},
-	instanceTicker: {
-		where: 'device',
-		default: 'remote' as 'none' | 'remote' | 'always',
-	},
-	reactionPickerSize: {
-		where: 'device',
-		default: 1,
-	},
-	reactionPickerWidth: {
-		where: 'device',
-		default: 1,
-	},
-	reactionPickerHeight: {
-		where: 'device',
-		default: 2,
-	},
-	reactionPickerUseDrawerForMobile: {
-		where: 'device',
-		default: true,
-	},
-	recentlyUsedEmojis: {
-		where: 'device',
-		default: [] as string[],
-	},
-	recentlyUsedUsers: {
-		where: 'device',
-		default: [] as string[],
-	},
-	defaultSideView: {
-		where: 'device',
-		default: false,
-	},
-	menuDisplay: {
-		where: 'device',
-		default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top',
-	},
-	reportError: {
-		where: 'device',
-		default: false,
-	},
-	squareAvatars: {
-		where: 'device',
-		default: false,
-	},
-	postFormWithHashtags: {
-		where: 'device',
-		default: false,
-	},
-	postFormHashtags: {
-		where: 'device',
-		default: '',
-	},
-	themeInitial: {
-		where: 'device',
-		default: true,
-	},
-	numberOfPageCache: {
-		where: 'device',
-		default: 5,
-	},
-	aiChanMode: {
-		where: 'device',
-		default: false,
-	},
-}));
-
-// TODO: 他のタブと永続化されたstateを同期
-
-const PREFIX = 'miux:';
-
-type Plugin = {
-	id: string;
-	name: string;
-	active: boolean;
-	configData: Record<string, any>;
-	token: string;
-	ast: any[];
-};
-
-interface Watcher {
-	key: string;
-	callback: (value: unknown) => void;
-}
-
-/**
- * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
- */
-import lightTheme from '@/themes/l-light.json5';
-import darkTheme from '@/themes/d-green-lime.json5';
-import { Note, UserDetailed } from 'misskey-js/built/entities';
-
-export class ColdDeviceStorage {
-	public static default = {
-		lightTheme,
-		darkTheme,
-		syncDeviceDarkMode: true,
-		plugins: [] as Plugin[],
-		mediaVolume: 0.5,
-		sound_masterVolume: 0.3,
-		sound_note: { type: 'syuilo/down', volume: 1 },
-		sound_noteMy: { type: 'syuilo/up', volume: 1 },
-		sound_notification: { type: 'syuilo/pope2', volume: 1 },
-		sound_chat: { type: 'syuilo/pope1', volume: 1 },
-		sound_chatBg: { type: 'syuilo/waon', volume: 1 },
-		sound_antenna: { type: 'syuilo/triple', volume: 1 },
-		sound_channel: { type: 'syuilo/square-pico', volume: 1 },
-	};
-
-	public static watchers: Watcher[] = [];
-
-	public static get<T extends keyof typeof ColdDeviceStorage.default>(key: T): typeof ColdDeviceStorage.default[T] {
-		// TODO: indexedDBにする
-		//       ただしその際はnullチェックではなくキー存在チェックにしないとダメ
-		//       (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
-		const value = localStorage.getItem(PREFIX + key);
-		if (value == null) {
-			return ColdDeviceStorage.default[key];
-		} else {
-			return JSON.parse(value);
-		}
-	}
-
-	public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void {
-		// 呼び出し側のバグ等で undefined が来ることがある
-		// undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-		if (value === undefined) {
-			console.error(`attempt to store undefined value for key '${key}'`);
-			return;
-		}
-
-		localStorage.setItem(PREFIX + key, JSON.stringify(value));
-
-		for (const watcher of this.watchers) {
-			if (watcher.key === key) watcher.callback(value);
-		}
-	}
-
-	public static watch(key, callback) {
-		this.watchers.push({ key, callback });
-	}
-
-	// TODO: VueのcustomRef使うと良い感じになるかも
-	public static ref<T extends keyof typeof ColdDeviceStorage.default>(key: T) {
-		const v = ColdDeviceStorage.get(key);
-		const r = ref(v);
-		// TODO: このままではwatcherがリークするので開放する方法を考える
-		this.watch(key, v => {
-			r.value = v;
-		});
-		return r;
-	}
-
-	/**
-	 * 特定のキーの、簡易的なgetter/setterを作ります
-	 * 主にvue場で設定コントロールのmodelとして使う用
-	 */
-	public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) {
-		// TODO: VueのcustomRef使うと良い感じになるかも
-		const valueRef = ColdDeviceStorage.ref(key);
-		return {
-			get: () => {
-				return valueRef.value;
-			},
-			set: (value: unknown) => {
-				const val = value;
-				ColdDeviceStorage.set(key, val);
-			},
-		};
-	}
-}
diff --git a/packages/client/src/stream.ts b/packages/client/src/stream.ts
deleted file mode 100644
index dea3459b86..0000000000
--- a/packages/client/src/stream.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as Misskey from 'misskey-js';
-import { markRaw } from 'vue';
-import { $i } from '@/account';
-import { url } from '@/config';
-
-export const stream = markRaw(new Misskey.Stream(url, $i ? {
-	token: $i.token,
-} : null));
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
deleted file mode 100644
index 8b7a846863..0000000000
--- a/packages/client/src/style.scss
+++ /dev/null
@@ -1,584 +0,0 @@
-@charset "utf-8";
-
-:root {
-	--radius: 12px;
-	--marginFull: 16px;
-	--marginHalf: 10px;
-
-	--margin: var(--marginFull);
-
-	@media (max-width: 500px) {
-		--margin: var(--marginHalf);
-	}
-
-	//--ad: rgb(255 169 0 / 10%);
-}
-
-::selection {
-	color: #fff;
-	background-color: var(--accent);
-} 
-
-html {
-	touch-action: manipulation;
-	background-color: var(--bg);
-	background-attachment: fixed;
-	background-size: cover;
-	background-position: center;
-	color: var(--fg);
-	accent-color: var(--accent);
-	overflow: auto;
-	overflow-wrap: break-word;
-	font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
-	font-size: 14px;
-	line-height: 1.35;
-	text-size-adjust: 100%;
-	tab-size: 2;
-
-	&, * {
-		scrollbar-color: var(--scrollbarHandle) inherit;
-		scrollbar-width: thin;
-
-		&::-webkit-scrollbar {
-			width: 6px;
-			height: 6px;
-		}
-
-		&::-webkit-scrollbar-track {
-			background: inherit;
-		}
-
-		&::-webkit-scrollbar-thumb {
-			background: var(--scrollbarHandle);
-
-			&:hover {
-				background: var(--scrollbarHandleHover);
-			}
-
-			&:active {
-				background: var(--accent);
-			}
-		}
-	}
-
-	&.f-1 {
-		font-size: 15px;
-	}
-
-	&.f-2 {
-		font-size: 16px;
-	}
-
-	&.f-3 {
-		font-size: 17px;
-	}
-
-	&.useSystemFont {
-		font-family: 'Hiragino Maru Gothic Pro', sans-serif;
-	}
-}
-
-html._themeChanging_ {
-	&, * {
-		transition: background 1s ease, border 1s ease !important;
-	}
-}
-
-html, body {
-	margin: 0;
-	padding: 0;
-	scroll-behavior: smooth;
-}
-
-a {
-	text-decoration: none;
-	cursor: pointer;
-	color: inherit;
-	tap-highlight-color: transparent;
-	-webkit-tap-highlight-color: transparent;
-
-	&:hover {
-		text-decoration: underline;
-	}
-}
-
-textarea, input {
-	tap-highlight-color: transparent;
-	-webkit-tap-highlight-color: transparent;
-}
-
-optgroup, option {
-	background: var(--panel);
-	color: var(--fg);
-}
-
-hr {
-	margin: var(--margin) 0 var(--margin) 0;
-	border: none;
-	height: 1px;
-	background: var(--divider);
-}
-
-.ti {
-	vertical-align: -10%;
-	line-height: 0.9em;
-
-	&:before {
-		font-size: 130%;
-	}
-}
-
-.ti-fw {
-	display: inline-block;
-	text-align: center;
-}
-
-._indicatorCircle {
-	display: inline-block;
-	width: 1em;
-	height: 1em;
-	border-radius: 100%;
-	background: currentColor;
-}
-
-._noSelect {
-	user-select: none;
-	-webkit-user-select: none;
-	-webkit-touch-callout: none;
-}
-
-._ghost {
-	&, * {
-		@extend ._noSelect;
-		pointer-events: none;
-	}
-}
-
-._modalBg {
-	position: fixed;
-	top: 0;
-	left: 0;
-	width: 100%;
-	height: 100%;
-	background: var(--modalBg);
-	-webkit-backdrop-filter: var(--modalBgFilter);
-	backdrop-filter: var(--modalBgFilter);
-}
-
-._shadow {
-	box-shadow: 0px 4px 32px var(--shadow) !important;
-}
-
-._button {
-	appearance: none;
-	display: inline-block;
-	padding: 0;
-	margin: 0; // for Safari
-	background: none;
-	border: none;
-	cursor: pointer;
-	color: inherit;
-	touch-action: manipulation;
-	tap-highlight-color: transparent;
-	-webkit-tap-highlight-color: transparent;
-	font-size: 1em;
-	font-family: inherit;
-	line-height: inherit;
-	max-width: 100%;
-
-	&, * {
-		@extend ._noSelect;
-	}
-
-	* {
-		pointer-events: none;
-	}
-
-	&:focus-visible {
-		outline: none;
-	}
-
-	&:disabled {
-		opacity: 0.5;
-		cursor: default;
-	}
-}
-
-._buttonPrimary {
-	@extend ._button;
-	color: var(--fgOnAccent);
-	background: var(--accent);
-
-	&:not(:disabled):hover {
-		background: var(--X8);
-	}
-
-	&:not(:disabled):active {
-		background: var(--X9);
-	}
-}
-
-._buttonGradate {
-	@extend ._buttonPrimary;
-	color: var(--fgOnAccent);
-	background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
-
-	&:not(:disabled):hover {
-		background: linear-gradient(90deg, var(--X8), var(--X8));
-	}
-
-	&:not(:disabled):active {
-		background: linear-gradient(90deg, var(--X8), var(--X8));
-	}
-}
-
-._help {
-	color: var(--accent);
-	cursor: help
-}
-
-._textButton {
-	@extend ._button;
-	color: var(--accent);
-
-	&:not(:disabled):hover {
-		text-decoration: underline;
-	}
-}
-
-._inputs {
-	display: flex;
-	margin: 32px 0;
-
-	&:first-child {
-		margin-top: 8px;
-	}
-
-	&:last-child {
-		margin-bottom: 8px;
-	}
-
-	> * {
-		flex: 1;
-		margin: 0 !important;
-
-		&:not(:first-child) {
-			margin-left: 8px !important;
-		}
-
-		&:not(:last-child) {
-			margin-right: 8px !important;
-		}
-	}
-}
-
-._panel {
-	background: var(--panel);
-	border-radius: var(--radius);
-	overflow: clip;
-}
-
-._block {
-	@extend ._panel;
-
-	& + ._block {
-		margin-top: var(--margin);
-	}
-}
-
-._gap {
-	margin: var(--margin) 0;
-}
-
-// TODO: 廃止
-._card {
-	@extend ._panel;
-
-	// TODO: _cardTitle に
-	> ._title {
-		margin: 0;
-		padding: 22px 32px;
-		font-size: 1em;
-		border-bottom: solid 1px var(--panelHeaderDivider);
-		font-weight: bold;
-		background: var(--panelHeaderBg);
-		color: var(--panelHeaderFg);
-
-		@media (max-width: 500px) {
-			padding: 16px;
-			font-size: 1em;
-		}
-	}
-
-	// TODO: _cardContent に
-	> ._content {
-		padding: 32px;
-
-		@media (max-width: 500px) {
-			padding: 16px;
-		}
-
-		&._noPad {
-			padding: 0 !important;
-		}
-
-		& + ._content {
-			border-top: solid 0.5px var(--divider);
-		}
-	}
-
-	// TODO: _cardFooter に
-	> ._footer {
-		border-top: solid 0.5px var(--divider);
-		padding: 24px 32px;
-
-		@media (max-width: 500px) {
-			padding: 16px;
-		}
-	}
-}
-
-._borderButton {
-	@extend ._button;
-	display: block;
-	width: 100%;
-	padding: 10px;
-	box-sizing: border-box;
-	text-align: center;
-	border: solid 0.5px var(--divider);
-	border-radius: var(--radius);
-
-	&:active {
-		border-color: var(--accent);
-	}
-}
-
-._popup {
-	background: var(--popup);
-	border-radius: var(--radius);
-	contain: content;
-}
-
-// TODO: 廃止
-._monolithic_ {
-	._section:not(:empty) {
-		box-sizing: border-box;
-		padding: var(--root-margin, 32px);
-	
-		@media (max-width: 500px) {
-			--root-margin: 10px;
-		}
-	
-		& + ._section:not(:empty) {
-			border-top: solid 0.5px var(--divider);
-		}
-	}
-}
-
-._narrow_ ._card {
-	> ._title {
-		padding: 16px;
-		font-size: 1em;
-	}
-
-	> ._content {
-		padding: 16px;
-	}
-
-	> ._footer {
-		padding: 16px;
-	}
-}
-
-._acrylic {
-	background: var(--acrylicPanel);
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-}
-
-._formBlock {
-	margin: 1.5em 0;
-}
-
-._formRoot {
-	> ._formBlock:first-child {
-		margin-top: 0;
-	}
-
-	> ._formBlock:last-child {
-		margin-bottom: 0;
-	}
-}
-
-._formLinksGrid {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-	grid-gap: 12px;
-}
-
-._formLinks {
-	> *:not(:last-child) {
-		margin-bottom: 8px;
-	}
-}
-
-._beta {
-	margin-left: 0.7em;
-	font-size: 65%;
-	padding: 2px 3px;
-	color: var(--accent);
-	border: solid 1px var(--accent);
-	border-radius: 4px;
-	vertical-align: top;
-}
-
-._table {
-	> ._row {
-		display: flex;
-
-		&:not(:last-child) {
-			margin-bottom: 16px;
-
-			@media (max-width: 500px) {
-				margin-bottom: 8px;
-			}
-		}
-
-		> ._cell {
-			flex: 1;
-
-			> ._label {
-				font-size: 80%;
-				opacity: 0.7;
-
-				> ._icon {
-					margin-right: 4px;
-					display: none;
-				}
-			}
-		}
-	}
-}
-
-._fullinfo {
-	padding: 64px 32px;
-	text-align: center;
-
-	> img {
-		vertical-align: bottom;
-		height: 128px;
-		margin-bottom: 16px;
-		border-radius: 16px;
-	}
-}
-
-._keyValue {
-	display: flex;
-
-	> * {
-		flex: 1;
-	}
-}
-
-._link {
-	color: var(--link);
-}
-
-._caption {
-	font-size: 0.8em;
-	opacity: 0.7;
-}
-
-._monospace {
-	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
-}
-
-._code {
-	@extend ._monospace;
-	background: #2d2d2d;
-	color: #ccc;
-	font-size: 14px;
-	line-height: 1.5;
-	padding: 5px;
-}
-
-.prism-editor__textarea:focus {
-	outline: none;
-}
-
-._zoom {
-	transition-duration: 0.5s, 0.5s;
-	transition-property: opacity, transform;
-	transition-timing-function: cubic-bezier(0,.5,.5,1);
-}
-
-.zoom-enter-active, .zoom-leave-active {
-	transition: opacity 0.5s, transform 0.5s !important;
-}
-.zoom-enter-from, .zoom-leave-to {
-	opacity: 0;
-	transform: scale(0.9);
-}
-
-@keyframes blink {
-	0% { opacity: 1; transform: scale(1); }
-	30% { opacity: 1; transform: scale(1); }
-	90% { opacity: 0; transform: scale(0.5); }
-}
-
-@keyframes tada {
-	from {
-		transform: scale3d(1, 1, 1);
-	}
-
-	10%,
-	20% {
-		transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg);
-	}
-
-	30%,
-	50%,
-	70%,
-	90% {
-		transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
-	}
-
-	40%,
-	60%,
-	80% {
-		transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
-	}
-
-	to {
-		transform: scale3d(1, 1, 1);
-	}
-}
-
-._anime_bounce {
-	will-change: transform;
-  animation: bounce ease 0.7s;
-  animation-iteration-count: 1;
-  transform-origin: 50% 50%;
-}
-._anime_bounce_ready {
-	will-change: transform;
-	transform:  scaleX(0.90) scaleY(0.90) ;
-}
-._anime_bounce_standBy {
-	transition: transform 0.1s ease;
-}
-
-@keyframes bounce{
-  0% {
-    transform:  scaleX(0.90) scaleY(0.90) ;
-  }
-  19% {
-    transform:  scaleX(1.10) scaleY(1.10) ;
-  }
-  48% {
-    transform:  scaleX(0.95) scaleY(0.95) ;
-  }
-  100% {
-    transform:  scaleX(1.00) scaleY(1.00) ;
-  }
-}
diff --git a/packages/client/src/theme-store.ts b/packages/client/src/theme-store.ts
deleted file mode 100644
index fdc92ed793..0000000000
--- a/packages/client/src/theme-store.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { api } from '@/os';
-import { $i } from '@/account';
-import { Theme } from './scripts/theme';
-
-const lsCacheKey = $i ? `themes:${$i.id}` : '';
-
-export function getThemes(): Theme[] {
-	return JSON.parse(localStorage.getItem(lsCacheKey) || '[]');
-}
-
-export async function fetchThemes(): Promise<void> {
-	if ($i == null) return;
-
-	try {
-		const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' });
-		localStorage.setItem(lsCacheKey, JSON.stringify(themes));
-	} catch (err) {
-		if (err.code === 'NO_SUCH_KEY') return;
-		throw err;
-	}
-}
-
-export async function addTheme(theme: Theme): Promise<void> {
-	await fetchThemes();
-	const themes = getThemes().concat(theme);
-	await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
-	localStorage.setItem(lsCacheKey, JSON.stringify(themes));
-}
-
-export async function removeTheme(theme: Theme): Promise<void> {
-	const themes = getThemes().filter(t => t.id !== theme.id);
-	await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
-	localStorage.setItem(lsCacheKey, JSON.stringify(themes));
-}
diff --git a/packages/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5
deleted file mode 100644
index 88ec8a5459..0000000000
--- a/packages/client/src/themes/_dark.json5
+++ /dev/null
@@ -1,99 +0,0 @@
-// ダークテーマのベーステーマ
-// このテーマが直接使われることは無い
-{
-	id: 'dark',
-
-	name: 'Dark',
-	author: 'syuilo',
-	desc: 'Default dark theme',
-	kind: 'dark',
-
-	props: {
-		accent: '#86b300',
-		accentDarken: ':darken<10<@accent',
-		accentLighten: ':lighten<10<@accent',
-		accentedBg: ':alpha<0.15<@accent',
-		focus: ':alpha<0.3<@accent',
-		bg: '#000',
-		acrylicBg: ':alpha<0.5<@bg',
-		fg: '#dadada',
-		fgTransparentWeak: ':alpha<0.75<@fg',
-		fgTransparent: ':alpha<0.5<@fg',
-		fgHighlighted: ':lighten<3<@fg',
-		fgOnAccent: '#fff',
-		divider: 'rgba(255, 255, 255, 0.1)',
-		indicator: '@accent',
-		panel: ':lighten<3<@bg',
-		panelHighlight: ':lighten<3<@panel',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
-		panelBorder: '" solid 1px var(--divider)',
-		acrylicPanel: ':alpha<0.5<@panel',
-		windowHeader: ':alpha<0.85<@panel',
-		popup: ':lighten<3<@panel',
-		shadow: 'rgba(0, 0, 0, 0.3)',
-		header: ':alpha<0.7<@panel',
-		navBg: '@panel',
-		navFg: '@fg',
-		navHoverFg: ':lighten<17<@fg',
-		navActive: '@accent',
-		navIndicator: '@indicator',
-		link: '#44a4c1',
-		hashtag: '#ff9156',
-		mention: '@accent',
-		mentionMe: '@mention',
-		renote: '#229e82',
-		modalBg: 'rgba(0, 0, 0, 0.5)',
-		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
-		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
-		dateLabelFg: '@fg',
-		infoBg: '#253142',
-		infoFg: '#fff',
-		infoWarnBg: '#42321c',
-		infoWarnFg: '#ffbd3e',
-		switchBg: 'rgba(255, 255, 255, 0.15)',
-		cwBg: '#687390',
-		cwFg: '#393f4f',
-		cwHoverBg: '#707b97',
-		buttonBg: 'rgba(255, 255, 255, 0.05)',
-		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':hue<20<@accent',
-		swutchOffBg: 'rgba(255, 255, 255, 0.1)',
-		swutchOffFg: '@fg',
-		swutchOnBg: '@accentedBg',
-		swutchOnFg: '@accent',
-		inputBorder: 'rgba(255, 255, 255, 0.1)',
-		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
-		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
-		driveFolderBg: ':alpha<0.3<@accent',
-		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
-		badge: '#31b1ce',
-		messageBg: '@bg',
-		success: '#86b300',
-		error: '#ec4137',
-		warn: '#ecb637',
-		codeString: '#ffb675',
-		codeNumber: '#cfff9e',
-		codeBoolean: '#c59eff',
-		deckDivider: '#000',
-		htmlThemeColor: '@bg',
-		X2: ':darken<2<@panel',
-		X3: 'rgba(255, 255, 255, 0.05)',
-		X4: 'rgba(255, 255, 255, 0.1)',
-		X5: 'rgba(255, 255, 255, 0.05)',
-		X6: 'rgba(255, 255, 255, 0.15)',
-		X7: 'rgba(255, 255, 255, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.3)',
-		X12: 'rgba(255, 255, 255, 0.1)',
-		X13: 'rgba(255, 255, 255, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-		X17: ':alpha<0.8<@bg',
-	},
-}
diff --git a/packages/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5
deleted file mode 100644
index bad1291c83..0000000000
--- a/packages/client/src/themes/_light.json5
+++ /dev/null
@@ -1,99 +0,0 @@
-// ライトテーマのベーステーマ
-// このテーマが直接使われることは無い
-{
-	id: 'light',
-
-	name: 'Light',
-	author: 'syuilo',
-	desc: 'Default light theme',
-	kind: 'light',
-
-	props: {
-		accent: '#86b300',
-		accentDarken: ':darken<10<@accent',
-		accentLighten: ':lighten<10<@accent',
-		accentedBg: ':alpha<0.15<@accent',
-		focus: ':alpha<0.3<@accent',
-		bg: '#fff',
-		acrylicBg: ':alpha<0.5<@bg',
-		fg: '#5f5f5f',
-		fgTransparentWeak: ':alpha<0.75<@fg',
-		fgTransparent: ':alpha<0.5<@fg',
-		fgHighlighted: ':darken<3<@fg',
-		fgOnAccent: '#fff',
-		divider: 'rgba(0, 0, 0, 0.1)',
-		indicator: '@accent',
-		panel: ':lighten<3<@bg',
-		panelHighlight: ':darken<3<@panel',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
-		panelBorder: '" solid 1px var(--divider)',
-		acrylicPanel: ':alpha<0.5<@panel',
-		windowHeader: ':alpha<0.85<@panel',
-		popup: ':lighten<3<@panel',
-		shadow: 'rgba(0, 0, 0, 0.1)',
-		header: ':alpha<0.7<@panel',
-		navBg: '@panel',
-		navFg: '@fg',
-		navHoverFg: ':darken<17<@fg',
-		navActive: '@accent',
-		navIndicator: '@indicator',
-		link: '#44a4c1',
-		hashtag: '#ff9156',
-		mention: '@accent',
-		mentionMe: '@mention',
-		renote: '#229e82',
-		modalBg: 'rgba(0, 0, 0, 0.3)',
-		scrollbarHandle: 'rgba(0, 0, 0, 0.2)',
-		scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)',
-		dateLabelFg: '@fg',
-		infoBg: '#e5f5ff',
-		infoFg: '#72818a',
-		infoWarnBg: '#fff0db',
-		infoWarnFg: '#8f6e31',
-		switchBg: 'rgba(0, 0, 0, 0.15)',
-		cwBg: '#b1b9c1',
-		cwFg: '#fff',
-		cwHoverBg: '#bbc4ce',
-		buttonBg: 'rgba(0, 0, 0, 0.05)',
-		buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':hue<20<@accent',
-		swutchOffBg: 'rgba(0, 0, 0, 0.1)',
-		swutchOffFg: '@panel',
-		swutchOnBg: '@accent',
-		swutchOnFg: '@fgOnAccent',
-		inputBorder: 'rgba(0, 0, 0, 0.1)',
-		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
-		listItemHoverBg: 'rgba(0, 0, 0, 0.03)',
-		driveFolderBg: ':alpha<0.3<@accent',
-		wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
-		badge: '#31b1ce',
-		messageBg: '@bg',
-		success: '#86b300',
-		error: '#ec4137',
-		warn: '#ecb637',
-		codeString: '#b98710',
-		codeNumber: '#0fbbbb',
-		codeBoolean: '#62b70c',
-		deckDivider: ':darken<3<@bg',
-		htmlThemeColor: '@bg',
-		X2: ':darken<2<@panel',
-		X3: 'rgba(0, 0, 0, 0.05)',
-		X4: 'rgba(0, 0, 0, 0.1)',
-		X5: 'rgba(0, 0, 0, 0.05)',
-		X6: 'rgba(0, 0, 0, 0.25)',
-		X7: 'rgba(0, 0, 0, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.1)',
-		X12: 'rgba(0, 0, 0, 0.1)',
-		X13: 'rgba(0, 0, 0, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-		X17: ':alpha<0.8<@bg',
-	},
-}
diff --git a/packages/client/src/themes/d-astro.json5 b/packages/client/src/themes/d-astro.json5
deleted file mode 100644
index c6a927ec3a..0000000000
--- a/packages/client/src/themes/d-astro.json5
+++ /dev/null
@@ -1,78 +0,0 @@
-{
-	id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea',
-	base: 'dark',
-	name: 'Mi Astro Dark',
-	author: 'syuilo',
-	props: {
-		bg: '#232125',
-		fg: '#efdab9',
-		cwBg: '#687390',
-		cwFg: '#393f4f',
-		link: '#78b0a0',
-		warn: '#ecb637',
-		badge: '#31b1ce',
-		error: '#ec4137',
-		focus: ':alpha<0.3<@accent',
-		navBg: '@panel',
-		navFg: '@fg',
-		panel: '#2a272b',
-		accent: '#81c08b',
-		header: ':alpha<0.7<@bg',
-		infoBg: '#253142',
-		infoFg: '#fff',
-		renote: '#659CC8',
-		shadow: 'rgba(0, 0, 0, 0.3)',
-		divider: 'rgba(255, 255, 255, 0.1)',
-		hashtag: '#ff9156',
-		mention: '#ffd152',
-		modalBg: 'rgba(0, 0, 0, 0.5)',
-		success: '#86b300',
-		buttonBg: 'rgba(255, 255, 255, 0.05)',
-		acrylicBg: ':alpha<0.5<@bg',
-		cwHoverBg: '#707b97',
-		indicator: '@accent',
-		mentionMe: '#fb5d38',
-		messageBg: '@bg',
-		navActive: '@accent',
-		infoWarnBg: '#42321c',
-		infoWarnFg: '#ffbd3e',
-		navHoverFg: ':lighten<17<@fg',
-		dateLabelFg: '@fg',
-		inputBorder: 'rgba(255, 255, 255, 0.1)',
-		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
-		panelBorder: '" solid 1px var(--divider)',
-		accentDarken: ':darken<10<@accent',
-		acrylicPanel: ':alpha<0.5<@panel',
-		navIndicator: '@accent',
-		accentLighten: ':lighten<10<@accent',
-		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':hue<-20<@accent',
-		driveFolderBg: ':alpha<0.3<@accent',
-		fgHighlighted: ':lighten<3<@fg',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		htmlThemeColor: '@bg',
-		panelHighlight: ':lighten<3<@panel',
-		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
-		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
-		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
-		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
-		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
-		X2: ':darken<2<@panel',
-		X3: 'rgba(255, 255, 255, 0.05)',
-		X4: 'rgba(255, 255, 255, 0.1)',
-		X5: 'rgba(255, 255, 255, 0.05)',
-		X6: 'rgba(255, 255, 255, 0.15)',
-		X7: 'rgba(255, 255, 255, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.3)',
-		X12: 'rgba(255, 255, 255, 0.1)',
-		X13: 'rgba(255, 255, 255, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-	},
-}
diff --git a/packages/client/src/themes/d-botanical.json5 b/packages/client/src/themes/d-botanical.json5
deleted file mode 100644
index c03b95e2d7..0000000000
--- a/packages/client/src/themes/d-botanical.json5
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-	id: '504debaf-4912-6a4c-5059-1db08a76b737',
-
-	name: 'Mi Botanical Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: 'rgb(148, 179, 0)',
-		bg: 'rgb(37, 38, 36)',
-		fg: 'rgb(216, 212, 199)',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: 'rgb(47, 47, 44)',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		header: ':alpha<0.7<@panel',
-		navBg: '#363636',
-		renote: '@accent',
-		mention: 'rgb(212, 153, 76)',
-		mentionMe: 'rgb(212, 210, 76)',
-		hashtag: '#5bcbb0',
-		link: '@accent',
-	},
-}
diff --git a/packages/client/src/themes/d-cherry.json5 b/packages/client/src/themes/d-cherry.json5
deleted file mode 100644
index a7e1ad1c80..0000000000
--- a/packages/client/src/themes/d-cherry.json5
+++ /dev/null
@@ -1,20 +0,0 @@
-{
-	id: '679b3b87-a4e9-4789-8696-b56c15cc33b0',
-
-	name: 'Mi Cherry Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: 'rgb(255, 89, 117)',
-		bg: 'rgb(28, 28, 37)',
-		fg: 'rgb(236, 239, 244)',
-		panel: 'rgb(35, 35, 47)',
-		renote: '@accent',
-		link: '@accent',
-		mention: '@accent',
-		hashtag: '@accent',
-		divider: 'rgb(63, 63, 80)',
-	},
-}
diff --git a/packages/client/src/themes/d-dark.json5 b/packages/client/src/themes/d-dark.json5
deleted file mode 100644
index d24ce4df69..0000000000
--- a/packages/client/src/themes/d-dark.json5
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-	id: '8050783a-7f63-445a-b270-36d0f6ba1677',
-
-	name: 'Mi Dark',
-	author: 'syuilo',
-	desc: 'Default light theme',
-
-	base: 'dark',
-
-	props: {
-		bg: '#232323',
-		fg: 'rgb(199, 209, 216)',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '#2d2d2d',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		header: ':alpha<0.7<@panel',
-		navBg: '#363636',
-		renote: '@accent',
-		mention: '#da6d35',
-		mentionMe: '#d44c4c',
-		hashtag: '#4cb8d4',
-		link: '@accent',
-	},
-}
diff --git a/packages/client/src/themes/d-future.json5 b/packages/client/src/themes/d-future.json5
deleted file mode 100644
index b6fa1ab0c1..0000000000
--- a/packages/client/src/themes/d-future.json5
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-	id: '32a637ef-b47a-4775-bb7b-bacbb823f865',
-
-	name: 'Mi Future Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: '#63e2b7',
-		bg: '#101014',
-		fg: '#D5D5D6',
-		fgHighlighted: '#fff',
-		fgOnAccent: '#000',
-		divider: 'rgba(255, 255, 255, 0.1)',
-		panel: '#18181c',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		renote: '@accent',
-		mention: '#f2c97d',
-		mentionMe: '@accent',
-		hashtag: '#70c0e8',
-		link: '#e88080',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':saturate<30<:hue<30<@accent',
-	},
-}
diff --git a/packages/client/src/themes/d-green-lime.json5 b/packages/client/src/themes/d-green-lime.json5
deleted file mode 100644
index a6983b9ac2..0000000000
--- a/packages/client/src/themes/d-green-lime.json5
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-	id: '02816013-8107-440f-877e-865083ffe194',
-
-	name: 'Mi Green+Lime Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: '#b4e900',
-		bg: '#0C1210',
-		fg: '#dee7e4',
-		fgHighlighted: '#fff',
-		fgOnAccent: '#192320',
-		divider: '#e7fffb24',
-		panel: '#192320',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		popup: '#293330',
-		renote: '@accent',
-		mentionMe: '#ffaa00',
-		link: '#24d7ce',
-	},
-}
diff --git a/packages/client/src/themes/d-green-orange.json5 b/packages/client/src/themes/d-green-orange.json5
deleted file mode 100644
index 62adc39e29..0000000000
--- a/packages/client/src/themes/d-green-orange.json5
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-	id: 'dc489603-27b5-424a-9b25-1ff6aec9824a',
-
-	name: 'Mi Green+Orange Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: '#e97f00',
-		bg: '#0C1210',
-		fg: '#dee7e4',
-		fgHighlighted: '#fff',
-		fgOnAccent: '#192320',
-		divider: '#e7fffb24',
-		panel: '#192320',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		popup: '#293330',
-		renote: '@accent',
-		mentionMe: '#b4e900',
-		link: '#24d7ce',
-	},
-}
diff --git a/packages/client/src/themes/d-ice.json5 b/packages/client/src/themes/d-ice.json5
deleted file mode 100644
index 179b060dcf..0000000000
--- a/packages/client/src/themes/d-ice.json5
+++ /dev/null
@@ -1,13 +0,0 @@
-{
-	id: '66e7e5a9-cd43-42cd-837d-12f47841fa34',
-
-	name: 'Mi Ice Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: '#47BFE8',
-		bg: '#212526',
-	},
-}
diff --git a/packages/client/src/themes/d-persimmon.json5 b/packages/client/src/themes/d-persimmon.json5
deleted file mode 100644
index e36265ff10..0000000000
--- a/packages/client/src/themes/d-persimmon.json5
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-	id: 'c503d768-7c70-4db2-a4e6-08264304bc8d',
-
-	name: 'Mi Persimmon Dark',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: 'rgb(206, 102, 65)',
-		bg: 'rgb(31, 33, 31)',
-		fg: '#cdd8c7',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: 'rgb(41, 43, 41)',
-		infoFg: '@fg',
-		infoBg: '#333c3b',
-		navBg: '#141714',
-		renote: '@accent',
-		mention: '@accent',
-		mentionMe: '#de6161',
-		hashtag: '#68bad0',
-		link: '#a1c758',
-	},
-}
diff --git a/packages/client/src/themes/d-u0.json5 b/packages/client/src/themes/d-u0.json5
deleted file mode 100644
index b270f809ac..0000000000
--- a/packages/client/src/themes/d-u0.json5
+++ /dev/null
@@ -1,88 +0,0 @@
-{
-	id: '7a5bc13b-df8f-4d44-8e94-4452f0c634bb',
-	base: 'dark',
-	name: 'Mi U0 Dark',
-	props: {
-		X2: ':darken<2<@panel',
-		X3: 'rgba(255, 255, 255, 0.05)',
-		X4: 'rgba(255, 255, 255, 0.1)',
-		X5: 'rgba(255, 255, 255, 0.05)',
-		X6: 'rgba(255, 255, 255, 0.15)',
-		X7: 'rgba(255, 255, 255, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		bg: '#172426',
-		fg: '#dadada',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.3)',
-		X12: 'rgba(255, 255, 255, 0.1)',
-		X13: 'rgba(255, 255, 255, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-		X17: ':alpha<0.8<@bg',
-		cwBg: '#687390',
-		cwFg: '#393f4f',
-		link: '@accent',
-		warn: '#ecb637',
-		badge: '#31b1ce',
-		error: '#ec4137',
-		focus: ':alpha<0.3<@accent',
-		navBg: '@panel',
-		navFg: '@fg',
-		panel: ':lighten<3<@bg',
-		popup: ':lighten<3<@panel',
-		accent: '#00a497',
-		header: ':alpha<0.7<@panel',
-		infoBg: '#253142',
-		infoFg: '#fff',
-		renote: '@accent',
-		shadow: 'rgba(0, 0, 0, 0.3)',
-		divider: 'rgba(255, 255, 255, 0.1)',
-		hashtag: '#e6b422',
-		mention: '@accent',
-		modalBg: 'rgba(0, 0, 0, 0.5)',
-		success: '#86b300',
-		buttonBg: 'rgba(255, 255, 255, 0.05)',
-		switchBg: 'rgba(255, 255, 255, 0.15)',
-		acrylicBg: ':alpha<0.5<@bg',
-		cwHoverBg: '#707b97',
-		indicator: '@accent',
-		mentionMe: '@mention',
-		messageBg: '@bg',
-		navActive: '@accent',
-		accentedBg: ':alpha<0.15<@accent',
-		codeNumber: '#cfff9e',
-		codeString: '#ffb675',
-		fgOnAccent: '#fff',
-		infoWarnBg: '#42321c',
-		infoWarnFg: '#ffbd3e',
-		navHoverFg: ':lighten<17<@fg',
-		codeBoolean: '#c59eff',
-		dateLabelFg: '@fg',
-		inputBorder: 'rgba(255, 255, 255, 0.1)',
-		panelBorder: '" solid 1px var(--divider)',
-		accentDarken: ':darken<10<@accent',
-		acrylicPanel: ':alpha<0.5<@panel',
-		navIndicator: '@indicator',
-		accentLighten: ':lighten<10<@accent',
-		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
-		driveFolderBg: ':alpha<0.3<@accent',
-		fgHighlighted: ':lighten<3<@fg',
-		fgTransparent: ':alpha<0.5<@fg',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':hue<20<@accent',
-		htmlThemeColor: '@bg',
-		panelHighlight: ':lighten<3<@panel',
-		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
-		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
-		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
-		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
-		fgTransparentWeak: ':alpha<0.75<@fg',
-		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
-		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
-		deckDivider: '#142022',
-	},
-}
diff --git a/packages/client/src/themes/l-apricot.json5 b/packages/client/src/themes/l-apricot.json5
deleted file mode 100644
index 1ed5525575..0000000000
--- a/packages/client/src/themes/l-apricot.json5
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-	id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b',
-
-	name: 'Mi Apricot Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: 'rgb(234, 154, 82)',
-		bg: '#e6e5e2',
-		fg: 'rgb(149, 143, 139)',
-		panel: '#EEECE8',
-		renote: '@accent',
-		link: '@accent',
-		mention: '@accent',
-		hashtag: '@accent',
-		inputBorder: 'rgba(0, 0, 0, 0.1)',
-		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
-		infoBg: 'rgb(226, 235, 241)',
-	},
-}
diff --git a/packages/client/src/themes/l-cherry.json5 b/packages/client/src/themes/l-cherry.json5
deleted file mode 100644
index 5ad240241e..0000000000
--- a/packages/client/src/themes/l-cherry.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: 'ac168876-f737-4074-a3fc-a370c732ef48',
-
-	name: 'Mi Cherry Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: 'rgb(219, 96, 114)',
-		bg: 'rgb(254, 248, 249)',
-		fg: 'rgb(152, 13, 26)',
-		panel: 'rgb(255, 255, 255)',
-		renote: '@accent',
-		link: 'rgb(156, 187, 5)',
-		mention: '@accent',
-		hashtag: '@accent',
-		divider: 'rgba(134, 51, 51, 0.1)',
-		inputBorderHover: 'rgb(238, 221, 222)',
-	},
-}
diff --git a/packages/client/src/themes/l-coffee.json5 b/packages/client/src/themes/l-coffee.json5
deleted file mode 100644
index fbcd4fa9ef..0000000000
--- a/packages/client/src/themes/l-coffee.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: '6ed80faa-74f0-42c2-98e4-a64d9e138eab',
-
-	name: 'Mi Coffee Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#9f8989',
-		bg: '#f5f3f3',
-		fg: '#7f6666',
-		panel: '#fff',
-		divider: 'rgba(87, 68, 68, 0.1)',
-		renote: 'rgb(160, 172, 125)',
-		link: 'rgb(137, 151, 159)',
-		mention: '@accent',
-		mentionMe: 'rgb(170, 149, 98)',
-		hashtag: '@accent',
-	},
-}
diff --git a/packages/client/src/themes/l-light.json5 b/packages/client/src/themes/l-light.json5
deleted file mode 100644
index 248355c945..0000000000
--- a/packages/client/src/themes/l-light.json5
+++ /dev/null
@@ -1,20 +0,0 @@
-{
-	id: '4eea646f-7afa-4645-83e9-83af0333cd37',
-
-	name: 'Mi Light',
-	author: 'syuilo',
-	desc: 'Default light theme',
-
-	base: 'light',
-
-	props: {
-		bg: '#f9f9f9',
-		fg: '#676767',
-		divider: '#e8e8e8',
-		header: ':alpha<0.7<@panel',
-		navBg: '#fff',
-		panel: '#fff',
-		panelHeaderDivider: '@divider',
-		mentionMe: 'rgb(0, 179, 70)',
-	},
-}
diff --git a/packages/client/src/themes/l-rainy.json5 b/packages/client/src/themes/l-rainy.json5
deleted file mode 100644
index 283dd74c6c..0000000000
--- a/packages/client/src/themes/l-rainy.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96',
-
-	name: 'Mi Rainy Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#5db0da',
-		bg: 'rgb(246 248 249)',
-		fg: '#636b71',
-		panel: '#fff',
-		divider: 'rgb(230 233 234)',
-		panelHeaderDivider: '@divider',
-		renote: '@accent',
-		link: '@accent',
-		mention: '@accent',
-		hashtag: '@accent',
-	},
-}
diff --git a/packages/client/src/themes/l-sushi.json5 b/packages/client/src/themes/l-sushi.json5
deleted file mode 100644
index 5846927d65..0000000000
--- a/packages/client/src/themes/l-sushi.json5
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-	id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c',
-
-	name: 'Mi Sushi Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#e36749',
-		bg: '#f0eee9',
-		fg: '#5f5f5f',
-		renote: '@accent',
-		link: '@accent',
-		mention: '@accent',
-		hashtag: '#229e82',
-	},
-}
diff --git a/packages/client/src/themes/l-u0.json5 b/packages/client/src/themes/l-u0.json5
deleted file mode 100644
index 03b114ba39..0000000000
--- a/packages/client/src/themes/l-u0.json5
+++ /dev/null
@@ -1,87 +0,0 @@
-{
-	id: 'e2c940b5-6e9a-4c03-b738-261c720c426d',
-	base: 'light',
-	name: 'Mi U0 Light',
-	props: {
-		X2: ':darken<2<@panel',
-		X3: 'rgba(255, 255, 255, 0.05)',
-		X4: 'rgba(255, 255, 255, 0.1)',
-		X5: 'rgba(255, 255, 255, 0.05)',
-		X6: 'rgba(255, 255, 255, 0.15)',
-		X7: 'rgba(255, 255, 255, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		bg: '#e7e7eb',
-		fg: '#5f5f5f',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.3)',
-		X12: 'rgba(255, 255, 255, 0.1)',
-		X13: 'rgba(255, 255, 255, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-		X17: ':alpha<0.8<@bg',
-		cwBg: '#687390',
-		cwFg: '#393f4f',
-		link: '@accent',
-		warn: '#ecb637',
-		badge: '#31b1ce',
-		error: '#ec4137',
-		focus: ':alpha<0.3<@accent',
-		navBg: '@panel',
-		navFg: '@fg',
-		panel: ':lighten<3<@bg',
-		popup: ':lighten<3<@panel',
-		accent: '#478384',
-		header: ':alpha<0.7<@panel',
-		infoBg: '#253142',
-		infoFg: '#fff',
-		renote: '@accent',
-		shadow: 'rgba(0, 0, 0, 0.3)',
-		divider: '#4646461a',
-		hashtag: '#1f3134',
-		mention: '@accent',
-		modalBg: 'rgba(0, 0, 0, 0.5)',
-		success: '#86b300',
-		buttonBg: '#0000000d',
-		switchBg: 'rgba(255, 255, 255, 0.15)',
-		acrylicBg: ':alpha<0.5<@bg',
-		cwHoverBg: '#707b97',
-		indicator: '@accent',
-		mentionMe: '@mention',
-		messageBg: '@bg',
-		navActive: '@accent',
-		accentedBg: ':alpha<0.15<@accent',
-		codeNumber: '#cfff9e',
-		codeString: '#ffb675',
-		fgOnAccent: '#fff',
-		infoWarnBg: '#42321c',
-		infoWarnFg: '#ffbd3e',
-		navHoverFg: ':lighten<17<@fg',
-		codeBoolean: '#c59eff',
-		dateLabelFg: '@fg',
-		inputBorder: 'rgba(255, 255, 255, 0.1)',
-		panelBorder: '" solid 1px var(--divider)',
-		accentDarken: ':darken<10<@accent',
-		acrylicPanel: ':alpha<0.5<@panel',
-		navIndicator: '@indicator',
-		accentLighten: ':lighten<10<@accent',
-		buttonHoverBg: '#0000001a',
-		driveFolderBg: ':alpha<0.3<@accent',
-		fgHighlighted: ':lighten<3<@fg',
-		fgTransparent: ':alpha<0.5<@fg',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		buttonGradateA: '@accent',
-		buttonGradateB: ':hue<20<@accent',
-		htmlThemeColor: '@bg',
-		panelHighlight: ':lighten<3<@panel',
-		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
-		scrollbarHandle: '#74747433',
-		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
-		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
-		fgTransparentWeak: ':alpha<0.75<@fg',
-		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
-		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
-	},
-}
diff --git a/packages/client/src/themes/l-vivid.json5 b/packages/client/src/themes/l-vivid.json5
deleted file mode 100644
index b3c08f38ae..0000000000
--- a/packages/client/src/themes/l-vivid.json5
+++ /dev/null
@@ -1,82 +0,0 @@
-{
-	id: '6128c2a9-5c54-43fe-a47d-17942356470b',
-
-	name: 'Mi Vivid Light',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		bg: '#fafafa',
-		fg: '#444',
-		cwBg: '#b1b9c1',
-		cwFg: '#fff',
-		link: '#ff9400',
-		warn: '#ecb637',
-		badge: '#31b1ce',
-		error: '#ec4137',
-		focus: ':alpha<0.3<@accent',
-		navBg: '@panel',
-		navFg: '@fg',
-		panel: '#fff',
-		accent: '#008cff',
-		header: ':alpha<0.7<@panel',
-		infoBg: '#e5f5ff',
-		infoFg: '#72818a',
-		renote: '@accent',
-		shadow: 'rgba(0, 0, 0, 0.1)',
-		divider: 'rgba(0, 0, 0, 0.08)',
-		hashtag: '#92d400',
-		mention: '@accent',
-		modalBg: 'rgba(0, 0, 0, 0.3)',
-		success: '#86b300',
-		buttonBg: 'rgba(0, 0, 0, 0.05)',
-		acrylicBg: ':alpha<0.5<@bg',
-		cwHoverBg: '#bbc4ce',
-		indicator: '@accent',
-		mentionMe: '@mention',
-		messageBg: '@bg',
-		navActive: '@accent',
-		infoWarnBg: '#fff0db',
-		infoWarnFg: '#8f6e31',
-		navHoverFg: ':darken<17<@fg',
-		dateLabelFg: '@fg',
-		inputBorder: 'rgba(0, 0, 0, 0.1)',
-		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
-		panelBorder: '" solid 1px var(--divider)',
-		accentDarken: ':darken<10<@accent',
-		acrylicPanel: ':alpha<0.5<@panel',
-		navIndicator: '@accent',
-		accentLighten: ':lighten<10<@accent',
-		buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
-		driveFolderBg: ':alpha<0.3<@accent',
-		fgHighlighted: ':darken<3<@fg',
-		fgTransparent: ':alpha<0.5<@fg',
-		panelHeaderBg: ':lighten<3<@panel',
-		panelHeaderFg: '@fg',
-		htmlThemeColor: '@bg',
-		panelHighlight: ':darken<3<@panel',
-		listItemHoverBg: 'rgba(0, 0, 0, 0.03)',
-		scrollbarHandle: 'rgba(0, 0, 0, 0.2)',
-		wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
-		fgTransparentWeak: ':alpha<0.75<@fg',
-		panelHeaderDivider: '@divider',
-		scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)',
-		X2: ':darken<2<@panel',
-		X3: 'rgba(0, 0, 0, 0.05)',
-		X4: 'rgba(0, 0, 0, 0.1)',
-		X5: 'rgba(0, 0, 0, 0.05)',
-		X6: 'rgba(0, 0, 0, 0.25)',
-		X7: 'rgba(0, 0, 0, 0.05)',
-		X8: ':lighten<5<@accent',
-		X9: ':darken<5<@accent',
-		X10: ':alpha<0.4<@accent',
-		X11: 'rgba(0, 0, 0, 0.1)',
-		X12: 'rgba(0, 0, 0, 0.1)',
-		X13: 'rgba(0, 0, 0, 0.15)',
-		X14: ':alpha<0.5<@navBg',
-		X15: ':alpha<0<@panel',
-		X16: ':alpha<0.7<@panel',
-		X17: ':alpha<0.8<@bg',
-	},
-}
diff --git a/packages/client/src/types/menu.ts b/packages/client/src/types/menu.ts
deleted file mode 100644
index 972f6db214..0000000000
--- a/packages/client/src/types/menu.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as Misskey from 'misskey-js';
-import { Ref } from 'vue';
-
-export type MenuAction = (ev: MouseEvent) => void;
-
-export type MenuDivider = null;
-export type MenuNull = undefined;
-export type MenuLabel = { type: 'label', text: string };
-export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
-export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
-export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
-export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean };
-export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction };
-export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] };
-
-export type MenuPending = { type: 'pending' };
-
-type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
-type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
-export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
-export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue
deleted file mode 100644
index 7f3fc0e4af..0000000000
--- a/packages/client/src/ui/_common_/common.vue
+++ /dev/null
@@ -1,139 +0,0 @@
-<template>
-<component
-	:is="popup.component"
-	v-for="popup in popups"
-	:key="popup.id"
-	v-bind="popup.props"
-	v-on="popup.events"
-/>
-
-<XUpload v-if="uploads.length > 0"/>
-
-<XStreamIndicator/>
-
-<div v-if="pendingApiRequestsCount > 0" id="wait"></div>
-
-<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
-
-<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import { swInject } from './sw-inject';
-import { popup, popups, pendingApiRequestsCount } from '@/os';
-import { uploads } from '@/scripts/upload';
-import * as sound from '@/scripts/sound';
-import { $i } from '@/account';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
-const XUpload = defineAsyncComponent(() => import('./upload.vue'));
-
-const dev = _DEV_;
-
-const onNotification = notification => {
-	if ($i.mutingNotificationTypes.includes(notification.type)) return;
-
-	if (document.visibilityState === 'visible') {
-		stream.send('readNotification', {
-			id: notification.id,
-		});
-
-		popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), {
-			notification,
-		}, {}, 'closed');
-	}
-
-	sound.play('notification');
-};
-
-if ($i) {
-	const connection = stream.useChannel('main', null, 'UI');
-	connection.on('notification', onNotification);
-
-	//#region Listen message from SW
-	if ('serviceWorker' in navigator) {
-		swInject();
-	}
-}
-</script>
-
-<style lang="scss">
-@keyframes dev-ticker-blink {
-	0% { opacity: 1; }
-	50% { opacity: 0; }
-	100% { opacity: 1; }
-}
-
-@keyframes progress-spinner {
-	0% {
-		transform: rotate(0deg);
-	}
-	100% {
-		transform: rotate(360deg);
-	}
-}
-
-#wait {
-	display: block;
-	position: fixed;
-	z-index: 4000000;
-	top: 15px;
-	right: 15px;
-
-	&:before {
-		content: "";
-		display: block;
-		width: 18px;
-		height: 18px;
-		box-sizing: border-box;
-		border: solid 2px transparent;
-		border-top-color: var(--accent);
-		border-left-color: var(--accent);
-		border-radius: 50%;
-		animation: progress-spinner 400ms linear infinite;
-	}
-}
-
-#botWarn {
-	position: fixed;
-	top: 0;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	margin: auto;
-	width: 100%;
-	height: max-content;
-	text-align: center;
-	z-index: 2147483647;
-	color: #ff0;
-	background: rgba(0, 0, 0, 0.5);
-	padding: 4px 7px;
-	font-size: 14px;
-	pointer-events: none;
-	user-select: none;
-
-	> span {
-		animation: dev-ticker-blink 2s infinite;
-	}
-}
-
-#devTicker {
-	position: fixed;
-	top: 0;
-	left: 0;
-	z-index: 2147483647;
-	color: #ff0;
-	background: rgba(0, 0, 0, 0.5);
-	padding: 4px 5px;
-	font-size: 14px;
-	pointer-events: none;
-	user-select: none;
-
-	> span {
-		animation: dev-ticker-blink 2s infinite;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/navbar-for-mobile.vue b/packages/client/src/ui/_common_/navbar-for-mobile.vue
deleted file mode 100644
index 50b28de063..0000000000
--- a/packages/client/src/ui/_common_/navbar-for-mobile.vue
+++ /dev/null
@@ -1,314 +0,0 @@
-<template>
-<div class="kmwsukvl">
-	<div class="body">
-		<div class="top">
-			<div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div>
-			<button v-click-anime class="item _button instance" @click="openInstanceMenu">
-				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
-			</button>
-		</div>
-		<div class="middle">
-			<MkA v-click-anime class="item index" active-class="active" to="/" exact>
-				<i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
-			</MkA>
-			<template v-for="item in menu">
-				<div v-if="item === '-'" class="divider"></div>
-				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
-					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
-					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
-				</component>
-			</template>
-			<div class="divider"></div>
-			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin">
-				<i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
-			</MkA>
-			<button v-click-anime class="item _button" @click="more">
-				<i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
-				<span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
-			</button>
-			<MkA v-click-anime class="item" active-class="active" to="/settings">
-				<i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
-			</MkA>
-		</div>
-		<div class="bottom">
-			<button class="item _button post" data-cy-open-post-form @click="os.post">
-				<i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
-			</button>
-			<button v-click-anime class="item _button account" @click="openAccountMenu">
-				<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
-			</button>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
-import { host } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { openAccountMenu as openAccountMenu_ } from '@/account';
-import { defaultStore } from '@/store';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const menu = toRef(defaultStore.state, 'menu');
-const otherMenuItemIndicated = computed(() => {
-	for (const def in navbarItemDef) {
-		if (menu.value.includes(def)) continue;
-		if (navbarItemDef[def].indicated) return true;
-	}
-	return false;
-});
-
-function openAccountMenu(ev: MouseEvent) {
-	openAccountMenu_({
-		withExtraOperation: true,
-	}, ev);
-}
-
-function openInstanceMenu(ev: MouseEvent) {
-	os.popupMenu([{
-		text: instance.name ?? host,
-		type: 'label',
-	}, {
-		type: 'link',
-		text: i18n.ts.instanceInfo,
-		icon: 'ti ti-info-circle',
-		to: '/about',
-	}, {
-		type: 'link',
-		text: i18n.ts.customEmojis,
-		icon: 'ti ti-mood-happy',
-		to: '/about#emojis',
-	}, {
-		type: 'link',
-		text: i18n.ts.federation,
-		icon: 'ti ti-whirl',
-		to: '/about#federation',
-	}, null, {
-		type: 'parent',
-		text: i18n.ts.help,
-		icon: 'ti ti-question-circle',
-		children: [{
-			type: 'link',
-			to: '/mfm-cheat-sheet',
-			text: i18n.ts._mfm.cheatSheet,
-			icon: 'ti ti-code',
-		}, {
-			type: 'link',
-			to: '/scratchpad',
-			text: i18n.ts.scratchpad,
-			icon: 'ti ti-terminal-2',
-		}, {
-			type: 'link',
-			to: '/api-console',
-			text: 'API Console',
-			icon: 'ti ti-terminal-2',
-		}, null, {
-			text: i18n.ts.document,
-			icon: 'ti ti-question-circle',
-			action: () => {
-				window.open('https://misskey-hub.net/help.html', '_blank');
-			},
-		}],
-	}, {
-		type: 'link',
-		text: i18n.ts.aboutMisskey,
-		to: '/about-misskey',
-	}], ev.currentTarget ?? ev.target, {
-		align: 'left',
-	});
-}
-
-function more() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
-	}, 'closed');
-}
-</script>
-
-<style lang="scss" scoped>
-.kmwsukvl {
-	> .body {
-		display: flex;
-		flex-direction: column;
-
-		> .top {
-			position: sticky;
-			top: 0;
-			z-index: 1;
-			padding: 20px 0;
-			background: var(--X14);
-			-webkit-backdrop-filter: var(--blur, blur(8px));
-			backdrop-filter: var(--blur, blur(8px));
-
-			> .banner {
-				position: absolute;
-				top: 0;
-				left: 0;
-				width: 100%;
-				height: 100%;
-				background-size: cover;
-				background-position: center center;
-				-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
-				mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
-			}
-
-			> .instance {
-				position: relative;
-				display: block;
-				text-align: center;
-				width: 100%;
-
-				> .icon {
-					display: inline-block;
-					width: 38px;
-					aspect-ratio: 1;
-				}
-			}
-		}
-
-		> .bottom {
-			position: sticky;
-			bottom: 0;
-			padding: 20px 0;
-			background: var(--X14);
-			-webkit-backdrop-filter: var(--blur, blur(8px));
-			backdrop-filter: var(--blur, blur(8px));
-
-			> .post {
-				position: relative;
-				display: block;
-				width: 100%;
-				height: 40px;
-				color: var(--fgOnAccent);
-				font-weight: bold;
-				text-align: left;
-
-				&:before {
-					content: "";
-					display: block;
-					width: calc(100% - 38px);
-					height: 100%;
-					margin: auto;
-					position: absolute;
-					top: 0;
-					left: 0;
-					right: 0;
-					bottom: 0;
-					border-radius: 999px;
-					background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
-				}
-
-				&:hover, &.active {
-					&:before {
-						background: var(--accentLighten);
-					}
-				}
-
-				> .icon {
-					position: relative;
-					margin-left: 30px;
-					margin-right: 8px;
-					width: 32px;
-				}
-
-				> .text {
-					position: relative;
-				}
-			}
-
-			> .account {
-				position: relative;
-				display: flex;
-				align-items: center;
-				padding-left: 30px;
-				text-overflow: ellipsis;
-				overflow: hidden;
-				white-space: nowrap;
-				width: 100%;
-				text-align: left;
-				box-sizing: border-box;
-				margin-top: 16px;
-
-				> .avatar {
-					position: relative;
-					width: 32px;
-					aspect-ratio: 1;
-					margin-right: 8px;
-				}
-			}
-		}
-
-		> .middle {
-			flex: 1;
-
-			> .divider {
-				margin: 16px 16px;
-				border-top: solid 0.5px var(--divider);
-			}
-
-			> .item {
-				position: relative;
-				display: block;
-				padding-left: 24px;
-				line-height: 2.85rem;
-				text-overflow: ellipsis;
-				overflow: hidden;
-				white-space: nowrap;
-				width: 100%;
-				text-align: left;
-				box-sizing: border-box;
-				color: var(--navFg);
-
-				> .icon {
-					position: relative;
-					width: 32px;
-					margin-right: 8px;
-				}
-
-				> .indicator {
-					position: absolute;
-					top: 0;
-					left: 20px;
-					color: var(--navIndicator);
-					font-size: 8px;
-					animation: blink 1s infinite;
-				}
-
-				> .text {
-					position: relative;
-					font-size: 0.9em;
-				}
-
-				&:hover {
-					text-decoration: none;
-					color: var(--navHoverFg);
-				}
-
-				&.active {
-					color: var(--navActive);
-				}
-
-				&:hover, &.active {
-					&:before {
-						content: "";
-						display: block;
-						width: calc(100% - 24px);
-						height: 100%;
-						margin: auto;
-						position: absolute;
-						top: 0;
-						left: 0;
-						right: 0;
-						bottom: 0;
-						border-radius: 999px;
-						background: var(--accentedBg);
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue
deleted file mode 100644
index b82da15f13..0000000000
--- a/packages/client/src/ui/_common_/navbar.vue
+++ /dev/null
@@ -1,521 +0,0 @@
-<template>
-<div class="mvcprjjd" :class="{ iconOnly }">
-	<div class="body">
-		<div class="top">
-			<div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div>
-			<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
-				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
-			</button>
-		</div>
-		<div class="middle">
-			<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact>
-				<i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
-			</MkA>
-			<template v-for="item in menu">
-				<div v-if="item === '-'" class="divider"></div>
-				<component
-					:is="navbarItemDef[item].to ? 'MkA' : 'button'"
-					v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
-					v-click-anime
-					v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
-					class="item _button"
-					:class="[item, { active: navbarItemDef[item].active }]"
-					active-class="active"
-					:to="navbarItemDef[item].to"
-					v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
-				>
-					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
-					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
-				</component>
-			</template>
-			<div class="divider"></div>
-			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin">
-				<i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
-			</MkA>
-			<button v-click-anime class="item _button" @click="more">
-				<i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
-				<span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
-			</button>
-			<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings">
-				<i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
-			</MkA>
-		</div>
-		<div class="bottom">
-			<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
-				<i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
-			</button>
-			<button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
-				<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
-			</button>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, watch } from 'vue';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-import { instance } from '@/instance';
-import { host } from '@/config';
-
-const iconOnly = ref(false);
-
-const menu = computed(() => defaultStore.state.menu);
-const otherMenuItemIndicated = computed(() => {
-	for (const def in navbarItemDef) {
-		if (menu.value.includes(def)) continue;
-		if (navbarItemDef[def].indicated) return true;
-	}
-	return false;
-});
-
-const calcViewState = () => {
-	iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon');
-};
-
-calcViewState();
-
-window.addEventListener('resize', calcViewState);
-
-watch(defaultStore.reactiveState.menuDisplay, () => {
-	calcViewState();
-});
-
-function openAccountMenu(ev: MouseEvent) {
-	openAccountMenu_({
-		withExtraOperation: true,
-	}, ev);
-}
-
-function openInstanceMenu(ev: MouseEvent) {
-	os.popupMenu([{
-		text: instance.name ?? host,
-		type: 'label',
-	}, {
-		type: 'link',
-		text: i18n.ts.instanceInfo,
-		icon: 'ti ti-info-circle',
-		to: '/about',
-	}, {
-		type: 'link',
-		text: i18n.ts.customEmojis,
-		icon: 'ti ti-mood-happy',
-		to: '/about#emojis',
-	}, {
-		type: 'link',
-		text: i18n.ts.federation,
-		icon: 'ti ti-whirl',
-		to: '/about#federation',
-	}, null, {
-		type: 'parent',
-		text: i18n.ts.help,
-		icon: 'ti ti-question-circle',
-		children: [{
-			type: 'link',
-			to: '/mfm-cheat-sheet',
-			text: i18n.ts._mfm.cheatSheet,
-			icon: 'ti ti-code',
-		}, {
-			type: 'link',
-			to: '/scratchpad',
-			text: i18n.ts.scratchpad,
-			icon: 'ti ti-terminal-2',
-		}, {
-			type: 'link',
-			to: '/api-console',
-			text: 'API Console',
-			icon: 'ti ti-terminal-2',
-		}, null, {
-			text: i18n.ts.document,
-			icon: 'ti ti-question-circle',
-			action: () => {
-				window.open('https://misskey-hub.net/help.html', '_blank');
-			},
-		}],
-	}, {
-		type: 'link',
-		text: i18n.ts.aboutMisskey,
-		to: '/about-misskey',
-	}], ev.currentTarget ?? ev.target, {
-		align: 'left',
-	});
-}
-
-function more(ev: MouseEvent) {
-	os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
-		src: ev.currentTarget ?? ev.target,
-	}, {
-	}, 'closed');
-}
-</script>
-
-<style lang="scss" scoped>
-.mvcprjjd {
-	$nav-width: 250px;
-	$nav-icon-only-width: 80px;
-
-	flex: 0 0 $nav-width;
-	width: $nav-width;
-	box-sizing: border-box;
-
-	> .body {
-		position: fixed;
-		top: 0;
-		left: 0;
-		z-index: 1001;
-		width: $nav-icon-only-width;
-		height: 100dvh;
-		box-sizing: border-box;
-		overflow: auto;
-		overflow-x: clip;
-		background: var(--navBg);
-		contain: strict;
-		display: flex;
-		flex-direction: column;
-	}
-
-	&:not(.iconOnly) {
-		> .body {
-			width: $nav-width;
-
-			> .top {
-				position: sticky;
-				top: 0;
-				z-index: 1;
-				padding: 20px 0;
-				background: var(--X14);
-				-webkit-backdrop-filter: var(--blur, blur(8px));
-				backdrop-filter: var(--blur, blur(8px));
-
-				> .banner {
-					position: absolute;
-					top: 0;
-					left: 0;
-					width: 100%;
-					height: 100%;
-					background-size: cover;
-					background-position: center center;
-					-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
-					mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
-				}
-
-				> .instance {
-					position: relative;
-					display: block;
-					text-align: center;
-					width: 100%;
-
-					> .icon {
-						display: inline-block;
-						width: 38px;
-						aspect-ratio: 1;
-					}
-				}
-			}
-
-			> .bottom {
-				position: sticky;
-				bottom: 0;
-				padding: 20px 0;
-				background: var(--X14);
-				-webkit-backdrop-filter: var(--blur, blur(8px));
-				backdrop-filter: var(--blur, blur(8px));
-
-				> .post {
-					position: relative;
-					display: block;
-					width: 100%;
-					height: 40px;
-					color: var(--fgOnAccent);
-					font-weight: bold;
-					text-align: left;
-
-					&:before {
-						content: "";
-						display: block;
-						width: calc(100% - 38px);
-						height: 100%;
-						margin: auto;
-						position: absolute;
-						top: 0;
-						left: 0;
-						right: 0;
-						bottom: 0;
-						border-radius: 999px;
-						background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
-					}
-
-					&:hover, &.active {
-						&:before {
-							background: var(--accentLighten);
-						}
-					}
-
-					> .icon {
-						position: relative;
-						margin-left: 30px;
-						margin-right: 8px;
-						width: 32px;
-					}
-
-					> .text {
-						position: relative;
-					}
-				}
-
-				> .account {
-					position: relative;
-					display: flex;
-					align-items: center;
-					padding-left: 30px;
-					text-overflow: ellipsis;
-					overflow: hidden;
-					white-space: nowrap;
-					width: 100%;
-					text-align: left;
-					box-sizing: border-box;
-					margin-top: 16px;
-
-					> .avatar {
-						position: relative;
-						width: 32px;
-						aspect-ratio: 1;
-						margin-right: 8px;
-					}
-				}
-			}
-
-			> .middle {
-				flex: 1;
-
-				> .divider {
-					margin: 16px 16px;
-					border-top: solid 0.5px var(--divider);
-				}
-
-				> .item {
-					position: relative;
-					display: block;
-					padding-left: 30px;
-					line-height: 2.85rem;
-					text-overflow: ellipsis;
-					overflow: hidden;
-					white-space: nowrap;
-					width: 100%;
-					text-align: left;
-					box-sizing: border-box;
-					color: var(--navFg);
-
-					> .icon {
-						position: relative;
-						width: 32px;
-						margin-right: 8px;
-					}
-
-					> .indicator {
-						position: absolute;
-						top: 0;
-						left: 20px;
-						color: var(--navIndicator);
-						font-size: 8px;
-						animation: blink 1s infinite;
-					}
-
-					> .text {
-						position: relative;
-						font-size: 0.9em;
-					}
-
-					&:hover {
-						text-decoration: none;
-						color: var(--navHoverFg);
-					}
-
-					&.active {
-						color: var(--navActive);
-					}
-
-					&:hover, &.active {
-						color: var(--accent);
-
-						&:before {
-							content: "";
-							display: block;
-							width: calc(100% - 34px);
-							height: 100%;
-							margin: auto;
-							position: absolute;
-							top: 0;
-							left: 0;
-							right: 0;
-							bottom: 0;
-							border-radius: 999px;
-							background: var(--accentedBg);
-						}
-					}
-				}
-			}
-		}
-	}
-
-	&.iconOnly {
-		flex: 0 0 $nav-icon-only-width;
-		width: $nav-icon-only-width;
-
-		> .body {
-			width: $nav-icon-only-width;
-
-			> .top {
-				position: sticky;
-				top: 0;
-				z-index: 1;
-				padding: 20px 0;
-				background: var(--X14);
-				-webkit-backdrop-filter: var(--blur, blur(8px));
-				backdrop-filter: var(--blur, blur(8px));
-
-				> .instance {
-					display: block;
-					text-align: center;
-					width: 100%;
-
-					> .icon {
-						display: inline-block;
-						width: 30px;
-						aspect-ratio: 1;
-					}
-				}
-			}
-
-			> .bottom {
-				position: sticky;
-				bottom: 0;
-				padding: 20px 0;
-				background: var(--X14);
-				-webkit-backdrop-filter: var(--blur, blur(8px));
-				backdrop-filter: var(--blur, blur(8px));
-
-				> .post {
-					display: block;
-					position: relative;
-					width: 100%;
-					height: 52px;
-					margin-bottom: 16px;
-					text-align: center;
-
-					&:before {
-						content: "";
-						display: block;
-						position: absolute;
-						top: 0;
-						left: 0;
-						right: 0;
-						bottom: 0;
-						margin: auto;
-						width: 52px;
-						aspect-ratio: 1/1;
-						border-radius: 100%;
-						background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
-					}
-
-					&:hover, &.active {
-						&:before {
-							background: var(--accentLighten);
-						}
-					}
-
-					> .icon {
-						position: relative;
-						color: var(--fgOnAccent);
-					}
-
-					> .text {
-						display: none;
-					}
-				}
-
-				> .account {
-					display: block;
-					text-align: center;
-					width: 100%;
-
-					> .avatar {
-						display: inline-block;
-						width: 38px;
-						aspect-ratio: 1;
-					}
-
-					> .text {
-						display: none;
-					}
-				}
-			}
-
-			> .middle {
-				flex: 1;
-
-				> .divider {
-					margin: 8px auto;
-					width: calc(100% - 32px);
-					border-top: solid 0.5px var(--divider);
-				}
-
-				> .item {
-					display: block;
-					position: relative;
-					padding: 18px 0;
-					width: 100%;
-					text-align: center;
-
-					> .icon {
-						display: block;
-						margin: 0 auto;
-						opacity: 0.7;
-					}
-
-					> .text {
-						display: none;
-					}
-
-					> .indicator {
-						position: absolute;
-						top: 6px;
-						left: 24px;
-						color: var(--navIndicator);
-						font-size: 8px;
-						animation: blink 1s infinite;
-					}
-
-					&:hover, &.active {
-						text-decoration: none;
-						color: var(--accent);
-
-						&:before {
-							content: "";
-							display: block;
-							height: 100%;
-							aspect-ratio: 1;
-							margin: auto;
-							position: absolute;
-							top: 0;
-							left: 0;
-							right: 0;
-							bottom: 0;
-							border-radius: 999px;
-							background: var(--accentedBg);
-						}
-
-						> .icon, > .text {
-							opacity: 1;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/statusbar-federation.vue b/packages/client/src/ui/_common_/statusbar-federation.vue
deleted file mode 100644
index 24fc4f6f6d..0000000000
--- a/packages/client/src/ui/_common_/statusbar-federation.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-<template>
-<span v-if="!fetching" class="nmidsaqw">
-	<template v-if="display === 'marquee'">
-		<transition name="change" mode="default">
-			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
-				<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
-					<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
-					<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
-						{{ instance.host }}
-					</MkA>
-					<span class="divider"></span>
-				</span>
-			</MarqueeText>
-		</transition>
-	</template>
-	<template v-else-if="display === 'oneByOne'">
-		<!-- TODO -->
-	</template>
-</span>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import MarqueeText from '@/components/MkMarquee.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { getNoteSummary } from '@/scripts/get-note-summary';
-import { notePage } from '@/filters/note';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const props = defineProps<{
-	display?: 'marquee' | 'oneByOne';
-	colored?: boolean;
-	marqueeDuration?: number;
-	marqueeReverse?: boolean;
-	oneByOneInterval?: number;
-	refreshIntervalSec?: number;
-}>();
-
-const instances = ref<misskey.entities.Instance[]>([]);
-const fetching = ref(true);
-let key = $ref(0);
-
-const tick = () => {
-	os.api('federation/instances', {
-		sort: '+lastCommunicatedAt',
-		limit: 30,
-	}).then(res => {
-		instances.value = res;
-		fetching.value = false;
-		key++;
-	});
-};
-
-useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
-	immediate: true,
-	afterMounted: true,
-});
-
-function getInstanceIcon(instance): string {
-	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
-}
-</script>
-
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
-	position: absolute;
-	top: 0;
-  transition: all 1s ease;
-}
-.change-enter-from {
-  opacity: 0;
-	transform: translateY(-100%);
-}
-.change-leave-to {
-  opacity: 0;
-	transform: translateY(100%);
-}
-
-.nmidsaqw {
-	display: inline-block;
-	position: relative;
-
-	::v-deep(.item) {
-		display: inline-block;
-		vertical-align: bottom;
-		margin-right: 5em;
-
-		> .icon {
-			display: inline-block;
-			height: var(--height);
-			aspect-ratio: 1;
-			vertical-align: bottom;
-			margin-right: 1em;
-		}
-
-		> .host {
-			vertical-align: bottom;
-		}
-
-		&.colored {
-			padding-right: 1em;
-			color: #fff;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue
deleted file mode 100644
index e7f88e4984..0000000000
--- a/packages/client/src/ui/_common_/statusbar-rss.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<template>
-<span v-if="!fetching" class="xbhtxfms">
-	<template v-if="display === 'marquee'">
-		<transition name="change" mode="default">
-			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
-				<span v-for="item in items" class="item">
-					<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
-				</span>
-			</MarqueeText>
-		</transition>
-	</template>
-	<template v-else-if="display === 'oneByOne'">
-		<!-- TODO -->
-	</template>
-</span>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
-import MarqueeText from '@/components/MkMarquee.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { shuffle } from '@/scripts/shuffle';
-
-const props = defineProps<{
-	url?: string;
-	shuffle?: boolean;
-	display?: 'marquee' | 'oneByOne';
-	marqueeDuration?: number;
-	marqueeReverse?: boolean;
-	oneByOneInterval?: number;
-	refreshIntervalSec?: number;
-}>();
-
-const items = ref([]);
-const fetching = ref(true);
-let key = $ref(0);
-
-const tick = () => {
-	window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
-		res.json().then(feed => {
-			if (props.shuffle) {
-				shuffle(feed.items);
-			}
-			items.value = feed.items;
-			fetching.value = false;
-			key++;
-		});
-	});
-};
-
-useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
-	immediate: true,
-	afterMounted: true,
-});
-</script>
-
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
-	position: absolute;
-	top: 0;
-  transition: all 1s ease;
-}
-.change-enter-from {
-  opacity: 0;
-	transform: translateY(-100%);
-}
-.change-leave-to {
-  opacity: 0;
-	transform: translateY(100%);
-}
-
-.xbhtxfms {
-	display: inline-block;
-	position: relative;
-
-	::v-deep(.item) {
-		display: inline-flex;
-		align-items: center;
-		vertical-align: bottom;
-		margin: 0;
-
-		> .divider {
-			display: inline-block;
-			width: 0.5px;
-			height: var(--height);
-			margin: 0 3em;
-			background: currentColor;
-			opacity: 0.3;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/statusbar-user-list.vue b/packages/client/src/ui/_common_/statusbar-user-list.vue
deleted file mode 100644
index f4d989c387..0000000000
--- a/packages/client/src/ui/_common_/statusbar-user-list.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<template>
-<span v-if="!fetching" class="osdsvwzy">
-	<template v-if="display === 'marquee'">
-		<transition name="change" mode="default">
-			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
-				<span v-for="note in notes" :key="note.id" class="item">
-					<img class="avatar" :src="note.user.avatarUrl" decoding="async"/>
-					<MkA class="text" :to="notePage(note)">
-						<Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/>
-					</MkA>
-					<span class="divider"></span>
-				</span>
-			</MarqueeText>
-		</transition>
-	</template>
-	<template v-else-if="display === 'oneByOne'">
-		<!-- TODO -->
-	</template>
-</span>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
-import * as misskey from 'misskey-js';
-import MarqueeText from '@/components/MkMarquee.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { getNoteSummary } from '@/scripts/get-note-summary';
-import { notePage } from '@/filters/note';
-
-const props = defineProps<{
-	userListId?: string;
-	display?: 'marquee' | 'oneByOne';
-	marqueeDuration?: number;
-	marqueeReverse?: boolean;
-	oneByOneInterval?: number;
-	refreshIntervalSec?: number;
-}>();
-
-const notes = ref<misskey.entities.Note[]>([]);
-const fetching = ref(true);
-let key = $ref(0);
-
-const tick = () => {
-	if (props.userListId == null) return;
-	os.api('notes/user-list-timeline', {
-		listId: props.userListId,
-	}).then(res => {
-		notes.value = res;
-		fetching.value = false;
-		key++;
-	});
-};
-
-watch(() => props.userListId, tick);
-
-useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
-	immediate: true,
-	afterMounted: true,
-});
-</script>
-
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
-	position: absolute;
-	top: 0;
-  transition: all 1s ease;
-}
-.change-enter-from {
-  opacity: 0;
-	transform: translateY(-100%);
-}
-.change-leave-to {
-  opacity: 0;
-	transform: translateY(100%);
-}
-
-.osdsvwzy {
-	display: inline-block;
-	position: relative;
-
-	::v-deep(.item) {
-		display: inline-flex;
-		align-items: center;
-		vertical-align: bottom;
-		margin: 0;
-
-		> .avatar {
-			display: inline-block;
-			height: var(--height);
-			aspect-ratio: 1;
-			vertical-align: bottom;
-			margin-right: 8px;
-		}
-
-		> .text {
-			> .text {
-				display: inline-block;
-				vertical-align: bottom;
-			}
-		}
-
-		> .divider {
-			display: inline-block;
-			width: 0.5px;
-			height: 16px;
-			margin: 0 3em;
-			background: currentColor;
-			opacity: 0;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue
deleted file mode 100644
index 114ca5be8c..0000000000
--- a/packages/client/src/ui/_common_/statusbars.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<template>
-<div class="dlrsnxqu">
-	<div
-		v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, {
-			verySmall: x.size === 'verySmall',
-			small: x.size === 'small',
-			medium: x.size === 'medium',
-			large: x.size === 'large',
-			veryLarge: x.size === 'veryLarge',
-		}]"
-	>
-		<span class="name">{{ x.name }}</span>
-		<XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/>
-		<XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/>
-		<XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue'));
-const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue'));
-const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
-</script>
-
-<style lang="scss" scoped>
-.dlrsnxqu {
-	font-size: 15px;
-	background: var(--panel);
-
-	> .item {
-		--height: 24px;
-		--nameMargin: 10px;
-		font-size: 0.85em;
-
-		&.verySmall {
-			--nameMargin: 7px;
-			--height: 16px;
-			font-size: 0.75em;
-		}
-
-		&.small {
-			--nameMargin: 8px;
-			--height: 20px;
-			font-size: 0.8em;
-		}
-
-		&.large {
-			--nameMargin: 12px;
-			--height: 26px;
-			font-size: 0.875em;
-		}
-
-		&.veryLarge {
-			--nameMargin: 14px;
-			--height: 30px;
-			font-size: 0.9em;
-		}
-
-		display: flex;
-		vertical-align: bottom;
-		width: 100%;
-		line-height: var(--height);
-		height: var(--height);
-		overflow: clip;
-		contain: strict;
-
-		> .name {
-			padding: 0 var(--nameMargin);
-			font-weight: bold;
-			color: var(--accent);
-
-			&:empty {
-				display: none;
-			}
-		}
-
-		> .body {
-			min-width: 0;
-			flex: 1;
-		}
-
-		&.black {
-			background: #000;
-			color: #fff;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/stream-indicator.vue b/packages/client/src/ui/_common_/stream-indicator.vue
deleted file mode 100644
index a855de8ab9..0000000000
--- a/packages/client/src/ui/_common_/stream-indicator.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected">
-	<div>{{ i18n.ts.disconnectedFromServer }}</div>
-	<div class="command">
-		<button class="_textButton" @click="reload">{{ i18n.ts.reload }}</button>
-		<button class="_textButton">{{ i18n.ts.doNothing }}</button>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted } from 'vue';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-let hasDisconnected = $ref(false);
-
-function onDisconnected() {
-	hasDisconnected = true;
-}
-
-function resetDisconnected() {
-	hasDisconnected = false;
-}
-
-function reload() {
-	location.reload();
-}
-
-stream.on('_disconnected_', onDisconnected);
-
-onUnmounted(() => {
-	stream.off('_disconnected_', onDisconnected);
-});
-</script>
-
-<style lang="scss" scoped>
-.nsbbhtug {
-	position: fixed;
-	z-index: 16385;
-	bottom: 8px;
-	right: 8px;
-	margin: 0;
-	padding: 6px 12px;
-	font-size: 0.9em;
-	color: #fff;
-	background: #000;
-	opacity: 0.8;
-	border-radius: 4px;
-	max-width: 320px;
-
-	> .command {
-		display: flex;
-		justify-content: space-around;
-
-		> button {
-			padding: 0.7em;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/_common_/sw-inject.ts b/packages/client/src/ui/_common_/sw-inject.ts
deleted file mode 100644
index 8676d2d48d..0000000000
--- a/packages/client/src/ui/_common_/sw-inject.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { inject } from 'vue';
-import { post } from '@/os';
-import { $i, login } from '@/account';
-import { defaultStore } from '@/store';
-import { getAccountFromId } from '@/scripts/get-account-from-id';
-import { mainRouter } from '@/router';
-
-export function swInject() {
-	navigator.serviceWorker.addEventListener('message', ev => {
-		if (_DEV_) {
-			console.log('sw msg', ev.data);
-		}
-
-		if (ev.data.type !== 'order') return;
-
-		if (ev.data.loginId !== $i?.id) {
-			return getAccountFromId(ev.data.loginId).then(account => {
-				if (!account) return;
-				return login(account.token, ev.data.url);
-			});
-		}
-
-		switch (ev.data.order) {
-			case 'post':
-				return post(ev.data.options);
-			case 'push':
-				if (mainRouter.currentRoute.value.path === ev.data.url) {
-					return window.scroll({ top: 0, behavior: 'smooth' });
-				}
-				return mainRouter.push(ev.data.url);
-			default:
-				return;
-		}
-	});
-}
diff --git a/packages/client/src/ui/_common_/upload.vue b/packages/client/src/ui/_common_/upload.vue
deleted file mode 100644
index 70882bd251..0000000000
--- a/packages/client/src/ui/_common_/upload.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<template>
-<div class="mk-uploader _acrylic" :style="{ zIndex }">
-	<ol v-if="uploads.length > 0">
-		<li v-for="ctx in uploads" :key="ctx.id">
-			<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
-			<div class="top">
-				<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
-				<p class="status">
-					<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
-					<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
-					<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
-				</p>
-			</div>
-			<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
-		</li>
-	</ol>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as os from '@/os';
-import { uploads } from '@/scripts/upload';
-import { i18n } from '@/i18n';
-
-const zIndex = os.claimZIndex('high');
-</script>
-
-<style lang="scss" scoped>
-.mk-uploader {
-	position: fixed;
-	right: 16px;
-	width: 260px;
-	top: 32px;
-	padding: 16px 20px;
-	pointer-events: none;
-	box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
-	border-radius: 8px;
-}
-.mk-uploader:empty {
-  display: none;
-}
-.mk-uploader > ol {
-  display: block;
-  margin: 0;
-  padding: 0;
-  list-style: none;
-}
-.mk-uploader > ol > li {
-  display: grid;
-  margin: 8px 0 0 0;
-  padding: 0;
-  height: 36px;
-  width: 100%;
-  border-top: solid 8px transparent;
-  grid-template-columns: 36px calc(100% - 44px);
-  grid-template-rows: 1fr 8px;
-  column-gap: 8px;
-  box-sizing: content-box;
-}
-.mk-uploader > ol > li:first-child {
-  margin: 0;
-  box-shadow: none;
-  border-top: none;
-}
-.mk-uploader > ol > li > .img {
-  display: block;
-  background-size: cover;
-  background-position: center center;
-  grid-column: 1/2;
-  grid-row: 1/3;
-}
-.mk-uploader > ol > li > .top {
-  display: flex;
-  grid-column: 2/3;
-  grid-row: 1/2;
-}
-.mk-uploader > ol > li > .top > .name {
-  display: block;
-  padding: 0 8px 0 0;
-  margin: 0;
-  font-size: 0.8em;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  overflow: hidden;
-  flex-shrink: 1;
-}
-.mk-uploader > ol > li > .top > .name > i {
-  margin-right: 4px;
-}
-.mk-uploader > ol > li > .top > .status {
-  display: block;
-  margin: 0 0 0 auto;
-  padding: 0;
-  font-size: 0.8em;
-  flex-shrink: 0;
-}
-.mk-uploader > ol > li > .top > .status > .initing {
-}
-.mk-uploader > ol > li > .top > .status > .kb {
-}
-.mk-uploader > ol > li > .top > .status > .percentage {
-  display: inline-block;
-  width: 48px;
-  text-align: right;
-}
-.mk-uploader > ol > li > .top > .status > .percentage:after {
-  content: '%';
-}
-.mk-uploader > ol > li > progress {
-  display: block;
-  background: transparent;
-  border: none;
-  border-radius: 4px;
-  overflow: hidden;
-  grid-column: 2/3;
-  grid-row: 2/3;
-  z-index: 2;
-	width: 100%;
-	height: 8px;
-}
-.mk-uploader > ol > li > progress::-webkit-progress-value {
-  background: var(--accent);
-}
-.mk-uploader > ol > li > progress::-webkit-progress-bar {
-  //background: var(--accentAlpha01);
-	background: transparent;
-}
-</style>
diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue
deleted file mode 100644
index 46d79e6355..0000000000
--- a/packages/client/src/ui/classic.header.vue
+++ /dev/null
@@ -1,217 +0,0 @@
-<template>
-<div class="azykntjl">
-	<div class="body">
-		<div class="left">
-			<MkA v-click-anime v-tooltip="$ts.timeline" class="item index" active-class="active" to="/" exact>
-				<i class="ti ti-home ti-fw"></i>
-			</MkA>
-			<template v-for="item in menu">
-				<div v-if="item === '-'" class="divider"></div>
-				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
-					<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
-					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
-				</component>
-			</template>
-			<div class="divider"></div>
-			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="$ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
-				<i class="ti ti-dashboard ti-fw"></i>
-			</MkA>
-			<button v-click-anime class="item _button" @click="more">
-				<i class="ti ti-dots ti-fw"></i>
-				<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
-			</button>
-		</div>
-		<div class="right">
-			<MkA v-click-anime v-tooltip="$ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
-				<i class="ti ti-settings ti-fw"></i>
-			</MkA>
-			<button v-click-anime class="item _button account" @click="openAccountMenu">
-				<MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
-			</button>
-			<div class="post" @click="post">
-				<MkButton class="button" gradate full rounded>
-					<i class="ti ti-pencil ti-fw"></i>
-				</MkButton>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineAsyncComponent, defineComponent } from 'vue';
-import { host } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { openAccountMenu } from '@/account';
-import MkButton from '@/components/MkButton.vue';
-
-export default defineComponent({
-	components: {
-		MkButton,
-	},
-
-	data() {
-		return {
-			host: host,
-			accounts: [],
-			connection: null,
-			navbarItemDef: navbarItemDef,
-			settingsWindowed: false,
-		};
-	},
-
-	computed: {
-		menu(): string[] {
-			return this.$store.state.menu;
-		},
-
-		otherNavItemIndicated(): boolean {
-			for (const def in this.navbarItemDef) {
-				if (this.menu.includes(def)) continue;
-				if (this.navbarItemDef[def].indicated) return true;
-			}
-			return false;
-		},
-	},
-
-	watch: {
-		'$store.reactiveState.menuDisplay.value'() {
-			this.calcViewState();
-		},
-	},
-
-	created() {
-		window.addEventListener('resize', this.calcViewState);
-		this.calcViewState();
-	},
-
-	methods: {
-		calcViewState() {
-			this.settingsWindowed = (window.innerWidth > 1400);
-		},
-
-		post() {
-			os.post();
-		},
-
-		search() {
-			search();
-		},
-
-		more(ev) {
-			os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
-				src: ev.currentTarget ?? ev.target,
-				anchor: { x: 'center', y: 'bottom' },
-			}, {
-			}, 'closed');
-		},
-
-		openAccountMenu: (ev) => {
-			openAccountMenu({
-				withExtraOperation: true,
-			}, ev);
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.azykntjl {
-	$height: 60px;
-	$avatar-size: 32px;
-	$avatar-margin: 8px;
-
-	position: sticky;
-	top: 0;
-	z-index: 1000;
-	width: 100%;
-	height: $height;
-	background-color: var(--bg);
-
-	> .body {
-		max-width: 1380px;
-		margin: 0 auto;
-		display: flex;
-
-		> .right,
-		> .left {
-
-			> .item {
-				position: relative;
-				font-size: 0.9em;
-				display: inline-block;
-				padding: 0 12px;
-				line-height: $height;
-
-				> i,
-				> .avatar {
-					margin-right: 0;
-				}
-
-				> i {
-					left: 10px;
-				}
-
-				> .avatar {
-					width: $avatar-size;
-					height: $avatar-size;
-					vertical-align: middle;
-				}
-
-				> .indicator {
-					position: absolute;
-					top: 0;
-					left: 0;
-					color: var(--navIndicator);
-					font-size: 8px;
-					animation: blink 1s infinite;
-				}
-
-				&:hover {
-					text-decoration: none;
-					color: var(--navHoverFg);
-				}
-
-				&.active {
-					color: var(--navActive);
-				}
-			}
-
-			> .divider {
-				display: inline-block;
-				height: 16px;
-				margin: 0 10px;
-				border-right: solid 0.5px var(--divider);
-			}
-
-			> .post {
-				display: inline-block;
-			
-				> .button {
-					width: 40px;
-					height: 40px;
-					padding: 0;
-					min-width: 0;
-				}
-			}
-
-			> .account {
-				display: inline-flex;
-				align-items: center;
-				vertical-align: top;
-				margin-right: 8px;
-
-				> .acct {
-					margin-left: 8px;
-				}
-			}
-		}
-
-		> .right {
-			margin-left: auto;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/classic.sidebar.vue b/packages/client/src/ui/classic.sidebar.vue
deleted file mode 100644
index dac09ea703..0000000000
--- a/packages/client/src/ui/classic.sidebar.vue
+++ /dev/null
@@ -1,268 +0,0 @@
-<template>
-<div class="npcljfve" :class="{ iconOnly }">
-	<button v-click-anime class="item _button account" @click="openAccountMenu">
-		<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
-	</button>
-	<div class="post" data-cy-open-post-form @click="post">
-		<MkButton class="button" gradate full rounded>
-			<i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ $ts.note }}</span>
-		</MkButton>
-	</div>
-	<div class="divider"></div>
-	<MkA v-click-anime class="item index" active-class="active" to="/" exact>
-		<i class="ti ti-home ti-fw"></i><span class="text">{{ $ts.timeline }}</span>
-	</MkA>
-	<template v-for="item in menu">
-		<div v-if="item === '-'" class="divider"></div>
-		<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
-			<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span>
-			<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
-		</component>
-	</template>
-	<div class="divider"></div>
-	<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
-		<i class="ti ti-dashboard ti-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
-	</MkA>
-	<button v-click-anime class="item _button" @click="more">
-		<i class="ti ti-dots ti-fw"></i><span class="text">{{ $ts.more }}</span>
-		<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
-	</button>
-	<MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
-		<i class="ti ti-settings ti-fw"></i><span class="text">{{ $ts.settings }}</span>
-	</MkA>
-	<div class="divider"></div>
-	<div class="about">
-		<MkA v-click-anime class="link" to="/about">
-			<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/>
-		</MkA>
-	</div>
-	<!--<MisskeyLogo class="misskey"/>-->
-</div>
-</template>
-
-<script lang="ts">
-import { defineAsyncComponent, defineComponent } from 'vue';
-import { host } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { openAccountMenu } from '@/account';
-import MkButton from '@/components/MkButton.vue';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
-//import MisskeyLogo from '@assets/client/misskey.svg';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		//MisskeyLogo,
-	},
-
-	data() {
-		return {
-			host: host,
-			accounts: [],
-			connection: null,
-			navbarItemDef: navbarItemDef,
-			iconOnly: false,
-			settingsWindowed: false,
-		};
-	},
-
-	computed: {
-		menu(): string[] {
-			return this.$store.state.menu;
-		},
-
-		otherNavItemIndicated(): boolean {
-			for (const def in this.navbarItemDef) {
-				if (this.menu.includes(def)) continue;
-				if (this.navbarItemDef[def].indicated) return true;
-			}
-			return false;
-		},
-	},
-
-	watch: {
-		'$store.reactiveState.menuDisplay.value'() {
-			this.calcViewState();
-		},
-
-		iconOnly() {
-			this.$nextTick(() => {
-				this.$emit('change-view-mode');
-			});
-		},
-	},
-
-	created() {
-		window.addEventListener('resize', this.calcViewState);
-		this.calcViewState();
-	},
-
-	mounted() {
-		const sticky = new StickySidebar(this.$el.parentElement, 16);
-		window.addEventListener('scroll', () => {
-			sticky.calc(window.scrollY);
-		}, { passive: true });
-	},
-
-	methods: {
-		calcViewState() {
-			this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon');
-			this.settingsWindowed = (window.innerWidth > 1400);
-		},
-
-		post() {
-			os.post();
-		},
-
-		search() {
-			search();
-		},
-
-		more(ev) {
-			os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
-				src: ev.currentTarget ?? ev.target,
-			}, {}, 'closed');
-		},
-
-		openAccountMenu: (ev) => {
-			openAccountMenu({
-				withExtraOperation: true,
-			}, ev);
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.npcljfve {
-	$ui-font-size: 1em; // TODO: どこかに集約したい
-	$nav-icon-only-width: 78px; // TODO: どこかに集約したい
-	$avatar-size: 32px;
-	$avatar-margin: 8px;
-
-	padding: 0 16px;
-	box-sizing: border-box;
-	width: 260px;
-
-	&.iconOnly {
-		flex: 0 0 $nav-icon-only-width;
-		width: $nav-icon-only-width !important;
-
-		> .divider {
-			margin: 8px auto;
-			width: calc(100% - 32px);
-		}
-
-		> .post {
-			> .button {
-				width: 46px;
-				height: 46px;
-				padding: 0;
-			}
-		}
-
-		> .item {
-			padding-left: 0;
-			width: 100%;
-			text-align: center;
-			font-size: $ui-font-size * 1.1;
-			line-height: 3.7rem;
-
-			> i,
-			> .avatar {
-				margin-right: 0;
-			}
-
-			> i {
-				left: 10px;
-			}
-
-			> .text {
-				display: none;
-			}
-		}
-	}
-
-	> .divider {
-		margin: 10px 0;
-		border-top: solid 0.5px var(--divider);
-	}
-
-	> .post {
-		position: sticky;
-		top: 0;
-		z-index: 1;
-		padding: 16px 0;
-		background: var(--bg);
-
-		> .button {
-			min-width: 0;
-		}
-	}
-
-	> .about {
-		fill: currentColor;
-		padding: 8px 0 16px 0;
-		text-align: center;
-
-		> .link {
-			display: block;
-			width: 32px;
-			margin: 0 auto;
-
-			img {
-				display: block;
-				width: 100%;
-			}
-		}
-	}
-
-	> .item {
-		position: relative;
-		display: block;
-		font-size: $ui-font-size;
-		line-height: 2.6rem;
-		text-overflow: ellipsis;
-		overflow: hidden;
-		white-space: nowrap;
-		width: 100%;
-		text-align: left;
-		box-sizing: border-box;
-
-		> i {
-			width: 32px;
-		}
-
-		> i,
-		> .avatar {
-			margin-right: $avatar-margin;
-		}
-
-		> .avatar {
-			width: $avatar-size;
-			height: $avatar-size;
-			vertical-align: middle;
-		}
-
-		> .indicator {
-			position: absolute;
-			top: 0;
-			left: 0;
-			color: var(--navIndicator);
-			font-size: 8px;
-			animation: blink 1s infinite;
-		}
-
-		&:hover {
-			text-decoration: none;
-			color: var(--navHoverFg);
-		}
-
-		&.active {
-			color: var(--navActive);
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
deleted file mode 100644
index 0e726c11ed..0000000000
--- a/packages/client/src/ui/classic.vue
+++ /dev/null
@@ -1,320 +0,0 @@
-<template>
-<div class="gbhvwtnk" :class="{ wallpaper }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`">
-	<XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/>
-
-	<div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }">
-		<div v-if="!showMenuOnTop" class="sidebar">
-			<XSidebar/>
-		</div>
-		<div v-else ref="widgetsLeft" class="widgets left">
-			<XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/>
-		</div>
-
-		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
-			<div class="content">
-				<RouterView/>
-			</div>
-		</main>
-
-		<div v-if="isDesktop" ref="widgetsRight" class="widgets right">
-			<XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/>
-		</div>
-	</div>
-
-	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div
-			v-if="widgetsShowing"
-			class="tray-back _modalBg"
-			@click="widgetsShowing = false"
-			@touchstart.passive="widgetsShowing = false"
-		></div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'tray' : ''">
-		<XWidgets v-if="widgetsShowing" class="tray"/>
-	</transition>
-
-	<iframe v-if="$store.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue';
-import XSidebar from './classic.sidebar.vue';
-import XCommon from './_common_/common.vue';
-import { instanceName } from '@/config';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
-import * as os from '@/os';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
-const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue'));
-
-const DESKTOP_THRESHOLD = 1100;
-
-let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-let widgetsShowing = $ref(false);
-let fullView = $ref(false);
-let globalHeaderHeight = $ref(0);
-const wallpaper = localStorage.getItem('wallpaper') != null;
-const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top');
-let live2d = $ref<HTMLIFrameElement>();
-let widgetsLeft = $ref();
-let widgetsRight = $ref();
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-	if (pageMetadata.value) {
-		document.title = `${pageMetadata.value.title} | ${instanceName}`;
-	}
-});
-provide('shouldHeaderThin', showMenuOnTop);
-provide('shouldSpacerMin', true);
-
-function attachSticky(el) {
-	const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
-	window.addEventListener('scroll', () => {
-		sticky.calc(window.scrollY);
-	}, { passive: true });
-}
-
-function top() {
-	window.scroll({ top: 0, behavior: 'smooth' });
-}
-
-function onContextmenu(ev: MouseEvent) {
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target)) return;
-	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
-	if (window.getSelection().toString() !== '') return;
-	const path = mainRouter.getCurrentPath();
-	os.contextMenu([{
-		type: 'label',
-		text: path,
-	}, {
-		icon: fullView ? 'fas fa-compress' : 'fas fa-expand',
-		text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView,
-		action: () => {
-			fullView = !fullView;
-		},
-	}, {
-		icon: 'ti ti-window-maximize',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			os.pageWindow(path);
-		},
-	}], ev);
-}
-
-function onAiClick(ev) {
-	//if (this.live2d) this.live2d.click(ev);
-}
-
-if (window.innerWidth < 1024) {
-	localStorage.setItem('ui', 'default');
-	location.reload();
-}
-
-document.documentElement.style.overflowY = 'scroll';
-
-if (defaultStore.state.widgets.length === 0) {
-	defaultStore.set('widgets', [{
-		name: 'calendar',
-		id: 'a', place: null, data: {},
-	}, {
-		name: 'notifications',
-		id: 'b', place: null, data: {},
-	}, {
-		name: 'trends',
-		id: 'c', place: null, data: {},
-	}]);
-}
-
-onMounted(() => {
-	window.addEventListener('resize', () => {
-		isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
-	}, { passive: true });
-
-	if (defaultStore.state.aiChanMode) {
-		const iframeRect = live2d.getBoundingClientRect();
-		window.addEventListener('mousemove', ev => {
-			live2d.contentWindow.postMessage({
-				type: 'moveCursor',
-				body: {
-					x: ev.clientX - iframeRect.left,
-					y: ev.clientY - iframeRect.top,
-				},
-			}, '*');
-		}, { passive: true });
-		window.addEventListener('touchmove', ev => {
-			live2d.contentWindow.postMessage({
-				type: 'moveCursor',
-				body: {
-					x: ev.touches[0].clientX - iframeRect.left,
-					y: ev.touches[0].clientY - iframeRect.top,
-				},
-			}, '*');
-		}, { passive: true });
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.tray-enter-active,
-.tray-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-enter-from,
-.tray-leave-active {
-	opacity: 0;
-	transform: translateX(240px);
-}
-
-.tray-back-enter-active,
-.tray-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-back-enter-from,
-.tray-back-leave-active {
-	opacity: 0;
-}
-
-.gbhvwtnk {
-	$ui-font-size: 1em;
-	$widgets-hide-threshold: 1200px;
-
-	min-height: 100dvh;
-	box-sizing: border-box;
-
-	&.wallpaper {
-		background: var(--wallpaperOverlay);
-		//backdrop-filter: var(--blur, blur(4px));
-	}
-
-	> .columns {
-		display: flex;
-		justify-content: center;
-		max-width: 100%;
-		//margin: 32px 0;
-
-		&.fullView {
-			margin: 0;
-		
-			> .sidebar {
-				display: none;
-			}
-
-			> .widgets {
-				display: none;
-			}
-
-			> .main {
-				margin: 0;
-				border-radius: 0;
-				box-shadow: none;
-				width: 100%;
-			}
-		}
-
-		> .main {
-			min-width: 0;
-			width: 750px;
-			margin: 0 16px 0 0;
-			background: var(--panel);
-			border-left: solid 1px var(--divider);
-			border-right: solid 1px var(--divider);
-			border-radius: 0;
-			overflow: clip;
-			--margin: 12px;
-		}
-
-		> .widgets {
-			//--panelBorder: none;
-			width: 300px;
-			margin-top: 16px;
-
-			@media (max-width: $widgets-hide-threshold) {
-				display: none;
-			}
-
-			&.left {
-				margin-right: 16px;
-			}
-		}
-
-		> .sidebar {
-			margin-top: 16px;
-		}
-
-		&.withGlobalHeader {
-			> .main {
-				margin-top: 0;
-				border: solid 1px var(--divider);
-				border-radius: var(--radius);
-				--stickyTop: var(--globalHeaderHeight);
-			}
-
-			> .widgets {
-				--stickyTop: var(--globalHeaderHeight);
-				margin-top: 0;
-			}
-		}
-
-		@media (max-width: 850px) {
-			margin: 0;
-
-			> .sidebar {
-				border-right: solid 0.5px var(--divider);
-			}
-
-			> .main {
-				margin: 0;
-				border-radius: 0;
-				box-shadow: none;
-				width: 100%;
-			}
-		}
-	}
-
-	> .tray-back {
-		z-index: 1001;
-	}
-
-	> .tray {
-		position: fixed;
-		top: 0;
-		right: 0;
-		z-index: 1001;
-		height: 100dvh;
-		padding: var(--margin);
-		box-sizing: border-box;
-		overflow: auto;
-		background: var(--bg);
-	}
-
-	> .ivnzpscs {
-		position: fixed;
-		bottom: 0;
-		right: 0;
-		width: 300px;
-		height: 600px;
-		border: none;
-		pointer-events: none;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/classic.widgets.vue b/packages/client/src/ui/classic.widgets.vue
deleted file mode 100644
index 163ec982ce..0000000000
--- a/packages/client/src/ui/classic.widgets.vue
+++ /dev/null
@@ -1,84 +0,0 @@
-<template>
-<div class="ddiqwdnk">
-	<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
-	<MkAd class="a" :prefer="['square']"/>
-
-	<button v-if="editMode" class="_textButton edit" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ $ts.editWidgetsExit }}</button>
-	<button v-else class="_textButton edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ $ts.editWidgets }}</button>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import XWidgets from '@/components/MkWidgets.vue';
-
-export default defineComponent({
-	components: {
-		XWidgets,
-	},
-
-	props: {
-		place: {
-			type: String,
-		},
-	},
-
-	emits: ['mounted'],
-
-	data() {
-		return {
-			editMode: false,
-		};
-	},
-
-	mounted() {
-		this.$emit('mounted', this.$el);
-	},
-
-	methods: {
-		addWidget(widget) {
-			this.$store.set('widgets', [{
-				...widget,
-				place: this.place,
-			}, ...this.$store.state.widgets]);
-		},
-
-		removeWidget(widget) {
-			this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id !== widget.id));
-		},
-
-		updateWidget({ id, data }) {
-			this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
-				...w,
-				data,
-			} : w));
-		},
-
-		updateWidgets(widgets) {
-			this.$store.set('widgets', [
-				...this.$store.state.widgets.filter(w => w.place !== this.place),
-				...widgets,
-			]);
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.ddiqwdnk {
-	position: sticky;
-	height: min-content;
-	box-sizing: border-box;
-	padding-bottom: 8px;
-
-	> .widgets,
-	> .a {
-		width: 300px;
-	}
-
-	> .edit {
-		display: block;
-		margin: 16px auto;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue
deleted file mode 100644
index f3415cfd09..0000000000
--- a/packages/client/src/ui/deck.vue
+++ /dev/null
@@ -1,435 +0,0 @@
-<template>
-<div
-	class="mk-deck" :class="[{ isMobile }]"
->
-	<XSidebar v-if="!isMobile"/>
-
-	<div class="main">
-		<XStatusBars class="statusbars"/>
-		<div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu">
-			<template v-for="ids in layout">
-				<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-				<section
-					v-if="ids.length > 1"
-					class="folder column"
-					:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
-				>
-					<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
-				</section>
-				<DeckColumnCore
-					v-else
-					:ref="ids[0]"
-					:key="ids[0]"
-					class="column"
-					:column="columns.find(c => c.id === ids[0])"
-					:is-stacked="false"
-					:style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
-					@parent-focus="moveFocus(ids[0], $event)"
-				/>
-			</template>
-			<div v-if="layout.length === 0" class="intro _panel">
-				<div>{{ i18n.ts._deck.introduction }}</div>
-				<MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton>
-				<div>{{ i18n.ts._deck.introduction2 }}</div>
-			</div>
-			<div class="sideMenu">
-				<div class="top">
-					<button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" class="_button button" @click="changeProfile"><i class="ti ti-caret-down"></i></button>
-					<button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="ti ti-trash"></i></button>
-				</div>
-				<div class="middle">
-					<button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="ti ti-plus"></i></button>
-				</div>
-				<div class="bottom">
-					<button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="ti ti-settings"></i></button>
-				</div>
-			</div>
-		</div>
-	</div>
-
-	<div v-if="isMobile" class="buttons">
-		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button>
-		<button class="button home _button" @click="mainRouter.push('/')"><i class="ti ti-home"></i></button>
-		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button>
-		<button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button>
-	</div>
-
-	<transition :name="$store.state.animation ? 'menu-back' : ''">
-		<div
-			v-if="drawerMenuShowing"
-			class="menu-back _modalBg"
-			@click="drawerMenuShowing = false"
-			@touchstart.passive="drawerMenuShowing = false"
-		></div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'menu' : ''">
-		<XDrawerMenu v-if="drawerMenuShowing" class="menu"/>
-	</transition>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
-import XCommon from './_common_/common.vue';
-import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store';
-import DeckColumnCore from '@/ui/deck/column-core.vue';
-import XSidebar from '@/ui/_common_/navbar.vue';
-import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
-import MkButton from '@/components/MkButton.vue';
-import { getScrollContainer } from '@/scripts/scroll';
-import * as os from '@/os';
-import { navbarItemDef } from '@/navbar';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-import { mainRouter } from '@/router';
-import { unisonReload } from '@/scripts/unison-reload';
-const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
-
-mainRouter.navHook = (path, flag): boolean => {
-	if (flag === 'forcePage') return false;
-	const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main');
-	if (deckStore.state.navWindow || noMainColumn) {
-		os.pageWindow(path);
-		return true;
-	}
-	return false;
-};
-
-const isMobile = ref(window.innerWidth <= 500);
-window.addEventListener('resize', () => {
-	isMobile.value = window.innerWidth <= 500;
-});
-
-const drawerMenuShowing = ref(false);
-
-const route = 'TODO';
-watch(route, () => {
-	drawerMenuShowing.value = false;
-});
-
-const columns = deckStore.reactiveState.columns;
-const layout = deckStore.reactiveState.layout;
-const menuIndicated = computed(() => {
-	if ($i == null) return false;
-	for (const def in navbarItemDef) {
-		if (navbarItemDef[def].indicated) return true;
-	}
-	return false;
-});
-
-function showSettings() {
-	os.pageWindow('/settings/deck');
-}
-
-let columnsEl = $ref<HTMLElement>();
-
-const addColumn = async (ev) => {
-	const columns = [
-		'main',
-		'widgets',
-		'notifications',
-		'tl',
-		'antenna',
-		'list',
-		'mentions',
-		'direct',
-	];
-
-	const { canceled, result: column } = await os.select({
-		title: i18n.ts._deck.addColumn,
-		items: columns.map(column => ({
-			value: column, text: i18n.t('_deck._columns.' + column),
-		})),
-	});
-	if (canceled) return;
-
-	addColumnToStore({
-		type: column,
-		id: uuid(),
-		name: i18n.t('_deck._columns.' + column),
-		width: 330,
-	});
-};
-
-const onContextmenu = (ev) => {
-	os.contextMenu([{
-		text: i18n.ts._deck.addColumn,
-		action: addColumn,
-	}], ev);
-};
-
-document.documentElement.style.overflowY = 'hidden';
-document.documentElement.style.scrollBehavior = 'auto';
-window.addEventListener('wheel', (ev) => {
-	if (ev.target === columnsEl && ev.deltaX === 0) {
-		columnsEl.scrollLeft += ev.deltaY;
-	} else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) {
-		columnsEl.scrollLeft += ev.deltaY;
-	}
-});
-loadDeck();
-
-function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
-	// TODO??
-}
-
-function changeProfile(ev: MouseEvent) {
-	const items = ref([{
-		text: deckStore.state.profile,
-		active: true.valueOf,
-	}]);
-	getProfiles().then(profiles => {
-		items.value = [{
-			text: deckStore.state.profile,
-			active: true.valueOf,
-		}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
-			text: k,
-			action: () => {
-				deckStore.set('profile', k);
-				unisonReload();
-			},
-		}))), null, {
-			text: i18n.ts._deck.newProfile,
-			icon: 'ti ti-plus',
-			action: async () => {
-				const { canceled, result: name } = await os.inputText({
-					title: i18n.ts._deck.profile,
-					allowEmpty: false,
-				});
-				if (canceled) return;
-
-				deckStore.set('profile', name);
-				unisonReload();
-			},
-		}];
-	});
-	os.popupMenu(items, ev.currentTarget ?? ev.target);
-}
-
-async function deleteProfile() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }),
-	});
-	if (canceled) return;
-
-	deleteProfile_(deckStore.state.profile);
-	deckStore.set('profile', 'default');
-	unisonReload();
-}
-</script>
-
-<style lang="scss" scoped>
-.menu-enter-active,
-.menu-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.menu-enter-from,
-.menu-leave-active {
-	opacity: 0;
-	transform: translateX(-240px);
-}
-
-.menu-back-enter-active,
-.menu-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.menu-back-enter-from,
-.menu-back-leave-active {
-	opacity: 0;
-}
-
-.mk-deck {
-	$nav-hide-threshold: 650px; // TODO: どこかに集約したい
-
-	// TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい
-	--margin: var(--marginHalf);
-
-	--deckDividerThickness: 5px;
-
-	display: flex;
-	height: 100dvh;
-	box-sizing: border-box;
-	flex: 1;
-
-	&.isMobile {
-		padding-bottom: 100px;
-	}
-
-	> .main {
-		flex: 1;
-		min-width: 0;
-		display: flex;
-		flex-direction: column;
-
-		> .columns {
-			flex: 1;
-			display: flex;
-			overflow-x: auto;
-			overflow-y: clip;
-
-			&.center {
-				> .column:first-of-type {
-					margin-left: auto;
-				}
-
-				> .column:last-of-type {
-					margin-right: auto;
-				}
-			}
-
-			> .column {
-				flex-shrink: 0;
-				border-right: solid var(--deckDividerThickness) var(--deckDivider);
-
-				&:first-of-type {
-					border-left: solid var(--deckDividerThickness) var(--deckDivider);
-				}
-
-				&.folder {
-					display: flex;
-					flex-direction: column;
-
-					> *:not(:last-of-type) {
-						border-bottom: solid var(--deckDividerThickness) var(--deckDivider);
-					}
-				}
-			}
-
-			> .intro {
-				padding: 32px;
-				height: min-content;
-				text-align: center;
-				margin: auto;
-
-				> .add {
-					margin: 1em auto;
-				}
-			}
-
-			> .sideMenu {
-				flex-shrink: 0;
-				margin-right: 0;
-				margin-left: auto;
-				display: flex;
-				flex-direction: column;
-				justify-content: center;
-				width: 32px;
-
-				> .top, > .middle, > .bottom {
-					> .button {
-						display: block;
-						width: 100%;
-						aspect-ratio: 1;
-					}
-				}
-
-				> .top {
-					margin-bottom: auto;
-				}
-
-				> .middle {
-					margin-top: auto;
-					margin-bottom: auto;
-				}
-
-				> .bottom {
-					margin-top: auto;
-				}
-			}
-		}
-	}
-
-	> .buttons {
-		position: fixed;
-		z-index: 1000;
-		bottom: 0;
-		left: 0;
-		padding: 16px;
-		display: flex;
-		width: 100%;
-		box-sizing: border-box;
-
-		> .button {
-			position: relative;
-			flex: 1;
-			padding: 0;
-			margin: auto;
-			height: 64px;
-			border-radius: 8px;
-			background: var(--panel);
-			color: var(--fg);
-
-			&:not(:last-child) {
-				margin-right: 12px;
-			}
-
-			@media (max-width: 400px) {
-				height: 60px;
-
-				&:not(:last-child) {
-					margin-right: 8px;
-				}
-			}
-
-			&:hover {
-				background: var(--X2);
-			}
-
-			> .indicator {
-				position: absolute;
-				top: 0;
-				left: 0;
-				color: var(--indicator);
-				font-size: 16px;
-				animation: blink 1s infinite;
-			}
-
-			&:first-child {
-				margin-left: 0;
-			}
-
-			&:last-child {
-				margin-right: 0;
-			}
-
-			> * {
-				font-size: 20px;
-			}
-
-			&:disabled {
-				cursor: default;
-
-				> * {
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-
-	> .menu-back {
-		z-index: 1001;
-	}
-
-	> .menu {
-		position: fixed;
-		top: 0;
-		left: 0;
-		z-index: 1001;
-		height: 100dvh;
-		width: 240px;
-		box-sizing: border-box;
-		contain: strict;
-		overflow: auto;
-		overscroll-behavior: contain;
-		background: var(--navBg);
-	}
-}
-</style>
diff --git a/packages/client/src/ui/deck/antenna-column.vue b/packages/client/src/ui/deck/antenna-column.vue
deleted file mode 100644
index ba14530662..0000000000
--- a/packages/client/src/ui/deck/antenna-column.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header>
-		<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
-	</template>
-
-	<XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import XColumn from './column.vue';
-import { updateColumn, Column } from './deck-store';
-import XTimeline from '@/components/MkTimeline.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'loaded'): void;
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-let timeline = $ref<InstanceType<typeof XTimeline>>();
-
-onMounted(() => {
-	if (props.column.antennaId == null) {
-		setAntenna();
-	}
-});
-
-async function setAntenna() {
-	const antennas = await os.api('antennas/list');
-	const { canceled, result: antenna } = await os.select({
-		title: i18n.ts.selectAntenna,
-		items: antennas.map(x => ({
-			value: x, text: x.name,
-		})),
-		default: props.column.antennaId,
-	});
-	if (canceled) return;
-	updateColumn(props.column.id, {
-		antennaId: antenna.id,
-	});
-}
-
-const menu = [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.selectAntenna,
-	action: setAntenna,
-}];
-
-/*
-function focus() {
-	timeline.focus();
-}
-
-defineExpose({
-	focus,
-});
-*/
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/ui/deck/column-core.vue b/packages/client/src/ui/deck/column-core.vue
deleted file mode 100644
index 30c0dc5e1c..0000000000
--- a/packages/client/src/ui/deck/column-core.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<template>
-<!-- TODO: リファクタの余地がありそう -->
-<div v-if="!column">たぶん見えちゃいけないやつ</div>
-<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XMainColumn from './main-column.vue';
-import XTlColumn from './tl-column.vue';
-import XAntennaColumn from './antenna-column.vue';
-import XListColumn from './list-column.vue';
-import XNotificationsColumn from './notifications-column.vue';
-import XWidgetsColumn from './widgets-column.vue';
-import XMentionsColumn from './mentions-column.vue';
-import XDirectColumn from './direct-column.vue';
-import { Column } from './deck-store';
-
-defineProps<{
-	column?: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-</script>
diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue
deleted file mode 100644
index 2a99b621e6..0000000000
--- a/packages/client/src/ui/deck/column.vue
+++ /dev/null
@@ -1,398 +0,0 @@
-<template>
-<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-<section
-	v-hotkey="keymap" class="dnpfarvg _narrow_"
-	:class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }"
-	@dragover.prevent.stop="onDragover"
-	@dragleave="onDragleave"
-	@drop.prevent.stop="onDrop"
->
-	<header
-		:class="{ indicated }"
-		draggable="true"
-		@click="goTop"
-		@dragstart="onDragstart"
-		@dragend="onDragend"
-		@contextmenu.prevent.stop="onContextmenu"
-	>
-		<button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive">
-			<template v-if="active"><i class="ti ti-chevron-up"></i></template>
-			<template v-else><i class="ti ti-chevron-down"></i></template>
-		</button>
-		<div class="action">
-			<slot name="action"></slot>
-		</div>
-		<span class="header"><slot name="header"></slot></span>
-		<button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button>
-	</header>
-	<div v-show="active" ref="body">
-		<slot></slot>
-	</div>
-</section>
-</template>
-
-<script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue';
-import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column, deckStore } from './deck-store';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { MenuItem } from '@/types/menu';
-
-provide('shouldHeaderThin', true);
-provide('shouldOmitHeaderTitle', true);
-provide('shouldSpacerMin', true);
-
-const props = withDefaults(defineProps<{
-	column: Column;
-	isStacked?: boolean;
-	naked?: boolean;
-	indicated?: boolean;
-	menu?: MenuItem[];
-}>(), {
-	isStacked: false,
-	naked: false,
-	indicated: false,
-});
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-	(ev: 'change-active-state', v: boolean): void;
-}>();
-
-let body = $ref<HTMLDivElement>();
-
-let dragging = $ref(false);
-watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
-
-let draghover = $ref(false);
-let dropready = $ref(false);
-
-const isMainColumn = $computed(() => props.column.type === 'main');
-const active = $computed(() => props.column.active !== false);
-watch($$(active), v => emit('change-active-state', v));
-
-const keymap = $computed(() => ({
-	'shift+up': () => emit('parent-focus', 'up'),
-	'shift+down': () => emit('parent-focus', 'down'),
-	'shift+left': () => emit('parent-focus', 'left'),
-	'shift+right': () => emit('parent-focus', 'right'),
-}));
-
-onMounted(() => {
-	os.deckGlobalEvents.on('column.dragStart', onOtherDragStart);
-	os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd);
-});
-
-onBeforeUnmount(() => {
-	os.deckGlobalEvents.off('column.dragStart', onOtherDragStart);
-	os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd);
-});
-
-function onOtherDragStart() {
-	dropready = true;
-}
-
-function onOtherDragEnd() {
-	dropready = false;
-}
-
-function toggleActive() {
-	if (!props.isStacked) return;
-	updateColumn(props.column.id, {
-		active: !props.column.active,
-	});
-}
-
-function getMenu() {
-	let items = [{
-		icon: 'ti ti-settings',
-		text: i18n.ts._deck.configureColumn,
-		action: async () => {
-			const { canceled, result } = await os.form(props.column.name, {
-				name: {
-					type: 'string',
-					label: i18n.ts.name,
-					default: props.column.name,
-				},
-				width: {
-					type: 'number',
-					label: i18n.ts.width,
-					default: props.column.width,
-				},
-				flexible: {
-					type: 'boolean',
-					label: i18n.ts.flexible,
-					default: props.column.flexible,
-				},
-			});
-			if (canceled) return;
-			updateColumn(props.column.id, result);
-		},
-	}, {
-		type: 'parent',
-		text: i18n.ts.move + '...',
-		icon: 'ti ti-arrows-move',
-		children: [{
-			icon: 'ti ti-arrow-left',
-			text: i18n.ts._deck.swapLeft,
-			action: () => {
-				swapLeftColumn(props.column.id);
-			},
-		}, {
-			icon: 'ti ti-arrow-right',
-			text: i18n.ts._deck.swapRight,
-			action: () => {
-				swapRightColumn(props.column.id);
-			},
-		}, props.isStacked ? {
-			icon: 'ti ti-arrow-up',
-			text: i18n.ts._deck.swapUp,
-			action: () => {
-				swapUpColumn(props.column.id);
-			},
-		} : undefined, props.isStacked ? {
-			icon: 'ti ti-arrow-down',
-			text: i18n.ts._deck.swapDown,
-			action: () => {
-				swapDownColumn(props.column.id);
-			},
-		} : undefined],
-	}, {
-		icon: 'ti ti-stack-2',
-		text: i18n.ts._deck.stackLeft,
-		action: () => {
-			stackLeftColumn(props.column.id);
-		},
-	}, props.isStacked ? {
-		icon: 'ti ti-window-maximize',
-		text: i18n.ts._deck.popRight,
-		action: () => {
-			popRightColumn(props.column.id);
-		},
-	} : undefined, null, {
-		icon: 'ti ti-trash',
-		text: i18n.ts.remove,
-		danger: true,
-		action: () => {
-			removeColumn(props.column.id);
-		},
-	}];
-
-	if (props.menu) {
-		items.unshift(null);
-		items = props.menu.concat(items);
-	}
-
-	return items;
-}
-
-function showSettingsMenu(ev: MouseEvent) {
-	os.popupMenu(getMenu(), ev.currentTarget ?? ev.target);
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu(getMenu(), ev);
-}
-
-function goTop() {
-	body.scrollTo({
-		top: 0,
-		behavior: 'smooth',
-	});
-}
-
-function onDragstart(ev) {
-	ev.dataTransfer.effectAllowed = 'move';
-	ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id);
-
-	// Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
-	// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
-	window.setTimeout(() => {
-		dragging = true;
-	}, 10);
-}
-
-function onDragend(ev) {
-	dragging = false;
-}
-
-function onDragover(ev) {
-	// 自分自身がドラッグされている場合
-	if (dragging) {
-		// 自分自身にはドロップさせない
-		ev.dataTransfer.dropEffect = 'none';
-	} else {
-		const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_;
-
-		ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
-
-		if (isDeckColumn) draghover = true;
-	}
-}
-
-function onDragleave() {
-	draghover = false;
-}
-
-function onDrop(ev) {
-	draghover = false;
-	os.deckGlobalEvents.emit('column.dragEnd');
-
-	const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_);
-	if (id != null && id !== '') {
-		swapColumn(props.column.id, id);
-	}
-}
-</script>
-
-<style lang="scss" scoped>
-.dnpfarvg {
-	--root-margin: 10px;
-	--deckColumnHeaderHeight: 40px;
-
-	height: 100%;
-	overflow: hidden;
-	contain: strict;
-
-	&.draghover {
-		&:after {
-			content: "";
-			display: block;
-			position: absolute;
-			z-index: 1000;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: var(--focus);
-		}
-	}
-
-	&.dragging {
-		&:after {
-			content: "";
-			display: block;
-			position: absolute;
-			z-index: 1000;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background: var(--focus);
-			opacity: 0.5;
-		}
-	}
-
-	&.dropready {
-		* {
-			pointer-events: none;
-		}
-	}
-
-	&:not(.active) {
-		flex-basis: var(--deckColumnHeaderHeight);
-		min-height: var(--deckColumnHeaderHeight);
-
-		> header.indicated {
-			box-shadow: 4px 0px var(--accent) inset;
-		}
-	}
-
-	&.naked {
-		background: var(--acrylicBg) !important;
-		-webkit-backdrop-filter: var(--blur, blur(10px));
-		backdrop-filter: var(--blur, blur(10px));
-
-		> header {
-			background: transparent;
-			box-shadow: none;
-
-			> button {
-				color: var(--fg);
-			}
-		}
-	}
-
-	&.paged {
-		background: var(--bg) !important;
-	}
-
-	> header {
-		position: relative;
-		display: flex;
-		z-index: 2;
-		line-height: var(--deckColumnHeaderHeight);
-		height: var(--deckColumnHeaderHeight);
-		padding: 0 16px;
-		font-size: 0.9em;
-		color: var(--panelHeaderFg);
-		background: var(--panelHeaderBg);
-		box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
-		cursor: pointer;
-
-		&, * {
-			user-select: none;
-		}
-
-		&.indicated {
-			box-shadow: 0 3px 0 0 var(--accent);
-		}
-
-		> .header {
-			display: inline-block;
-			align-items: center;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-		}
-
-		> span:only-of-type {
-			width: 100%;
-		}
-
-		> .toggleActive,
-		> .action > ::v-deep(*),
-		> .menu {
-			z-index: 1;
-			width: var(--deckColumnHeaderHeight);
-			line-height: var(--deckColumnHeaderHeight);
-			color: var(--faceTextButton);
-
-			&:hover {
-				color: var(--faceTextButtonHover);
-			}
-
-			&:active {
-				color: var(--faceTextButtonActive);
-			}
-		}
-
-		> .toggleActive, > .action {
-			margin-left: -16px;
-		}
-
-		> .action {
-			z-index: 1;
-		}
-
-		> .action:empty {
-			display: none;
-		}
-
-		> .menu {
-			margin-left: auto;
-			margin-right: -16px;
-		}
-	}
-
-	> div {
-		height: calc(100% - var(--deckColumnHeaderHeight));
-		overflow-y: auto;
-		overflow-x: hidden; // Safari does not supports clip
-		overflow-x: clip;
-		-webkit-overflow-scrolling: touch;
-		box-sizing: border-box;
-		background-color: var(--bg);
-	}
-}
-</style>
diff --git a/packages/client/src/ui/deck/deck-store.ts b/packages/client/src/ui/deck/deck-store.ts
deleted file mode 100644
index 56db7398e5..0000000000
--- a/packages/client/src/ui/deck/deck-store.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-import { throttle } from 'throttle-debounce';
-import { markRaw } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import { Storage } from '../../pizzax';
-import { i18n } from '@/i18n';
-import { api } from '@/os';
-import { deepClone } from '@/scripts/clone';
-
-type ColumnWidget = {
-	name: string;
-	id: string;
-	data: Record<string, any>;
-};
-
-export type Column = {
-	id: string;
-	type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct';
-	name: string | null;
-	width: number;
-	widgets?: ColumnWidget[];
-	active?: boolean;
-	flexible?: boolean;
-	antennaId?: string;
-	listId?: string;
-	includingTypes?: typeof notificationTypes[number][];
-	tl?: 'home' | 'local' | 'social' | 'global';
-};
-
-export const deckStore = markRaw(new Storage('deck', {
-	profile: {
-		where: 'deviceAccount',
-		default: 'default',
-	},
-	columns: {
-		where: 'deviceAccount',
-		default: [] as Column[],
-	},
-	layout: {
-		where: 'deviceAccount',
-		default: [] as Column['id'][][],
-	},
-	columnAlign: {
-		where: 'deviceAccount',
-		default: 'left' as 'left' | 'right' | 'center',
-	},
-	alwaysShowMainColumn: {
-		where: 'deviceAccount',
-		default: true,
-	},
-	navWindow: {
-		where: 'deviceAccount',
-		default: true,
-	},
-}));
-
-export const loadDeck = async () => {
-	let deck;
-
-	try {
-		deck = await api('i/registry/get', {
-			scope: ['client', 'deck', 'profiles'],
-			key: deckStore.state.profile,
-		});
-	} catch (err) {
-		if (err.code === 'NO_SUCH_KEY') {
-			// 後方互換性のため
-			if (deckStore.state.profile === 'default') {
-				saveDeck();
-				return;
-			}
-
-			deckStore.set('columns', []);
-			deckStore.set('layout', []);
-			return;
-		}
-		throw err;
-	}
-
-	deckStore.set('columns', deck.columns);
-	deckStore.set('layout', deck.layout);
-};
-
-// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
-export const saveDeck = throttle(1000, () => {
-	api('i/registry/set', {
-		scope: ['client', 'deck', 'profiles'],
-		key: deckStore.state.profile,
-		value: {
-			columns: deckStore.reactiveState.columns.value,
-			layout: deckStore.reactiveState.layout.value,
-		},
-	});
-});
-
-export async function getProfiles(): Promise<string[]> {
-	return await api('i/registry/keys', {
-		scope: ['client', 'deck', 'profiles'],
-	});
-}
-
-export async function deleteProfile(key: string): Promise<void> {
-	return await api('i/registry/remove', {
-		scope: ['client', 'deck', 'profiles'],
-		key: key,
-	});
-}
-
-export function addColumn(column: Column) {
-	if (column.name === undefined) column.name = null;
-	deckStore.push('columns', column);
-	deckStore.push('layout', [column.id]);
-	saveDeck();
-}
-
-export function removeColumn(id: Column['id']) {
-	deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id));
-	deckStore.set('layout', deckStore.state.layout
-		.map(ids => ids.filter(_id => _id !== id))
-		.filter(ids => ids.length > 0));
-	saveDeck();
-}
-
-export function swapColumn(a: Column['id'], b: Column['id']) {
-	const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1);
-	const aY = deckStore.state.layout[aX].findIndex(id => id === a);
-	const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1);
-	const bY = deckStore.state.layout[bX].findIndex(id => id === b);
-	const layout = deepClone(deckStore.state.layout);
-	layout[aX][aY] = b;
-	layout[bX][bY] = a;
-	deckStore.set('layout', layout);
-	saveDeck();
-}
-
-export function swapLeftColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	deckStore.state.layout.some((ids, i) => {
-		if (ids.includes(id)) {
-			const left = deckStore.state.layout[i - 1];
-			if (left) {
-				layout[i - 1] = deckStore.state.layout[i];
-				layout[i] = left;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-	});
-	saveDeck();
-}
-
-export function swapRightColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	deckStore.state.layout.some((ids, i) => {
-		if (ids.includes(id)) {
-			const right = deckStore.state.layout[i + 1];
-			if (right) {
-				layout[i + 1] = deckStore.state.layout[i];
-				layout[i] = right;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-	});
-	saveDeck();
-}
-
-export function swapUpColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const ids = deepClone(deckStore.state.layout[idsIndex]);
-	ids.some((x, i) => {
-		if (x === id) {
-			const up = ids[i - 1];
-			if (up) {
-				ids[i - 1] = id;
-				ids[i] = up;
-
-				layout[idsIndex] = ids;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-	});
-	saveDeck();
-}
-
-export function swapDownColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const ids = deepClone(deckStore.state.layout[idsIndex]);
-	ids.some((x, i) => {
-		if (x === id) {
-			const down = ids[i + 1];
-			if (down) {
-				ids[i + 1] = id;
-				ids[i] = down;
-
-				layout[idsIndex] = ids;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-	});
-	saveDeck();
-}
-
-export function stackLeftColumn(id: Column['id']) {
-	let layout = deepClone(deckStore.state.layout);
-	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	layout = layout.map(ids => ids.filter(_id => _id !== id));
-	layout[i - 1].push(id);
-	layout = layout.filter(ids => ids.length > 0);
-	deckStore.set('layout', layout);
-	saveDeck();
-}
-
-export function popRightColumn(id: Column['id']) {
-	let layout = deepClone(deckStore.state.layout);
-	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const affected = layout[i];
-	layout = layout.map(ids => ids.filter(_id => _id !== id));
-	layout.splice(i + 1, 0, [id]);
-	layout = layout.filter(ids => ids.length > 0);
-	deckStore.set('layout', layout);
-
-	const columns = deepClone(deckStore.state.columns);
-	for (const column of columns) {
-		if (affected.includes(column.id)) {
-			column.active = true;
-		}
-	}
-	deckStore.set('columns', columns);
-
-	saveDeck();
-}
-
-export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	if (column.widgets == null) column.widgets = [];
-	column.widgets.unshift(widget);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	column.widgets = column.widgets.filter(w => w.id !== widget.id);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	column.widgets = widgets;
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	column.widgets = column.widgets.map(w => w.id === widgetId ? {
-		...w,
-		data: widgetData,
-	} : w);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function updateColumn(id: Column['id'], column: Partial<Column>) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const currentColumn = deepClone(deckStore.state.columns[columnIndex]);
-	if (currentColumn == null) return;
-	for (const [k, v] of Object.entries(column)) {
-		currentColumn[k] = v;
-	}
-	columns[columnIndex] = currentColumn;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
diff --git a/packages/client/src/ui/deck/direct-column.vue b/packages/client/src/ui/deck/direct-column.vue
deleted file mode 100644
index 75b018cacd..0000000000
--- a/packages/client/src/ui/deck/direct-column.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<template>
-<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template>
-
-	<XNotes :pagination="pagination"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XColumn from './column.vue';
-import XNotes from '@/components/MkNotes.vue';
-import { Column } from './deck-store';
-
-defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-const pagination = {
-	endpoint: 'notes/mentions' as const,
-	limit: 10,
-	params: {
-		visibility: 'specified',
-	},
-};
-</script>
diff --git a/packages/client/src/ui/deck/list-column.vue b/packages/client/src/ui/deck/list-column.vue
deleted file mode 100644
index d9f3f7b4e7..0000000000
--- a/packages/client/src/ui/deck/list-column.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header>
-		<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
-	</template>
-
-	<XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XColumn from './column.vue';
-import { updateColumn, Column } from './deck-store';
-import XTimeline from '@/components/MkTimeline.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'loaded'): void;
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-let timeline = $ref<InstanceType<typeof XTimeline>>();
-
-if (props.column.listId == null) {
-	setList();
-}
-
-async function setList() {
-	const lists = await os.api('users/lists/list');
-	const { canceled, result: list } = await os.select({
-		title: i18n.ts.selectList,
-		items: lists.map(x => ({
-			value: x, text: x.name,
-		})),
-		default: props.column.listId,
-	});
-	if (canceled) return;
-	updateColumn(props.column.id, {
-		listId: list.id,
-	});
-}
-
-const menu = [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.selectList,
-	action: setList,
-}];
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue
deleted file mode 100644
index 0c66172397..0000000000
--- a/packages/client/src/ui/deck/main-column.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<template>
-<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header>
-		<template v-if="pageMetadata?.value">
-			<i :class="pageMetadata?.value.icon"></i>
-			{{ pageMetadata?.value.title }}
-		</template>
-	</template>
-
-	<RouterView @contextmenu.stop="onContextmenu"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, provide } from 'vue';
-import XColumn from './column.vue';
-import { deckStore, Column } from '@/ui/deck/deck-store';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-
-defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-});
-
-/*
-function back() {
-	history.back();
-}
-*/
-function onContextmenu(ev: MouseEvent) {
-	if (!ev.target) return;
-
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target as HTMLElement)) return;
-	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return;
-	if (window.getSelection()?.toString() !== '') return;
-	const path = mainRouter.currentRoute.value.path;
-	os.contextMenu([{
-		type: 'label',
-		text: path,
-	}, {
-		icon: 'ti ti-window-maximize',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			os.pageWindow(path);
-		},
-	}], ev);
-}
-</script>
diff --git a/packages/client/src/ui/deck/mentions-column.vue b/packages/client/src/ui/deck/mentions-column.vue
deleted file mode 100644
index 16962956a0..0000000000
--- a/packages/client/src/ui/deck/mentions-column.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<template>
-<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
-
-	<XNotes :pagination="pagination"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XColumn from './column.vue';
-import XNotes from '@/components/MkNotes.vue';
-import { Column } from './deck-store';
-
-defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-const pagination = {
-	endpoint: 'notes/mentions' as const,
-	limit: 10,
-};
-</script>
diff --git a/packages/client/src/ui/deck/notifications-column.vue b/packages/client/src/ui/deck/notifications-column.vue
deleted file mode 100644
index 9d133035fe..0000000000
--- a/packages/client/src/ui/deck/notifications-column.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<template>
-<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
-
-	<XNotifications :include-types="column.includingTypes"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import XColumn from './column.vue';
-import { updateColumn, Column } from './deck-store';
-import XNotifications from '@/components/MkNotifications.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-function func() {
-	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
-		includingTypes: props.column.includingTypes,
-	}, {
-		done: async (res) => {
-			const { includingTypes } = res;
-			updateColumn(props.column.id, {
-				includingTypes: includingTypes,
-			});
-		},
-	}, 'closed');
-}
-
-const menu = [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.notificationSetting,
-	action: func,
-}];
-</script>
diff --git a/packages/client/src/ui/deck/tl-column.vue b/packages/client/src/ui/deck/tl-column.vue
deleted file mode 100644
index 49b29145ff..0000000000
--- a/packages/client/src/ui/deck/tl-column.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header>
-		<i v-if="column.tl === 'home'" class="ti ti-home"></i>
-		<i v-else-if="column.tl === 'local'" class="ti ti-messages"></i>
-		<i v-else-if="column.tl === 'social'" class="ti ti-share"></i>
-		<i v-else-if="column.tl === 'global'" class="ti ti-world"></i>
-		<span style="margin-left: 8px;">{{ column.name }}</span>
-	</template>
-
-	<div v-if="disabled" class="iwaalbte">
-		<p>
-			<i class="ti ti-minus-circle"></i>
-			{{ $t('disabled-timeline.title') }}
-		</p>
-		<p class="desc">{{ $t('disabled-timeline.description') }}</p>
-	</div>
-	<XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import XColumn from './column.vue';
-import { removeColumn, updateColumn, Column } from './deck-store';
-import XTimeline from '@/components/MkTimeline.vue';
-import * as os from '@/os';
-import { $i } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'loaded'): void;
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-let disabled = $ref(false);
-let indicated = $ref(false);
-let columnActive = $ref(true);
-
-onMounted(() => {
-	if (props.column.tl == null) {
-		setType();
-	} else if ($i) {
-		disabled = !$i.isModerator && !$i.isAdmin && (
-			instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) ||
-			instance.disableGlobalTimeline && ['global'].includes(props.column.tl));
-	}
-});
-
-async function setType() {
-	const { canceled, result: src } = await os.select({
-		title: i18n.ts.timeline,
-		items: [{
-			value: 'home' as const, text: i18n.ts._timelines.home,
-		}, {
-			value: 'local' as const, text: i18n.ts._timelines.local,
-		}, {
-			value: 'social' as const, text: i18n.ts._timelines.social,
-		}, {
-			value: 'global' as const, text: i18n.ts._timelines.global,
-		}],
-	});
-	if (canceled) {
-		if (props.column.tl == null) {
-			removeColumn(props.column.id);
-		}
-		return;
-	}
-	updateColumn(props.column.id, {
-		tl: src,
-	});
-}
-
-function queueUpdated(q) {
-	if (columnActive) {
-		indicated = q !== 0;
-	}
-}
-
-function onNote() {
-	if (!columnActive) {
-		indicated = true;
-	}
-}
-
-function onChangeActiveState(state) {
-	columnActive = state;
-
-	if (columnActive) {
-		indicated = false;
-	}
-}
-
-const menu = [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.timeline,
-	action: setType,
-}];
-</script>
-
-<style lang="scss" scoped>
-.iwaalbte {
-	text-align: center;
-
-	> p {
-		margin: 16px;
-
-		&.desc {
-			font-size: 14px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/deck/widgets-column.vue b/packages/client/src/ui/deck/widgets-column.vue
deleted file mode 100644
index fc61d18ff6..0000000000
--- a/packages/client/src/ui/deck/widgets-column.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<template>
-<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
-	<template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template>
-
-	<div class="wtdtxvec">
-		<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div>
-		<XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
-	</div>
-</XColumn>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XColumn from './column.vue';
-import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
-import XWidgets from '@/components/MkWidgets.vue';
-import { i18n } from '@/i18n';
-
-const props = defineProps<{
-	column: Column;
-	isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
-let edit = $ref(false);
-
-function addWidget(widget) {
-	addColumnWidget(props.column.id, widget);
-}
-
-function removeWidget(widget) {
-	removeColumnWidget(props.column.id, widget);
-}
-
-function updateWidget({ id, data }) {
-	updateColumnWidget(props.column.id, id, data);
-}
-
-function updateWidgets(widgets) {
-	setColumnWidgets(props.column.id, widgets);
-}
-
-function func() {
-	edit = !edit;
-}
-
-const menu = [{
-	icon: 'ti ti-pencil',
-	text: i18n.ts.editWidgets,
-	action: func,
-}];
-</script>
-
-<style lang="scss" scoped>
-.wtdtxvec {
-	--margin: 8px;
-	--panelBorder: none;
-
-	padding: 0 var(--margin);
-
-	> .intro {
-		padding: 16px;
-		text-align: center;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue
deleted file mode 100644
index b91bf476e8..0000000000
--- a/packages/client/src/ui/universal.vue
+++ /dev/null
@@ -1,390 +0,0 @@
-<template>
-<div class="dkgtipfy" :class="{ wallpaper }">
-	<XSidebar v-if="!isMobile" class="sidebar"/>
-
-	<MkStickyContainer class="contents">
-		<template #header><XStatusBars :class="$style.statusbars"/></template>
-		<main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
-			<div :class="$style.content">
-				<RouterView/>
-			</div>
-			<div :class="$style.spacer"></div>
-		</main>
-	</MkStickyContainer>
-
-	<div v-if="isDesktop" ref="widgetsEl" class="widgets">
-		<XWidgets @mounted="attachSticky"/>
-	</div>
-
-	<button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
-
-	<div v-if="isMobile" class="buttons">
-		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button>
-		<button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="ti ti-home"></i></button>
-		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button>
-		<button class="button widget _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
-		<button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button>
-	</div>
-
-	<transition :name="$store.state.animation ? 'menuDrawer-back' : ''">
-		<div
-			v-if="drawerMenuShowing"
-			class="menuDrawer-back _modalBg"
-			@click="drawerMenuShowing = false"
-			@touchstart.passive="drawerMenuShowing = false"
-		></div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'menuDrawer' : ''">
-		<XDrawerMenu v-if="drawerMenuShowing" class="menuDrawer"/>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''">
-		<div
-			v-if="widgetsShowing"
-			class="widgetsDrawer-back _modalBg"
-			@click="widgetsShowing = false"
-			@touchstart.passive="widgetsShowing = false"
-		></div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'widgetsDrawer' : ''">
-		<XWidgets v-if="widgetsShowing" class="widgetsDrawer"/>
-	</transition>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue';
-import XCommon from './_common_/common.vue';
-import { instanceName } from '@/config';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
-import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
-import * as os from '@/os';
-import { defaultStore } from '@/store';
-import { navbarItemDef } from '@/navbar';
-import { i18n } from '@/i18n';
-import { $i } from '@/account';
-import { Router } from '@/nirax';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-import { deviceKind } from '@/scripts/device-kind';
-const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
-const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
-const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
-
-const DESKTOP_THRESHOLD = 1100;
-const MOBILE_THRESHOLD = 500;
-
-// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない
-const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
-const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
-window.addEventListener('resize', () => {
-	isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD;
-});
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-const widgetsEl = $ref<HTMLElement>();
-const widgetsShowing = $ref(false);
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-	if (pageMetadata.value) {
-		document.title = `${pageMetadata.value.title} | ${instanceName}`;
-	}
-});
-
-const menuIndicated = computed(() => {
-	for (const def in navbarItemDef) {
-		if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
-		if (navbarItemDef[def].indicated) return true;
-	}
-	return false;
-});
-
-const drawerMenuShowing = ref(false);
-
-mainRouter.on('change', () => {
-	drawerMenuShowing.value = false;
-});
-
-document.documentElement.style.overflowY = 'scroll';
-
-if (defaultStore.state.widgets.length === 0) {
-	defaultStore.set('widgets', [{
-		name: 'calendar',
-		id: 'a', place: 'right', data: {},
-	}, {
-		name: 'notifications',
-		id: 'b', place: 'right', data: {},
-	}, {
-		name: 'trends',
-		id: 'c', place: 'right', data: {},
-	}]);
-}
-
-onMounted(() => {
-	if (!isDesktop.value) {
-		window.addEventListener('resize', () => {
-			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop.value = true;
-		}, { passive: true });
-	}
-});
-
-const onContextmenu = (ev) => {
-	const isLink = (el: HTMLElement) => {
-		if (el.tagName === 'A') return true;
-		if (el.parentElement) {
-			return isLink(el.parentElement);
-		}
-	};
-	if (isLink(ev.target)) return;
-	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
-	if (window.getSelection()?.toString() !== '') return;
-	const path = mainRouter.getCurrentPath();
-	os.contextMenu([{
-		type: 'label',
-		text: path,
-	}, {
-		icon: 'ti ti-window-maximize',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			os.pageWindow(path);
-		},
-	}], ev);
-};
-
-const attachSticky = (el) => {
-	const sticky = new StickySidebar(widgetsEl);
-	window.addEventListener('scroll', () => {
-		sticky.calc(window.scrollY);
-	}, { passive: true });
-};
-
-function top() {
-	window.scroll({ top: 0, behavior: 'smooth' });
-}
-
-const wallpaper = localStorage.getItem('wallpaper') != null;
-</script>
-
-<style lang="scss" scoped>
-.widgetsDrawer-enter-active,
-.widgetsDrawer-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.widgetsDrawer-enter-from,
-.widgetsDrawer-leave-active {
-	opacity: 0;
-	transform: translateX(240px);
-}
-
-.widgetsDrawer-back-enter-active,
-.widgetsDrawer-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.widgetsDrawer-back-enter-from,
-.widgetsDrawer-back-leave-active {
-	opacity: 0;
-}
-
-.menuDrawer-enter-active,
-.menuDrawer-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.menuDrawer-enter-from,
-.menuDrawer-leave-active {
-	opacity: 0;
-	transform: translateX(-240px);
-}
-
-.menuDrawer-back-enter-active,
-.menuDrawer-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.menuDrawer-back-enter-from,
-.menuDrawer-back-leave-active {
-	opacity: 0;
-}
-
-.dkgtipfy {
-	$ui-font-size: 1em; // TODO: どこかに集約したい
-	$widgets-hide-threshold: 1090px;
-
-	min-height: 100dvh;
-	box-sizing: border-box;
-	display: flex;
-
-	&.wallpaper {
-		background: var(--wallpaperOverlay);
-		//backdrop-filter: var(--blur, blur(4px));
-	}
-
-	> .sidebar {
-		border-right: solid 0.5px var(--divider);
-	}
-
-	> .contents {
-		width: 100%;
-		min-width: 0;
-		background: var(--bg);
-	}
-
-	> .widgets {
-		padding: 0 var(--margin);
-		border-left: solid 0.5px var(--divider);
-		background: var(--bg);
-
-		@media (max-width: $widgets-hide-threshold) {
-			display: none;
-		}
-	}
-
-	> .widgetButton {
-		display: block;
-		position: fixed;
-		z-index: 1000;
-		bottom: 32px;
-		right: 32px;
-		width: 64px;
-		height: 64px;
-		border-radius: 100%;
-		box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
-		font-size: 22px;
-		background: var(--panel);
-	}
-
-	> .widgetsDrawer-back {
-		z-index: 1001;
-	}
-
-	> .widgetsDrawer {
-		position: fixed;
-		top: 0;
-		right: 0;
-		z-index: 1001;
-		height: 100dvh;
-		padding: var(--margin);
-		box-sizing: border-box;
-		overflow: auto;
-		overscroll-behavior: contain;
-		background: var(--bg);
-	}
-
-	> .buttons {
-		position: fixed;
-		z-index: 1000;
-		bottom: 0;
-		left: 0;
-		padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
-		display: flex;
-		width: 100%;
-		box-sizing: border-box;
-		-webkit-backdrop-filter: var(--blur, blur(32px));
-		backdrop-filter: var(--blur, blur(32px));
-		background-color: var(--header);
-		border-top: solid 0.5px var(--divider);
-
-		> .button {
-			position: relative;
-			flex: 1;
-			padding: 0;
-			margin: auto;
-			height: 64px;
-			border-radius: 8px;
-			background: var(--panel);
-			color: var(--fg);
-
-			&:not(:last-child) {
-				margin-right: 12px;
-			}
-
-			@media (max-width: 400px) {
-				height: 60px;
-
-				&:not(:last-child) {
-					margin-right: 8px;
-				}
-			}
-
-			&:hover {
-				background: var(--X2);
-			}
-
-			> .indicator {
-				position: absolute;
-				top: 0;
-				left: 0;
-				color: var(--indicator);
-				font-size: 16px;
-				animation: blink 1s infinite;
-			}
-
-			&:first-child {
-				margin-left: 0;
-			}
-
-			&:last-child {
-				margin-right: 0;
-			}
-
-			> * {
-				font-size: 20px;
-			}
-
-			&:disabled {
-				cursor: default;
-
-				> * {
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-
-	> .menuDrawer-back {
-		z-index: 1001;
-	}
-
-	> .menuDrawer {
-		position: fixed;
-		top: 0;
-		left: 0;
-		z-index: 1001;
-		height: 100dvh;
-		width: 240px;
-		box-sizing: border-box;
-		contain: strict;
-		overflow: auto;
-		overscroll-behavior: contain;
-		background: var(--navBg);
-	}
-}
-</style>
-
-<style lang="scss" module>
-.statusbars {
-	position: sticky;
-	top: 0;
-	left: 0;
-}
-
-.spacer {
-	$widgets-hide-threshold: 1090px;
-
-	height: calc(env(safe-area-inset-bottom, 0px) + 96px);
-
-	@media (min-width: ($widgets-hide-threshold + 1px)) {
-		display: none;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/universal.widgets.vue b/packages/client/src/ui/universal.widgets.vue
deleted file mode 100644
index 33fb492836..0000000000
--- a/packages/client/src/ui/universal.widgets.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<template>
-<div class="efzpzdvf">
-	<XWidgets :edit="editMode" :widgets="defaultStore.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
-
-	<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
-	<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import XWidgets from '@/components/MkWidgets.vue';
-import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
-
-const emit = defineEmits<{
-	(ev: 'mounted', el: Element): void;
-}>();
-
-let editMode = $ref(false);
-let rootEl = $ref<HTMLDivElement>();
-
-onMounted(() => {
-	emit('mounted', rootEl);
-});
-
-function addWidget(widget) {
-	defaultStore.set('widgets', [{
-		...widget,
-		place: null,
-	}, ...defaultStore.state.widgets]);
-}
-
-function removeWidget(widget) {
-	defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id));
-}
-
-function updateWidget({ id, data }) {
-	defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? {
-		...w,
-		data,
-	} : w));
-}
-
-function updateWidgets(widgets) {
-	defaultStore.set('widgets', widgets);
-}
-</script>
-
-<style lang="scss" scoped>
-.efzpzdvf {
-	position: sticky;
-	height: min-content;
-	min-height: 100vh;
-	padding: var(--margin) 0;
-	box-sizing: border-box;
-
-	> * {
-		margin: var(--margin) 0;
-		width: 300px;
-
-		&:first-child {
-			margin-top: 0;
-		}
-	}
-
-	> .add {
-		margin: 0 auto;
-	}
-}
-</style>
diff --git a/packages/client/src/ui/visitor.vue b/packages/client/src/ui/visitor.vue
deleted file mode 100644
index ec9150d346..0000000000
--- a/packages/client/src/ui/visitor.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<template>
-<DesignB/>
-<XCommon/>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import DesignA from './visitor/a.vue';
-import DesignB from './visitor/b.vue';
-import XCommon from './_common_/common.vue';
-
-export default defineComponent({
-	components: {
-		XCommon,
-		DesignA,
-		DesignB,
-	},
-});
-</script>
diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue
deleted file mode 100644
index f8db7a9d09..0000000000
--- a/packages/client/src/ui/visitor/a.vue
+++ /dev/null
@@ -1,259 +0,0 @@
-<template>
-<div class="mk-app">
-	<div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
-		<div>
-			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
-			<div v-if="meta" class="about">
-				<!-- eslint-disable-next-line vue/no-v-html -->
-				<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
-			</div>
-			<div class="action">
-				<button class="_button primary" @click="signup()">{{ $ts.signup }}</button>
-				<button class="_button" @click="signin()">{{ $ts.login }}</button>
-			</div>
-		</div>
-	</div>
-	<div v-else class="banner-mini" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
-		<div>
-			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
-		</div>
-	</div>
-
-	<div class="main">
-		<div ref="contents" class="contents" :class="{ wallpaper }">
-			<header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header">
-				<XHeader :info="pageInfo"/>
-			</header>
-			<main ref="main">
-				<RouterView/>
-			</main>
-			<div class="powered-by">
-				<b><MkA to="/">{{ host }}</MkA></b>
-				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import XHeader from './header.vue';
-import { host, instanceName } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import MkPagination from '@/components/MkPagination.vue';
-import MkButton from '@/components/MkButton.vue';
-import { ColdDeviceStorage } from '@/store';
-import { mainRouter } from '@/router';
-
-const DESKTOP_THRESHOLD = 1100;
-
-export default defineComponent({
-	components: {
-		XHeader,
-		MkPagination,
-		MkButton,
-	},
-
-	data() {
-		return {
-			host,
-			instanceName,
-			pageInfo: null,
-			meta: null,
-			narrow: window.innerWidth < 1280,
-			announcements: {
-				endpoint: 'announcements',
-				limit: 10,
-			},
-			mainRouter,
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-		};
-	},
-
-	computed: {
-		keymap(): any {
-			return {
-				'd': () => {
-					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
-					this.$store.set('darkMode', !this.$store.state.darkMode);
-				},
-				's': search,
-				'h|/': this.help,
-			};
-		},
-	},
-
-	created() {
-		document.documentElement.style.overflowY = 'scroll';
-
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-	},
-
-	mounted() {
-		if (!this.isDesktop) {
-			window.addEventListener('resize', () => {
-				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
-			}, { passive: true });
-		}
-	},
-
-	methods: {
-		// @ThatOneCalculator: Are these methods even used?
-		// I can't find references to them anywhere else in the code...
-
-		// setParallax(el) {
-		// 	new simpleParallax(el);
-		// },
-
-		changePage(page) {
-			if (page == null) return;
-			// eslint-disable-next-line no-undef
-			if (page[symbols.PAGE_INFO]) {
-				// eslint-disable-next-line no-undef
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		help() {
-			window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-app {
-	min-height: 100vh;
-
-	> .banner {
-		position: relative;
-		width: 100%;
-		text-align: center;
-		background-position: center;
-		background-size: cover;
-
-		> div {
-			height: 100%;
-			background: rgba(0, 0, 0, 0.3);
-
-			* {
-				color: #fff;
-			}
-					
-			> h1 {
-				margin: 0;
-				padding: 96px 32px 0 32px;
-				text-shadow: 0 0 8px black;
-
-				> .logo {
-					vertical-align: bottom;
-					max-height: 150px;
-				}
-			}
-
-			> .about {
-				padding: 32px;
-				max-width: 580px;
-				margin: 0 auto;
-				box-sizing: border-box;
-				text-shadow: 0 0 8px black;
-			}
-
-			> .action {
-				padding-bottom: 64px;
-				
-				> button {
-					display: inline-block;
-					padding: 10px 20px;
-					box-sizing: border-box;
-					text-align: center;
-					border-radius: 999px;
-					background: var(--panel);
-					color: var(--fg);
-
-					&.primary {
-						background: var(--accent);
-						color: #fff;
-					}
-
-					&:first-child {
-						margin-right: 16px;
-					}
-				}
-			}
-		}
-	}
-
-	> .banner-mini {
-		position: relative;
-		width: 100%;
-		text-align: center;
-		background-position: center;
-		background-size: cover;
-
-		> div {
-			position: relative;
-			z-index: 1;
-			height: 100%;
-			background: rgba(0, 0, 0, 0.3);
-
-			* {
-				color: #fff !important;
-			}
-
-			> header {
-				
-			}
-					
-			> h1 {
-				margin: 0;
-				padding: 32px;
-				text-shadow: 0 0 8px black;
-
-				> .logo {
-					vertical-align: bottom;
-					max-height: 100px;
-				}
-			}
-		}
-	}
-
-	> .main {
-		> .contents {
-			position: relative;
-			z-index: 1;
-
-			> .header {
-				position: sticky;
-				top: 0;
-				left: 0;
-				z-index: 1000;
-			}
-
-			> .powered-by {
-				padding: 28px;
-				font-size: 14px;
-				text-align: center;
-				border-top: 1px solid var(--divider);
-
-				> small {
-					display: block;
-					margin-top: 8px;
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-}
-</style>
-
-<style lang="scss">
-</style>
diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue
deleted file mode 100644
index 275008a8f8..0000000000
--- a/packages/client/src/ui/visitor/b.vue
+++ /dev/null
@@ -1,248 +0,0 @@
-<template>
-<div class="mk-app">
-	<a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
-
-	<div v-if="!narrow && !root" class="side">
-		<XKanban class="kanban" full/>
-	</div>
-
-	<div class="main">
-		<XKanban v-if="narrow && !root" class="banner" :powered-by="root"/>
-
-		<div class="contents">
-			<XHeader v-if="!root" class="header" :info="pageInfo"/>
-			<main>
-				<RouterView/>
-			</main>
-			<div v-if="!root" class="powered-by">
-				<b><MkA to="/">{{ host }}</MkA></b>
-				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
-			</div>
-		</div>
-	</div>
-
-	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div
-			v-if="showMenu"
-			class="menu-back _modalBg"
-			@click="showMenu = false"
-			@touchstart.passive="showMenu = false"
-		></div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'tray' : ''">
-		<div v-if="showMenu" class="menu">
-			<MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA>
-			<MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA>
-			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
-			<MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA>
-			<div class="action">
-				<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
-				<button class="_button" @click="signin()">{{ $ts.login }}</button>
-			</div>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, onMounted, provide } from 'vue';
-import XHeader from './header.vue';
-import XKanban from './kanban.vue';
-import { host, instanceName } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import MkPagination from '@/components/MkPagination.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import { ColdDeviceStorage, defaultStore } from '@/store';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-
-const DESKTOP_THRESHOLD = 1100;
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-	if (pageMetadata.value) {
-		document.title = `${pageMetadata.value.title} | ${instanceName}`;
-	}
-});
-
-const announcements = {
-	endpoint: 'announcements',
-	limit: 10,
-};
-let showMenu = $ref(false);
-let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
-let narrow = $ref(window.innerWidth < 1280);
-let meta = $ref();
-
-const keymap = $computed(() => {
-	return {
-		'd': () => {
-			if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
-			defaultStore.set('darkMode', !defaultStore.state.darkMode);
-		},
-		's': search,
-	};
-});
-
-const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
-
-os.api('meta', { detail: true }).then(res => {
-	meta = res;
-});
-
-function signin() {
-	os.popup(XSigninDialog, {
-		autoSet: true,
-	}, {}, 'closed');
-}
-
-function signup() {
-	os.popup(XSignupDialog, {
-		autoSet: true,
-	}, {}, 'closed');
-}
-
-onMounted(() => {
-	if (!isDesktop) {
-		window.addEventListener('resize', () => {
-			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
-		}, { passive: true });
-	}
-});
-
-defineExpose({
-	showMenu: $$(showMenu),
-});
-</script>
-
-<style>
-.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
-</style>
-
-<style lang="scss" scoped>
-.tray-enter-active,
-.tray-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-enter-from,
-.tray-leave-active {
-	opacity: 0;
-	transform: translateX(-240px);
-}
-
-.tray-back-enter-active,
-.tray-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-back-enter-from,
-.tray-back-leave-active {
-	opacity: 0;
-}
-
-.mk-app {
-	display: flex;
-	min-height: 100vh;
-	background-position: center;
-	background-size: cover;
-	background-attachment: fixed;
-
-	> .side {
-		width: 500px;
-		height: 100vh;
-
-		> .kanban {
-			position: fixed;
-			top: 0;
-			left: 0;
-			width: 500px;
-			height: 100vh;
-			overflow: auto;
-		}
-	}
-
-	> .main {
-		flex: 1;
-		min-width: 0;
-
-		> .banner {
-		}
-
-		> .contents {
-			position: relative;
-			z-index: 1;
-
-			> .powered-by {
-				padding: 28px;
-				font-size: 14px;
-				text-align: center;
-				border-top: 1px solid var(--divider);
-
-				> small {
-					display: block;
-					margin-top: 8px;
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-
-	> .menu-back {
-		position: fixed;
-		z-index: 1001;
-		top: 0;
-		left: 0;
-		width: 100vw;
-		height: 100vh;
-	}
-
-	> .menu {
-		position: fixed;
-		z-index: 1001;
-		top: 0;
-		left: 0;
-		width: 240px;
-		height: 100vh;
-		background: var(--panel);
-
-		> .link {
-			display: block;
-			padding: 16px;
-
-			> .icon {
-				margin-right: 1em;
-			}
-		}
-
-		> .action {
-			padding: 16px;
-
-			> button {
-				display: block;
-				width: 100%;
-				padding: 10px;
-				box-sizing: border-box;
-				text-align: center;
-				border-radius: 999px;
-
-				&._button {
-					background: var(--panel);
-				}
-
-				&:first-child {
-					margin-bottom: 16px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/visitor/header.vue b/packages/client/src/ui/visitor/header.vue
deleted file mode 100644
index 7300b12a75..0000000000
--- a/packages/client/src/ui/visitor/header.vue
+++ /dev/null
@@ -1,228 +0,0 @@
-<template>
-<div class="sqxihjet">
-	<div v-if="narrow === false" class="wide">
-		<div class="content">
-			<MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA>
-			<MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA>
-			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
-			<MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA>
-			<div v-if="info" class="page active link">
-				<div class="title">
-					<i v-if="info.icon" class="icon" :class="info.icon"></i>
-					<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
-					<span v-if="info.title" class="text">{{ info.title }}</span>
-					<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
-				</div>
-				<button v-if="info.action" class="_button action" @click.stop="info.action.handler"><!-- TODO --></button>
-			</div>
-			<div class="right">
-				<button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ $ts.search }}</span></button>
-				<button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button>
-				<button class="_button login" @click="signin()">{{ $ts.login }}</button>
-			</div>
-		</div>
-	</div>
-	<div v-else-if="narrow === true" class="narrow">
-		<button class="menu _button" @click="$parent.showMenu = true">
-			<i class="ti ti-menu-2 icon"></i>
-		</button>
-		<div v-if="info" class="title">
-			<i v-if="info.icon" class="icon" :class="info.icon"></i>
-			<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
-			<span v-if="info.title" class="text">{{ info.title }}</span>
-			<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
-		</div>
-		<button v-if="info && info.action" class="action _button" @click.stop="info.action.handler">
-			<!-- TODO -->
-		</button>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import * as os from '@/os';
-import { search } from '@/scripts/search';
-
-export default defineComponent({
-	props: {
-		info: {
-			required: true,
-		},
-	},
-
-	data() {
-		return {
-			narrow: null,
-			showMenu: false,
-		};
-	},
-
-	mounted() {
-		this.narrow = this.$el.clientWidth < 1300;
-	},
-
-	methods: {
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		search,
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.sqxihjet {
-	$height: 60px;
-	position: sticky;
-	top: 0;
-	left: 0;
-	z-index: 1000;
-	line-height: $height;
-	-webkit-backdrop-filter: var(--blur, blur(32px));
-	backdrop-filter: var(--blur, blur(32px));
-	background-color: var(--X16);
-
-	> .wide {
-		> .content {
-			max-width: 1400px;
-			margin: 0 auto;
-			display: flex;
-			align-items: center;
-
-			> .link {
-				$line: 3px;
-				display: inline-block;
-				padding: 0 16px;
-				line-height: $height - ($line * 2);
-				border-top: solid $line transparent;
-				border-bottom: solid $line transparent;
-
-				> .icon {
-					margin-right: 0.5em;
-				}
-
-				&.page {
-					border-bottom-color: var(--accent);
-				}
-			}
-
-			> .page {
-				> .title {
-					display: inline-block;
-					vertical-align: bottom;
-					white-space: nowrap;
-					overflow: hidden;
-					text-overflow: ellipsis;
-					position: relative;
-
-					> .icon + .text {
-						margin-left: 8px;
-					}
-
-					> .avatar {
-						$size: 32px;
-						display: inline-block;
-						width: $size;
-						height: $size;
-						vertical-align: middle;
-						margin-right: 8px;
-						pointer-events: none;
-					}
-
-					&._button {
-						&:hover {
-							color: var(--fgHighlighted);
-						}
-					}
-
-					&.selected {
-						box-shadow: 0 -2px 0 0 var(--accent) inset;
-						color: var(--fgHighlighted);
-					}
-				}
-
-				> .action {
-					padding: 0 0 0 16px;
-				}
-			}
-
-			> .right {
-				margin-left: auto;
-
-				> .search {
-					background: var(--bg);
-					border-radius: 999px;
-					width: 230px;
-					line-height: $height - 20px;
-					margin-right: 16px;
-					text-align: left;
-
-					> * {
-						opacity: 0.7;
-					}
-
-					> .icon {
-						padding: 0 16px;
-					}
-				}
-
-				> .signup {
-					border-radius: 999px;
-					padding: 0 24px;
-					line-height: $height - 20px;
-				}
-
-				> .login {
-					padding: 0 16px;
-				}
-			}
-		}
-	}
-
-	> .narrow {
-		display: flex;
-
-		> .menu,
-		> .action {
-			width: $height;
-			height: $height;
-			font-size: 20px;
-		}
-
-		> .title {
-			flex: 1;
-			white-space: nowrap;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			position: relative;
-			text-align: center;
-
-			> .icon + .text {
-				margin-left: 8px;
-			}
-
-			> .avatar {
-				$size: 32px;
-				display: inline-block;
-				width: $size;
-				height: $size;
-				vertical-align: middle;
-				margin-right: 8px;
-				pointer-events: none;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/visitor/kanban.vue b/packages/client/src/ui/visitor/kanban.vue
deleted file mode 100644
index 51e47f277d..0000000000
--- a/packages/client/src/ui/visitor/kanban.vue
+++ /dev/null
@@ -1,257 +0,0 @@
-<!-- eslint-disable vue/no-v-html -->
-<template>
-<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }">
-	<div class="back" :class="{ transparent }"></div>
-	<div class="contents">
-		<div class="wrapper">
-			<h1 v-if="meta" :class="{ full }">
-				<MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA>
-			</h1>
-			<template v-if="full">
-				<div v-if="meta" class="about">
-					<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
-				</div>
-				<div class="action">
-					<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
-					<button class="_button" @click="signin()">{{ $ts.login }}</button>
-				</div>
-				<div class="announcements panel">
-					<header>{{ $ts.announcements }}</header>
-					<MkPagination v-slot="{items}" :pagination="announcements" class="list">
-						<section v-for="announcement in items" :key="announcement.id" class="item">
-							<div class="title">{{ announcement.title }}</div>
-							<div class="content">
-								<Mfm :text="announcement.text"/>
-								<img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/>
-							</div>
-						</section>
-					</MkPagination>
-				</div>
-				<div v-if="poweredBy" class="powered-by">
-					<b><MkA to="/">{{ host }}</MkA></b>
-					<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
-				</div>
-			</template>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import MkPagination from '@/components/MkPagination.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-
-export default defineComponent({
-	components: {
-		MkPagination,
-		MkButton,
-	},
-
-	props: {
-		full: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		transparent: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-		poweredBy: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-
-	data() {
-		return {
-			host,
-			instanceName,
-			pageInfo: null,
-			meta: null,
-			narrow: window.innerWidth < 1280,
-			announcements: {
-				endpoint: 'announcements',
-				limit: 10,
-			},
-		};
-	},
-
-	created() {
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-	},
-
-	methods: {
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true,
-			}, {}, 'closed');
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.rwqkcmrc {
-	position: relative;
-	text-align: center;
-	background-position: center;
-	background-size: cover;
-	// TODO: パララックスにしたい
-
-	> .back {
-		position: absolute;
-		top: 0;
-		left: 0;
-		width: 100%;
-		height: 100%;
-		background: rgba(0, 0, 0, 0.3);
-
-		&.transparent {
-			-webkit-backdrop-filter: var(--blur, blur(12px));
-			backdrop-filter: var(--blur, blur(12px));
-		}
-	}
-
-	> .contents {
-		position: relative;
-		z-index: 1;
-		height: inherit;
-		overflow: auto;
-
-		> .wrapper {
-			max-width: 380px;
-			padding: 0 16px;
-			box-sizing: border-box;
-			margin: 0 auto;
-
-			> .panel {
-				-webkit-backdrop-filter: var(--blur, blur(8px));
-				backdrop-filter: var(--blur, blur(8px));
-				background: rgba(0, 0, 0, 0.5);
-				border-radius: var(--radius);
-
-				&, * {
-					color: #fff !important;
-				}
-			}
-
-			> h1 {
-				display: block;
-				margin: 0;
-				padding: 32px 0 32px 0;
-				color: #fff;
-
-				&.full {
-					padding: 64px 0 0 0;
-
-					> .link {
-						> ::v-deep(.logo) {
-							max-height: 130px;
-						}
-					}
-				}
-
-				> .link {
-					display: block;
-
-					> ::v-deep(.logo) {
-						vertical-align: bottom;
-						max-height: 100px;
-					}
-				}
-			}
-
-			> .about {
-				display: block;
-				margin: 24px 0;
-				text-align: center;
-				box-sizing: border-box;
-				text-shadow: 0 0 8px black;
-				color: #fff;
-			}
-
-			> .action {
-				> button {
-					display: block;
-					width: 100%;
-					padding: 10px;
-					box-sizing: border-box;
-					text-align: center;
-					border-radius: 999px;
-
-					&._button {
-						background: var(--panel);
-					}
-
-					&:first-child {
-						margin-bottom: 16px;
-					}
-				}
-			}
-
-			> .announcements {
-				margin: 32px 0;
-				text-align: left;
-
-				> header {
-					padding: 12px 16px;
-					border-bottom: solid 1px rgba(255, 255, 255, 0.5);
-				}
-
-				> .list {
-					max-height: 300px;
-					overflow: auto;
-
-					> .item {
-						padding: 12px 16px;
-
-						& + .item {
-							border-top: solid 1px rgba(255, 255, 255, 0.5);
-						}
-
-						> .title {
-							font-weight: bold;
-						}
-
-						> .content {
-							> img {
-								max-width: 100%;
-							}
-						}
-					}
-				}
-			}
-
-			> .powered-by {
-				padding: 28px;
-				font-size: 14px;
-				text-align: center;
-				border-top: 1px solid rgba(255, 255, 255, 0.5);
-				color: #fff;
-
-				> small {
-					display: block;
-					margin-top: 8px;
-					opacity: 0.5;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue
deleted file mode 100644
index 84c96a1dae..0000000000
--- a/packages/client/src/ui/zen.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<template>
-<div class="mk-app">
-	<RouterView/>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { provide, ComputedRef } from 'vue';
-import XCommon from './_common_/common.vue';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
-import { instanceName } from '@/config';
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
-	pageMetadata = info;
-	if (pageMetadata.value) {
-		document.title = `${pageMetadata.value.title} | ${instanceName}`;
-	}
-});
-
-document.documentElement.style.overflowY = 'scroll';
-</script>
-
-<style lang="scss" scoped>
-.mk-app {
-	min-height: 100dvh;
-	box-sizing: border-box;
-}
-</style>
diff --git a/packages/client/src/widgets/activity.calendar.vue b/packages/client/src/widgets/activity.calendar.vue
deleted file mode 100644
index 84f6af1c13..0000000000
--- a/packages/client/src/widgets/activity.calendar.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<template>
-<svg viewBox="0 0 21 7">
-	<rect v-for="record in activity" class="day"
-		width="1" height="1"
-		:x="record.x" :y="record.date.weekday"
-		rx="1" ry="1"
-		fill="transparent">
-		<title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
-	</rect>
-	<rect v-for="record in activity" class="day"
-		:width="record.v" :height="record.v"
-		:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
-		rx="1" ry="1"
-		:fill="record.color"
-		style="pointer-events: none;"/>
-	<rect class="today"
-		width="1" height="1"
-		:x="activity[0].x" :y="activity[0].date.weekday"
-		rx="1" ry="1"
-		fill="none"
-		stroke-width="0.1"
-		stroke="#f73520"/>
-</svg>
-</template>
-
-<script lang="ts" setup>
-const props = defineProps<{
-	activity: any[]
-}>();
-
-for (const d of props.activity) {
-	d.total = d.notes + d.replies + d.renotes;
-}
-const peak = Math.max(...props.activity.map(d => d.total));
-
-const now = new Date();
-const year = now.getFullYear();
-const month = now.getMonth();
-const day = now.getDate();
-
-let x = 20;
-props.activity.slice().forEach((d, i) => {
-	d.x = x;
-
-	const date = new Date(year, month, day - i);
-	d.date = {
-		year: date.getFullYear(),
-		month: date.getMonth(),
-		day: date.getDate(),
-		weekday: date.getDay(),
-	};
-
-	d.v = peak === 0 ? 0 : d.total / (peak / 2);
-	if (d.v > 1) d.v = 1;
-	const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170;
-	const cs = d.v * 100;
-	const cl = 15 + ((1 - d.v) * 80);
-	d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
-
-	if (d.date.weekday === 0) x--;
-});
-</script>
-
-<style lang="scss" scoped>
-svg {
-	display: block;
-	padding: 16px;
-	width: 100%;
-	box-sizing: border-box;
-
-	> rect {
-		transform-origin: center;
-
-		&.day {
-			&:hover {
-				fill: rgba(#000, 0.05);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/activity.chart.vue b/packages/client/src/widgets/activity.chart.vue
deleted file mode 100644
index b61e419f94..0000000000
--- a/packages/client/src/widgets/activity.chart.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<template>
-<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
-	<polyline
-		:points="pointsNote"
-		fill="none"
-		stroke-width="1"
-		stroke="#41ddde"/>
-	<polyline
-		:points="pointsReply"
-		fill="none"
-		stroke-width="1"
-		stroke="#f7796c"/>
-	<polyline
-		:points="pointsRenote"
-		fill="none"
-		stroke-width="1"
-		stroke="#a1de41"/>
-	<polyline
-		:points="pointsTotal"
-		fill="none"
-		stroke-width="1"
-		stroke="#555"
-		stroke-dasharray="2 2"/>
-</svg>
-</template>
-
-<script lang="ts" setup>
-const props = defineProps<{
-	activity: any[]
-}>();
-
-let viewBoxX: number = $ref(147);
-let viewBoxY: number = $ref(60);
-let zoom: number = $ref(1);
-let pos: number = $ref(0);
-let pointsNote: any = $ref(null);
-let pointsReply: any = $ref(null);
-let pointsRenote: any = $ref(null);
-let pointsTotal: any = $ref(null);
-
-function dragListen(fn) {
-	window.addEventListener('mousemove', fn);
-	window.addEventListener('mouseleave', dragClear.bind(null, fn));
-	window.addEventListener('mouseup', dragClear.bind(null, fn));
-}
-
-function dragClear(fn) {
-	window.removeEventListener('mousemove', fn);
-	window.removeEventListener('mouseleave', dragClear);
-	window.removeEventListener('mouseup', dragClear);
-}
-
-function onMousedown(ev) {
-	const clickX = ev.clientX;
-	const clickY = ev.clientY;
-	const baseZoom = zoom;
-	const basePos = pos;
-
-	// 動かした時
-	dragListen(me => {
-		let moveLeft = me.clientX - clickX;
-		let moveTop = me.clientY - clickY;
-
-		zoom = Math.max(1, baseZoom + (-moveTop / 20));
-		pos = Math.min(0, basePos + moveLeft);
-		if (pos < -(((props.activity.length - 1) * zoom) - viewBoxX)) pos = -(((props.activity.length - 1) * zoom) - viewBoxX);
-
-		render();
-	});
-}
-
-function render() {
-	const peak = Math.max(...props.activity.map(d => d.total));
-	if (peak !== 0) {
-		const activity = props.activity.slice().reverse();
-		pointsNote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.notes / peak)) * viewBoxY}`).join(' ');
-		pointsReply = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.replies / peak)) * viewBoxY}`).join(' ');
-		pointsRenote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.renotes / peak)) * viewBoxY}`).join(' ');
-		pointsTotal = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.total / peak)) * viewBoxY}`).join(' ');
-	}
-}
-</script>
-
-<style lang="scss" scoped>
-svg {
-	display: block;
-	padding: 16px;
-	width: 100%;
-	box-sizing: border-box;
-	cursor: all-scroll;
-}
-</style>
diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue
deleted file mode 100644
index 238a05ca09..0000000000
--- a/packages/client/src/widgets/activity.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity">
-	<template #header><i class="ti ti-chart-line"></i>{{ i18n.ts._widgets.activity }}</template>
-	<template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
-
-	<div>
-		<MkLoading v-if="fetching"/>
-		<template v-else>
-			<XCalendar v-show="widgetProps.view === 0" :activity="[].concat(activity)"/>
-			<XChart v-show="widgetProps.view === 1" :activity="[].concat(activity)"/>
-		</template>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import XCalendar from './activity.calendar.vue';
-import XChart from './activity.chart.vue';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-
-const name = 'activity';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	view: {
-		type: 'number' as const,
-		default: 0,
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const activity = ref(null);
-const fetching = ref(true);
-
-const toggleView = () => {
-	if (widgetProps.view === 1) {
-		widgetProps.view = 0;
-	} else {
-		widgetProps.view++;
-	}
-	save();
-};
-
-os.apiGet('charts/user/notes', {
-	userId: $i.id,
-	span: 'day',
-	limit: 7 * 21,
-}).then(res => {
-	activity.value = res.diffs.normal.map((_, i) => ({
-		total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i],
-		notes: res.diffs.normal[i],
-		replies: res.diffs.reply[i],
-		renotes: res.diffs.renote[i],
-	}));
-	fetching.value = false;
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue
deleted file mode 100644
index 828490fd9c..0000000000
--- a/packages/client/src/widgets/aichan.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-aichan">
-	<iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-
-const name = 'ai';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const live2d = ref<HTMLIFrameElement>();
-
-const touched = () => {
-	//if (this.live2d) this.live2d.changeExpression('gurugurume');
-};
-
-const onMousemove = (ev: MouseEvent) => {
-	const iframeRect = live2d.value.getBoundingClientRect();
-	live2d.value.contentWindow.postMessage({
-		type: 'moveCursor',
-		body: {
-			x: ev.clientX - iframeRect.left,
-			y: ev.clientY - iframeRect.top,
-		},
-	}, '*');
-};
-
-onMounted(() => {
-	window.addEventListener('mousemove', onMousemove, { passive: true });
-});
-
-onUnmounted(() => {
-	window.removeEventListener('mousemove', onMousemove);
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.dedjhjmo {
-	width: 100%;
-	height: 350px;
-	border: none;
-	pointer-events: none;
-}
-</style>
diff --git a/packages/client/src/widgets/aiscript.vue b/packages/client/src/widgets/aiscript.vue
deleted file mode 100644
index 4009edb8b8..0000000000
--- a/packages/client/src/widgets/aiscript.vue
+++ /dev/null
@@ -1,175 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript">
-	<template #header><i class="ti ti-terminal-2"></i>{{ i18n.ts._widgets.aiscript }}</template>
-
-	<div class="uylguesu _monospace">
-		<textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea>
-		<button class="_buttonPrimary" @click="run">RUN</button>
-		<div class="logs">
-			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
-		</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { AiScript, parse, utils } from '@syuilo/aiscript';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { createAiScriptEnv } from '@/scripts/aiscript/api';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-
-const name = 'aiscript';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	script: {
-		type: 'string' as const,
-		multiline: true,
-		default: '(1 + 1)',
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const logs = ref<{
-	id: string;
-	text: string;
-	print: boolean;
-}[]>([]);
-
-const run = async () => {
-	logs.value = [];
-	const aiscript = new AiScript(createAiScriptEnv({
-		storageKey: 'widget',
-		token: $i?.token,
-	}), {
-		in: (q) => {
-			return new Promise(ok => {
-				os.inputText({
-					title: q,
-				}).then(({ canceled, result: a }) => {
-					ok(a);
-				});
-			});
-		},
-		out: (value) => {
-			logs.value.push({
-				id: Math.random().toString(),
-				text: value.type === 'str' ? value.value : utils.valToString(value),
-				print: true,
-			});
-		},
-		log: (type, params) => {
-			switch (type) {
-				case 'end': logs.value.push({
-					id: Math.random().toString(),
-					text: utils.valToString(params.val, true),
-					print: false,
-				}); break;
-				default: break;
-			}
-		},
-	});
-
-	let ast;
-	try {
-		ast = parse(widgetProps.script);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: 'Syntax error :(',
-		});
-		return;
-	}
-	try {
-		await aiscript.exec(ast);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: err,
-		});
-	}
-};
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.uylguesu {
-	text-align: right;
-
-	> textarea {
-		display: block;
-		width: 100%;
-		max-width: 100%;
-		min-width: 100%;
-		padding: 16px;
-		color: var(--fg);
-		background: transparent;
-		border: none;
-		border-bottom: solid 0.5px var(--divider);
-		border-radius: 0;
-		box-sizing: border-box;
-		font: inherit;
-
-		&:focus-visible {
-			outline: none;
-		}
-	}
-
-	> button {
-		display: inline-block;
-		margin: 8px;
-		padding: 0 10px;
-		height: 28px;
-		outline: none;
-		border-radius: 4px;
-
-		&:disabled {
-			opacity: 0.7;
-			cursor: default;
-		}
-	}
-
-	> .logs {
-		border-top: solid 0.5px var(--divider);
-		text-align: left;
-		padding: 16px;
-
-		&:empty {
-			display: none;
-		}
-
-		> .log {
-			&:not(.print) {
-				opacity: 0.7;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/button.vue b/packages/client/src/widgets/button.vue
deleted file mode 100644
index f0148d7f4e..0000000000
--- a/packages/client/src/widgets/button.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<template>
-<div class="mkw-button">
-	<MkButton :primary="widgetProps.colored" full @click="run">
-		{{ widgetProps.label }}
-	</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { AiScript, parse, utils } from '@syuilo/aiscript';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import { createAiScriptEnv } from '@/scripts/aiscript/api';
-import { $i } from '@/account';
-import MkButton from '@/components/MkButton.vue';
-
-const name = 'button';
-
-const widgetPropsDef = {
-	label: {
-		type: 'string' as const,
-		default: 'BUTTON',
-	},
-	colored: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	script: {
-		type: 'string' as const,
-		multiline: true,
-		default: 'Mk:dialog("hello" "world")',
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const run = async () => {
-	const aiscript = new AiScript(createAiScriptEnv({
-		storageKey: 'widget',
-		token: $i?.token,
-	}), {
-		in: (q) => {
-			return new Promise(ok => {
-				os.inputText({
-					title: q,
-				}).then(({ canceled, result: a }) => {
-					ok(a);
-				});
-			});
-		},
-		out: (value) => {
-			// nop
-		},
-		log: (type, params) => {
-			// nop
-		},
-	});
-
-	let ast;
-	try {
-		ast = parse(widgetProps.script);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: 'Syntax error :(',
-		});
-		return;
-	}
-	try {
-		await aiscript.exec(ast);
-	} catch (err) {
-		os.alert({
-			type: 'error',
-			text: err,
-		});
-	}
-};
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.mkw-button {
-}
-</style>
diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue
deleted file mode 100644
index 99bd36e2fc..0000000000
--- a/packages/client/src/widgets/calendar.vue
+++ /dev/null
@@ -1,213 +0,0 @@
-<template>
-<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }">
-	<div class="calendar" :class="{ isHoliday }">
-		<p class="month-and-year">
-			<span class="year">{{ $t('yearX', { year }) }}</span>
-			<span class="month">{{ $t('monthX', { month }) }}</span>
-		</p>
-		<p v-if="month === 1 && day === 1" class="day">🎉{{ $t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p>
-		<p v-else class="day">{{ $t('dayX', { day }) }}</p>
-		<p class="week-day">{{ weekDay }}</p>
-	</div>
-	<div class="info">
-		<div>
-			<p>{{ i18n.ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p>
-			<div class="meter">
-				<div class="val" :style="{ width: `${dayP}%` }"></div>
-			</div>
-		</div>
-		<div>
-			<p>{{ i18n.ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p>
-			<div class="meter">
-				<div class="val" :style="{ width: `${monthP}%` }"></div>
-			</div>
-		</div>
-		<div>
-			<p>{{ i18n.ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p>
-			<div class="meter">
-				<div class="val" :style="{ width: `${yearP}%` }"></div>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import { i18n } from '@/i18n';
-import { useInterval } from '@/scripts/use-interval';
-
-const name = 'calendar';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const year = ref(0);
-const month = ref(0);
-const day = ref(0);
-const weekDay = ref('');
-const yearP = ref(0);
-const monthP = ref(0);
-const dayP = ref(0);
-const isHoliday = ref(false);
-const tick = () => {
-	const now = new Date();
-	const nd = now.getDate();
-	const nm = now.getMonth();
-	const ny = now.getFullYear();
-
-	year.value = ny;
-	month.value = nm + 1;
-	day.value = nd;
-	weekDay.value = [
-		i18n.ts._weekday.sunday,
-		i18n.ts._weekday.monday,
-		i18n.ts._weekday.tuesday,
-		i18n.ts._weekday.wednesday,
-		i18n.ts._weekday.thursday,
-		i18n.ts._weekday.friday,
-		i18n.ts._weekday.saturday,
-	][now.getDay()];
-
-	const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
-	const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
-	const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
-	const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
-	const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
-	const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
-
-	dayP.value = dayNumer / dayDenom * 100;
-	monthP.value = monthNumer / monthDenom * 100;
-	yearP.value = yearNumer / yearDenom * 100;
-
-	isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
-};
-
-useInterval(tick, 1000, {
-	immediate: true,
-	afterMounted: false,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.mkw-calendar {
-	padding: 16px 0;
-
-	&:after {
-		content: "";
-		display: block;
-		clear: both;
-	}
-
-	> .calendar {
-		float: left;
-		width: 60%;
-		text-align: center;
-
-		&.isHoliday {
-			> .day {
-				color: #ef95a0;
-			}
-		}
-
-		> .month-and-year, > .week-day {
-			margin: 0;
-			line-height: 18px;
-			font-size: 0.9em;
-
-			> .year, > .month {
-				margin: 0 4px;
-			}
-		}
-
-		> .day {
-			margin: 10px 0;
-			line-height: 32px;
-			font-size: 1.75em;
-		}
-	}
-
-	> .info {
-		display: block;
-		float: left;
-		width: 40%;
-		padding: 0 16px 0 0;
-		box-sizing: border-box;
-
-		> div {
-			margin-bottom: 8px;
-
-			&:last-child {
-				margin-bottom: 4px;
-			}
-
-			> p {
-				margin: 0 0 2px 0;
-				font-size: 0.75em;
-				line-height: 18px;
-				opacity: 0.8;
-
-				> b {
-					margin-left: 2px;
-				}
-			}
-
-			> .meter {
-				width: 100%;
-				overflow: hidden;
-				background: var(--X11);
-				border-radius: 8px;
-
-				> .val {
-					height: 4px;
-					transition: width .3s cubic-bezier(0.23, 1, 0.32, 1);
-				}
-			}
-
-			&:nth-child(1) {
-				> .meter > .val {
-					background: #f7796c;
-				}
-			}
-
-			&:nth-child(2) {
-				> .meter > .val {
-					background: #a1de41;
-				}
-			}
-
-			&:nth-child(3) {
-				> .meter > .val {
-					background: #41ddde;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue
deleted file mode 100644
index dc99b6631e..0000000000
--- a/packages/client/src/widgets/clock.vue
+++ /dev/null
@@ -1,203 +0,0 @@
-<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock">
-	<div class="vubelbmv" :class="widgetProps.size">
-		<div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div>
-		<MkAnalogClock
-			class="clock"
-			:thickness="widgetProps.thickness"
-			:offset="tzOffset"
-			:graduations="widgetProps.graduations"
-			:fade-graduations="widgetProps.fadeGraduations"
-			:twentyfour="widgetProps.twentyFour"
-			:s-animation="widgetProps.sAnimation"
-		/>
-		<MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/>
-		<div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import MkAnalogClock from '@/components/MkAnalogClock.vue';
-import MkDigitalClock from '@/components/MkDigitalClock.vue';
-import { timezones } from '@/scripts/timezones';
-import { i18n } from '@/i18n';
-
-const name = 'clock';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	size: {
-		type: 'radio' as const,
-		default: 'medium',
-		options: [{
-			value: 'small', label: i18n.ts.small,
-		}, {
-			value: 'medium', label: i18n.ts.medium,
-		}, {
-			value: 'large', label: i18n.ts.large,
-		}],
-	},
-	thickness: {
-		type: 'radio' as const,
-		default: 0.2,
-		options: [{
-			value: 0.1, label: 'thin',
-		}, {
-			value: 0.2, label: 'medium',
-		}, {
-			value: 0.3, label: 'thick',
-		}],
-	},
-	graduations: {
-		type: 'radio' as const,
-		default: 'numbers',
-		options: [{
-			value: 'none', label: 'None',
-		}, {
-			value: 'dots', label: 'Dots',
-		}, {
-			value: 'numbers', label: 'Numbers',
-		}],
-	},
-	fadeGraduations: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	sAnimation: {
-		type: 'radio' as const,
-		default: 'elastic',
-		options: [{
-			value: 'none', label: 'None',
-		}, {
-			value: 'elastic', label: 'Elastic',
-		}, {
-			value: 'easeOut', label: 'Ease out',
-		}],
-	},
-	twentyFour: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	label: {
-		type: 'radio' as const,
-		default: 'none',
-		options: [{
-			value: 'none', label: 'None',
-		}, {
-			value: 'time', label: 'Time',
-		}, {
-			value: 'tz', label: 'TZ',
-		}, {
-			value: 'timeAndTz', label: 'Time + TZ',
-		}],
-	},
-	timezone: {
-		type: 'enum' as const,
-		default: null,
-		enum: [...timezones.map((tz) => ({
-			label: tz.name,
-			value: tz.name.toLowerCase(),
-		})), {
-			label: '(auto)',
-			value: null,
-		}],
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const tzAbbrev = $computed(() => (widgetProps.timezone === null
-	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
-	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
-
-const tzOffset = $computed(() => widgetProps.timezone === null
-	? 0 - new Date().getTimezoneOffset()
-	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
-
-const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.vubelbmv {
-	position: relative;
-
-	> .label {
-		position: absolute;
-		opacity: 0.7;
-
-		&.a {
-			top: 14px;
-			left: 14px;
-		}
-
-		&.b {
-			top: 14px;
-			right: 14px;
-		}
-
-		&.c {
-			bottom: 14px;
-			left: 14px;
-		}
-
-		&.d {
-			bottom: 14px;
-			right: 14px;
-		}
-	}
-
-	> .clock {
-		margin: auto;
-	}
-
-	&.small {
-		padding: 12px;
-
-		> .clock {
-			height: 100px;
-		}
-	}
-
-	&.medium {
-		padding: 14px;
-
-		> .clock {
-			height: 150px;
-		}
-	}
-
-	&.large {
-		padding: 16px;
-
-		> .clock {
-			height: 200px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue
deleted file mode 100644
index d2bfd523f3..0000000000
--- a/packages/client/src/widgets/digital-clock.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<template>
-<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
-	<div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div>
-	<div class="time">
-		<MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/>
-	</div>
-	<div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import { timezones } from '@/scripts/timezones';
-import MkDigitalClock from '@/components/MkDigitalClock.vue';
-
-const name = 'digitalClock';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	fontSize: {
-		type: 'number' as const,
-		default: 1.5,
-		step: 0.1,
-	},
-	showMs: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	showLabel: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	timezone: {
-		type: 'enum' as const,
-		default: null,
-		enum: [...timezones.map((tz) => ({
-			label: tz.name,
-			value: tz.name.toLowerCase(),
-		})), {
-			label: '(auto)',
-			value: null,
-		}],
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const tzAbbrev = $computed(() => (widgetProps.timezone === null
-	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
-	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
-
-const tzOffset = $computed(() => widgetProps.timezone === null
-	? 0 - new Date().getTimezoneOffset()
-	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
-
-const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.mkw-digitalClock {
-	padding: 16px 0;
-	text-align: center;
-
-	> .label {
-		font-size: 65%;
-		opacity: 0.7;
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue
deleted file mode 100644
index 3374783b0c..0000000000
--- a/packages/client/src/widgets/federation.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation">
-	<template #header><i class="ti ti-whirl"></i>{{ i18n.ts._widgets.federation }}</template>
-
-	<div class="wbrkwalb">
-		<MkLoading v-if="fetching"/>
-		<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
-			<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
-				<img :src="getInstanceIcon(instance)" alt=""/>
-				<div class="body">
-					<a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
-					<p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
-				</div>
-				<MkMiniChart class="chart" :src="charts[i].requests.received"/>
-			</div>
-		</transition-group>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const name = 'federation';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const instances = ref([]);
-const charts = ref([]);
-const fetching = ref(true);
-
-const fetch = async () => {
-	const fetchedInstances = await os.api('federation/instances', {
-		sort: '+lastCommunicatedAt',
-		limit: 5,
-	});
-	const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
-	instances.value = fetchedInstances;
-	charts.value = fetchedCharts;
-	fetching.value = false;
-};
-
-useInterval(fetch, 1000 * 60, {
-	immediate: true,
-	afterMounted: true,
-});
-
-function getInstanceIcon(instance): string {
-	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
-}
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.wbrkwalb {
-	$bodyTitleHieght: 18px;
-	$bodyInfoHieght: 16px;
-
-	height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
-	overflow: hidden;
-
-	> .instances {
-		.chart-move {
-			transition: transform 1s ease;
-		}
-
-		> .instance {
-			display: flex;
-			align-items: center;
-			padding: 14px 16px;
-			border-bottom: solid 0.5px var(--divider);
-
-			> img {
-				display: block;
-				width: ($bodyTitleHieght + $bodyInfoHieght);
-				height: ($bodyTitleHieght + $bodyInfoHieght);
-				object-fit: cover;
-				border-radius: 4px;
-				margin-right: 8px;
-			}
-
-			> .body {
-				flex: 1;
-				overflow: hidden;
-				font-size: 0.9em;
-				color: var(--fg);
-				padding-right: 8px;
-
-				> .a {
-					display: block;
-					width: 100%;
-					white-space: nowrap;
-					overflow: hidden;
-					text-overflow: ellipsis;
-					line-height: $bodyTitleHieght;
-				}
-
-				> p {
-					margin: 0;
-					font-size: 75%;
-					opacity: 0.7;
-					line-height: $bodyInfoHieght;
-					white-space: nowrap;
-					overflow: hidden;
-					text-overflow: ellipsis;
-				}
-			}
-
-			> .chart {
-				height: 30px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/index.ts b/packages/client/src/widgets/index.ts
deleted file mode 100644
index 39826f13c8..0000000000
--- a/packages/client/src/widgets/index.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { App, defineAsyncComponent } from 'vue';
-
-export default function(app: App) {
-	app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue')));
-	app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue')));
-	app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue')));
-	app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue')));
-	app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue')));
-	app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue')));
-	app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue')));
-	app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue')));
-	app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue')));
-	app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue')));
-	app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue')));
-	app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue')));
-	app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue')));
-	app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue')));
-	app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue')));
-	app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue')));
-	app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue')));
-	app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue')));
-	app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
-	app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
-	app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
-	app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
-	app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
-}
-
-export const widgets = [
-	'memo',
-	'notifications',
-	'timeline',
-	'calendar',
-	'rss',
-	'rssTicker',
-	'trends',
-	'clock',
-	'activity',
-	'photos',
-	'digitalClock',
-	'unixClock',
-	'federation',
-	'instanceCloud',
-	'postForm',
-	'slideshow',
-	'serverMetric',
-	'onlineUsers',
-	'jobQueue',
-	'button',
-	'aiscript',
-	'aichan',
-	'userList',
-];
diff --git a/packages/client/src/widgets/instance-cloud.vue b/packages/client/src/widgets/instance-cloud.vue
deleted file mode 100644
index 4965616995..0000000000
--- a/packages/client/src/widgets/instance-cloud.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud">
-	<div class="">
-		<MkTagCloud v-if="activeInstances">
-			<li v-for="instance in activeInstances" :key="instance.id">
-				<a @click.prevent="onInstanceClick(instance)">
-					<img style="width: 32px;" :src="getInstanceIcon(instance)">
-				</a>
-			</li>
-		</MkTagCloud>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import MkTagCloud from '@/components/MkTagCloud.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
-
-const name = 'instanceCloud';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-let cloud = $ref<InstanceType<typeof MkTagCloud> | null>();
-let activeInstances = $shallowRef(null);
-
-function onInstanceClick(i) {
-	os.pageWindow(`/instance-info/${i.host}`);
-}
-
-useInterval(() => {
-	os.api('federation/instances', {
-		sort: '+lastCommunicatedAt',
-		limit: 25,
-	}).then(res => {
-		activeInstances = res;
-		if (cloud) cloud.update();
-	});
-}, 1000 * 60 * 3, {
-	immediate: true,
-	afterMounted: true,
-});
-
-function getInstanceIcon(instance): string {
-	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
-}
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/client/src/widgets/job-queue.vue b/packages/client/src/widgets/job-queue.vue
deleted file mode 100644
index 9f19c51825..0000000000
--- a/packages/client/src/widgets/job-queue.vue
+++ /dev/null
@@ -1,197 +0,0 @@
-<template>
-<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }">
-	<div class="inbox">
-		<div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
-		<div class="values">
-			<div>
-				<div>Process</div>
-				<div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div>
-			</div>
-			<div>
-				<div>Active</div>
-				<div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div>
-			</div>
-			<div>
-				<div>Delayed</div>
-				<div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div>
-			</div>
-			<div>
-				<div>Waiting</div>
-				<div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div>
-			</div>
-		</div>
-	</div>
-	<div class="deliver">
-		<div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
-		<div class="values">
-			<div>
-				<div>Process</div>
-				<div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div>
-			</div>
-			<div>
-				<div>Active</div>
-				<div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div>
-			</div>
-			<div>
-				<div>Delayed</div>
-				<div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div>
-			</div>
-			<div>
-				<div>Waiting</div>
-				<div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div>
-			</div>
-		</div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import { stream } from '@/stream';
-import number from '@/filters/number';
-import * as sound from '@/scripts/sound';
-import * as os from '@/os';
-import { deepClone } from '@/scripts/clone';
-
-const name = 'jobQueue';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	sound: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const connection = stream.useChannel('queueStats');
-const current = reactive({
-	inbox: {
-		activeSincePrevTick: 0,
-		active: 0,
-		waiting: 0,
-		delayed: 0,
-	},
-	deliver: {
-		activeSincePrevTick: 0,
-		active: 0,
-		waiting: 0,
-		delayed: 0,
-	},
-});
-const prev = reactive({} as typeof current);
-const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
-
-for (const domain of ['inbox', 'deliver']) {
-	prev[domain] = deepClone(current[domain]);
-}
-
-const onStats = (stats) => {
-	for (const domain of ['inbox', 'deliver']) {
-		prev[domain] = deepClone(current[domain]);
-		current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
-		current[domain].active = stats[domain].active;
-		current[domain].waiting = stats[domain].waiting;
-		current[domain].delayed = stats[domain].delayed;
-
-		if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
-			jammedSound.play();
-		}
-	}
-};
-
-const onStatsLog = (statsLog) => {
-	for (const stats of [...statsLog].reverse()) {
-		onStats(stats);
-	}
-};
-
-connection.on('stats', onStats);
-connection.on('statsLog', onStatsLog);
-
-connection.send('requestLog', {
-	id: Math.random().toString().substr(2, 8),
-	length: 1,
-});
-
-onUnmounted(() => {
-	connection.off('stats', onStats);
-	connection.off('statsLog', onStatsLog);
-	connection.dispose();
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-@keyframes warnBlink {
-	0% { opacity: 1; }
-	50% { opacity: 0; }
-}
-
-.mkw-jobQueue {
-	font-size: 0.9em;
-
-	> div {
-		padding: 16px;
-
-		&:not(:first-child) {
-			border-top: solid 0.5px var(--divider);
-		}
-
-		> .label {
-			display: flex;
-
-			> .icon {
-				color: var(--warn);
-				margin-left: auto;
-				animation: warnBlink 1s infinite;
-			}
-		}
-
-		> .values {
-			display: flex;
-
-			> div {
-				flex: 1;
-
-				> div:first-child {
-					opacity: 0.7;
-				}
-
-				> div:last-child {
-					&.inc {
-						color: var(--warn);
-					}
-
-					&.dec {
-						color: var(--success);
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/memo.vue b/packages/client/src/widgets/memo.vue
deleted file mode 100644
index 1cc0e10bba..0000000000
--- a/packages/client/src/widgets/memo.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-memo">
-	<template #header><i class="ti ti-note"></i>{{ i18n.ts._widgets.memo }}</template>
-
-	<div class="otgbylcu">
-		<textarea v-model="text" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea>
-		<button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-
-const name = 'memo';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const text = ref<string | null>(defaultStore.state.memo);
-const changed = ref(false);
-let timeoutId;
-
-const saveMemo = () => {
-	defaultStore.set('memo', text.value);
-	changed.value = false;
-};
-
-const onChange = () => {
-	changed.value = true;
-	window.clearTimeout(timeoutId);
-	timeoutId = window.setTimeout(saveMemo, 1000);
-};
-
-watch(() => defaultStore.reactiveState.memo, newText => {
-	text.value = newText.value;
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.otgbylcu {
-	padding-bottom: 28px + 16px;
-
-	> textarea {
-		display: block;
-		width: 100%;
-		max-width: 100%;
-		min-width: 100%;
-		padding: 16px;
-		color: var(--fg);
-		background: transparent;
-		border: none;
-		border-bottom: solid 0.5px var(--divider);
-		border-radius: 0;
-		box-sizing: border-box;
-		font: inherit;
-		font-size: 0.9em;
-
-		&:focus-visible {
-			outline: none;
-		}
-	}
-
-	> button {
-		display: block;
-		position: absolute;
-		bottom: 8px;
-		right: 8px;
-		margin: 0;
-		padding: 0 10px;
-		height: 28px;
-		outline: none;
-		border-radius: 4px;
-
-		&:disabled {
-			opacity: 0.7;
-			cursor: default;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/notifications.vue b/packages/client/src/widgets/notifications.vue
deleted file mode 100644
index e697209444..0000000000
--- a/packages/client/src/widgets/notifications.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications">
-	<template #header><i class="ti ti-bell"></i>{{ i18n.ts.notifications }}</template>
-	<template #func><button class="_button" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
-
-	<div>
-		<XNotifications :include-types="widgetProps.includingTypes"/>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import XNotifications from '@/components/MkNotifications.vue';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-const name = 'notifications';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	height: {
-		type: 'number' as const,
-		default: 300,
-	},
-	includingTypes: {
-		type: 'array' as const,
-		hidden: true,
-		default: null,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const configureNotification = () => {
-	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
-		includingTypes: widgetProps.includingTypes,
-	}, {
-		done: async (res) => {
-			const { includingTypes } = res;
-			widgetProps.includingTypes = includingTypes;
-			save();
-		},
-	}, 'closed');
-};
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue
deleted file mode 100644
index e9ab79b111..0000000000
--- a/packages/client/src/widgets/online-users.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<template>
-<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
-	<I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text">
-		<template #n><b>{{ onlineUsersCount }}</b></template>
-	</I18n>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-
-const name = 'onlineUsers';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const onlineUsersCount = ref(0);
-
-const tick = () => {
-	os.api('get-online-users-count').then(res => {
-		onlineUsersCount.value = res.count;
-	});
-};
-
-useInterval(tick, 1000 * 15, {
-	immediate: true,
-	afterMounted: true,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.mkw-onlineUsers {
-	text-align: center;
-
-	&.pad {
-		padding: 16px 0;
-	}
-
-	> .text {
-		::v-deep(b) {
-			color: #41b781;
-		}
-
-		::v-deep(span) {
-			opacity: 0.7;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/photos.vue b/packages/client/src/widgets/photos.vue
deleted file mode 100644
index 4ad5324053..0000000000
--- a/packages/client/src/widgets/photos.vue
+++ /dev/null
@@ -1,123 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos">
-	<template #header><i class="ti ti-camera"></i>{{ i18n.ts._widgets.photos }}</template>
-
-	<div class="">
-		<MkLoading v-if="fetching"/>
-		<div v-else :class="$style.stream">
-			<div
-				v-for="(image, i) in images" :key="i"
-				:class="$style.img"
-				:style="`background-image: url(${thumbnail(image)})`"
-			></div>
-		</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import { stream } from '@/stream';
-import { getStaticImageUrl } from '@/scripts/get-static-image-url';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { defaultStore } from '@/store';
-import { i18n } from '@/i18n';
-
-const name = 'photos';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const connection = stream.useChannel('main');
-const images = ref([]);
-const fetching = ref(true);
-
-const onDriveFileCreated = (file) => {
-	if (/^image\/.+$/.test(file.type)) {
-		images.value.unshift(file);
-		if (images.value.length > 9) images.value.pop();
-	}
-};
-
-const thumbnail = (image: any): string => {
-	return defaultStore.state.disableShowingAnimatedImages
-		? getStaticImageUrl(image.thumbnailUrl)
-		: image.thumbnailUrl;
-};
-
-os.api('drive/stream', {
-	type: 'image/*',
-	limit: 9,
-}).then(res => {
-	images.value = res;
-	fetching.value = false;
-});
-
-connection.on('driveFileCreated', onDriveFileCreated);
-onUnmounted(() => {
-	connection.dispose();
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" module>
-.root[data-transparent] {
-	.stream {
-		padding: 0;
-	}
-
-	.img {
-		border: solid 4px transparent;
-		border-radius: 8px;
-	}
-}
-
-.stream {
-	display: flex;
-	justify-content: center;
-	flex-wrap: wrap;
-	padding: 8px;
-
-	.img {
-		flex: 1 1 33%;
-		width: 33%;
-		height: 80px;
-		box-sizing: border-box;
-		background-position: center center;
-		background-size: cover;
-		background-clip: content-box;
-		border: solid 2px transparent;
-		border-radius: 4px;
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/post-form.vue b/packages/client/src/widgets/post-form.vue
deleted file mode 100644
index f1708775ba..0000000000
--- a/packages/client/src/widgets/post-form.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<template>
-<XPostForm class="_panel mkw-postForm" :fixed="true" :autofocus="false"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import XPostForm from '@/components/MkPostForm.vue';
-
-const name = 'postForm';
-
-const widgetPropsDef = {
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
diff --git a/packages/client/src/widgets/rss-ticker.vue b/packages/client/src/widgets/rss-ticker.vue
deleted file mode 100644
index 44c21d1836..0000000000
--- a/packages/client/src/widgets/rss-ticker.vue
+++ /dev/null
@@ -1,152 +0,0 @@
-<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker">
-	<template #header><i class="ti ti-rss"></i>RSS</template>
-	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
-
-	<div class="ekmkgxbk">
-		<MkLoading v-if="fetching"/>
-		<div v-else class="feed">
-			<transition name="change" mode="default">
-				<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse">
-					<span v-for="item in items" class="item">
-						<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
-					</span>
-				</MarqueeText>
-			</transition>
-		</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import MarqueeText from '@/components/MkMarquee.vue';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { useInterval } from '@/scripts/use-interval';
-import { shuffle } from '@/scripts/shuffle';
-
-const name = 'rssTicker';
-
-const widgetPropsDef = {
-	url: {
-		type: 'string' as const,
-		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
-	},
-	shuffle: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	refreshIntervalSec: {
-		type: 'number' as const,
-		default: 60,
-	},
-	duration: {
-		type: 'range' as const,
-		default: 70,
-		step: 1,
-		min: 5,
-		max: 200,
-	},
-	reverse: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	showHeader: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const items = ref([]);
-const fetching = ref(true);
-let key = $ref(0);
-
-const tick = () => {
-	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
-		res.json().then(feed => {
-			if (widgetProps.shuffle) {
-				shuffle(feed.items);
-			}
-			items.value = feed.items;
-			fetching.value = false;
-			key++;
-		});
-	});
-};
-
-watch(() => widgetProps.url, tick);
-
-useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
-	immediate: true,
-	afterMounted: true,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
-	position: absolute;
-	top: 0;
-  transition: all 1s ease;
-}
-.change-enter-from {
-  opacity: 0;
-	transform: translateY(-100%);
-}
-.change-leave-to {
-  opacity: 0;
-	transform: translateY(100%);
-}
-
-.ekmkgxbk {
-	> .feed {
-		--height: 42px;
-		padding: 0;
-		font-size: 0.9em;
-		line-height: var(--height);
-		height: var(--height);
-		contain: strict;
-
-		::v-deep(.item) {
-			display: inline-flex;
-			align-items: center;
-			vertical-align: bottom;
-			color: var(--fg);
-
-			> .divider {
-				display: inline-block;
-				width: 0.5px;
-				height: 16px;
-				margin: 0 1em;
-				background: var(--divider);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue
deleted file mode 100644
index c0338c8e47..0000000000
--- a/packages/client/src/widgets/rss.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-rss">
-	<template #header><i class="ti ti-rss"></i>RSS</template>
-	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
-
-	<div class="ekmkgxbj">
-		<MkLoading v-if="fetching"/>
-		<div v-else class="feed">
-			<a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
-		</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import { useInterval } from '@/scripts/use-interval';
-
-const name = 'rss';
-
-const widgetPropsDef = {
-	url: {
-		type: 'string' as const,
-		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
-	},
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const items = ref([]);
-const fetching = ref(true);
-
-const tick = () => {
-	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
-		res.json().then(feed => {
-			items.value = feed.items;
-			fetching.value = false;
-		});
-	});
-};
-
-watch(() => widgetProps.url, tick);
-
-useInterval(tick, 60000, {
-	immediate: true,
-	afterMounted: true,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.ekmkgxbj {
-	> .feed {
-		padding: 0;
-		font-size: 0.9em;
-
-		> .item {
-			display: block;
-			padding: 8px 16px;
-			color: var(--fg);
-			white-space: nowrap;
-			text-overflow: ellipsis;
-			overflow: hidden;
-
-			&:nth-child(even) {
-				background: rgba(#000, 0.05);
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/cpu-mem.vue b/packages/client/src/widgets/server-metric/cpu-mem.vue
deleted file mode 100644
index 80a8e427e1..0000000000
--- a/packages/client/src/widgets/server-metric/cpu-mem.vue
+++ /dev/null
@@ -1,167 +0,0 @@
-<template>
-<div class="lcfyofjk">
-	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
-		<defs>
-			<linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
-				<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
-				<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
-			</linearGradient>
-			<mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
-				<polygon
-					:points="cpuPolygonPoints"
-					fill="#fff"
-					fill-opacity="0.5"
-				/>
-				<polyline
-					:points="cpuPolylinePoints"
-					fill="none"
-					stroke="#fff"
-					stroke-width="1"
-				/>
-				<circle
-					:cx="cpuHeadX"
-					:cy="cpuHeadY"
-					r="1.5"
-					fill="#fff"
-				/>
-			</mask>
-		</defs>
-		<rect
-			x="-2" y="-2"
-			:width="viewBoxX + 4" :height="viewBoxY + 4"
-			:style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"
-		/>
-		<text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text>
-	</svg>
-	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
-		<defs>
-			<linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
-				<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
-				<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
-			</linearGradient>
-			<mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
-				<polygon
-					:points="memPolygonPoints"
-					fill="#fff"
-					fill-opacity="0.5"
-				/>
-				<polyline
-					:points="memPolylinePoints"
-					fill="none"
-					stroke="#fff"
-					stroke-width="1"
-				/>
-				<circle
-					:cx="memHeadX"
-					:cy="memHeadY"
-					r="1.5"
-					fill="#fff"
-				/>
-			</mask>
-		</defs>
-		<rect
-			x="-2" y="-2"
-			:width="viewBoxX + 4" :height="viewBoxY + 4"
-			:style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"
-		/>
-		<text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text>
-	</svg>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onBeforeUnmount } from 'vue';
-import { v4 as uuid } from 'uuid';
-
-const props = defineProps<{
-	connection: any,
-	meta: any
-}>();
-
-let viewBoxX: number = $ref(50);
-let viewBoxY: number = $ref(30);
-let stats: any[] = $ref([]);
-const cpuGradientId = uuid();
-const cpuMaskId = uuid();
-const memGradientId = uuid();
-const memMaskId = uuid();
-let cpuPolylinePoints: string = $ref('');
-let memPolylinePoints: string = $ref('');
-let cpuPolygonPoints: string = $ref('');
-let memPolygonPoints: string = $ref('');
-let cpuHeadX: any = $ref(null);
-let cpuHeadY: any = $ref(null);
-let memHeadX: any = $ref(null);
-let memHeadY: any = $ref(null);
-let cpuP: string = $ref('');
-let memP: string = $ref('');
-
-onMounted(() => {
-	props.connection.on('stats', onStats);
-	props.connection.on('statsLog', onStatsLog);
-	props.connection.send('requestLog', {
-		id: Math.random().toString().substr(2, 8),
-	});
-});
-
-onBeforeUnmount(() => {
-	props.connection.off('stats', onStats);
-	props.connection.off('statsLog', onStatsLog);
-});
-
-function onStats(connStats) {
-	stats.push(connStats);
-	if (stats.length > 50) stats.shift();
-
-	let cpuPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - s.cpu) * viewBoxY]);
-	let memPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.mem.active / props.meta.mem.total)) * viewBoxY]);
-	cpuPolylinePoints = cpuPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
-	memPolylinePoints = memPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
-
-	cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`;
-	memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`;
-
-	cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0];
-	cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1];
-	memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0];
-	memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1];
-
-	cpuP = (connStats.cpu * 100).toFixed(0);
-	memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
-}
-
-function onStatsLog(statsLog) {
-	for (const revStats of [...statsLog].reverse()) {
-		onStats(revStats);
-	}
-}
-</script>
-
-<style lang="scss" scoped>
-.lcfyofjk {
-	display: flex;
-
-	> svg {
-		display: block;
-		padding: 10px;
-		width: 50%;
-
-		&:first-child {
-			padding-right: 5px;
-		}
-
-		&:last-child {
-			padding-left: 5px;
-		}
-
-		> text {
-			font-size: 4.5px;
-			fill: currentColor;
-
-			> tspan {
-				opacity: 0.5;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/cpu.vue b/packages/client/src/widgets/server-metric/cpu.vue
deleted file mode 100644
index e7b2226d1f..0000000000
--- a/packages/client/src/widgets/server-metric/cpu.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<template>
-<div class="vrvdvrys">
-	<XPie class="pie" :value="usage"/>
-	<div>
-		<p><i class="ti ti-cpu"></i>CPU</p>
-		<p>{{ meta.cpu.cores }} Logical cores</p>
-		<p>{{ meta.cpu.model }}</p>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onBeforeUnmount } from 'vue';
-import XPie from './pie.vue';
-
-const props = defineProps<{
-	connection: any,
-	meta: any
-}>();
-
-let usage: number = $ref(0);
-
-function onStats(stats) {
-	usage = stats.cpu;
-}
-
-onMounted(() => {
-	props.connection.on('stats', onStats);
-});
-
-onBeforeUnmount(() => {
-	props.connection.off('stats', onStats);
-});
-</script>
-
-<style lang="scss" scoped>
-.vrvdvrys {
-	display: flex;
-	padding: 16px;
-
-	> .pie {
-		height: 82px;
-		flex-shrink: 0;
-		margin-right: 16px;
-	}
-
-	> div {
-		flex: 1;
-
-		> p {
-			margin: 0;
-			font-size: 0.8em;
-
-			&:first-child {
-				font-weight: bold;
-				margin-bottom: 4px;
-
-				> i {
-					margin-right: 4px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/disk.vue b/packages/client/src/widgets/server-metric/disk.vue
deleted file mode 100644
index 3d22d05383..0000000000
--- a/packages/client/src/widgets/server-metric/disk.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<template>
-<div class="zbwaqsat">
-	<XPie class="pie" :value="usage"/>
-	<div>
-		<p><i class="ti ti-database"></i>Disk</p>
-		<p>Total: {{ bytes(total, 1) }}</p>
-		<p>Free: {{ bytes(available, 1) }}</p>
-		<p>Used: {{ bytes(used, 1) }}</p>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XPie from './pie.vue';
-import bytes from '@/filters/bytes';
-
-const props = defineProps<{
-	meta: any; // TODO
-}>();
-
-const usage = $computed(() => props.meta.fs.used / props.meta.fs.total);
-const total = $computed(() => props.meta.fs.total);
-const used = $computed(() => props.meta.fs.used);
-const available = $computed(() => props.meta.fs.total - props.meta.fs.used);
-</script>
-
-<style lang="scss" scoped>
-.zbwaqsat {
-	display: flex;
-	padding: 16px;
-
-	> .pie {
-		height: 82px;
-		flex-shrink: 0;
-		margin-right: 16px;
-	}
-
-	> div {
-		flex: 1;
-
-		> p {
-			margin: 0;
-			font-size: 0.8em;
-
-			&:first-child {
-				font-weight: bold;
-				margin-bottom: 4px;
-
-				> i {
-					margin-right: 4px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue
deleted file mode 100644
index bc3fca6fc1..0000000000
--- a/packages/client/src/widgets/server-metric/index.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
-	<template #header><i class="ti ti-server"></i>{{ i18n.ts._widgets.serverMetric }}</template>
-	<template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
-
-	<div v-if="meta" class="mkw-serverMetric">
-		<XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/>
-		<XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/>
-		<XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/>
-		<XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/>
-		<XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget';
-import XCpuMemory from './cpu-mem.vue';
-import XNet from './net.vue';
-import XCpu from './cpu.vue';
-import XMemory from './mem.vue';
-import XDisk from './disk.vue';
-import MkContainer from '@/components/MkContainer.vue';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import { stream } from '@/stream';
-import { i18n } from '@/i18n';
-
-const name = 'serverMetric';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	view: {
-		type: 'number' as const,
-		default: 0,
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const meta = ref(null);
-
-os.api('server-info', {}).then(res => {
-	meta.value = res;
-});
-
-const toggleView = () => {
-	if (widgetProps.view === 4) {
-		widgetProps.view = 0;
-	} else {
-		widgetProps.view++;
-	}
-	save();
-};
-
-const connection = stream.useChannel('serverStats');
-onUnmounted(() => {
-	connection.dispose();
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
diff --git a/packages/client/src/widgets/server-metric/mem.vue b/packages/client/src/widgets/server-metric/mem.vue
deleted file mode 100644
index 6018eb4265..0000000000
--- a/packages/client/src/widgets/server-metric/mem.vue
+++ /dev/null
@@ -1,73 +0,0 @@
-<template>
-<div class="zlxnikvl">
-	<XPie class="pie" :value="usage"/>
-	<div>
-		<p><i class="fas fa-memory"></i>RAM</p>
-		<p>Total: {{ bytes(total, 1) }}</p>
-		<p>Used: {{ bytes(used, 1) }}</p>
-		<p>Free: {{ bytes(free, 1) }}</p>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onBeforeUnmount } from 'vue';
-import XPie from './pie.vue';
-import bytes from '@/filters/bytes';
-
-const props = defineProps<{
-	connection: any,
-	meta: any
-}>();
-
-let usage: number = $ref(0);
-let total: number = $ref(0);
-let used: number = $ref(0);
-let free: number = $ref(0);
-
-function onStats(stats) {
-	usage = stats.mem.active / props.meta.mem.total;
-	total = props.meta.mem.total;
-	used = stats.mem.active;
-	free = total - used;
-}
-
-onMounted(() => {
-	props.connection.on('stats', onStats);
-});
-
-onBeforeUnmount(() => {
-	props.connection.off('stats', onStats);
-});
-</script>
-
-<style lang="scss" scoped>
-.zlxnikvl {
-	display: flex;
-	padding: 16px;
-
-	> .pie {
-		height: 82px;
-		flex-shrink: 0;
-		margin-right: 16px;
-	}
-
-	> div {
-		flex: 1;
-
-		> p {
-			margin: 0;
-			font-size: 0.8em;
-
-			&:first-child {
-				font-weight: bold;
-				margin-bottom: 4px;
-
-				> i {
-					margin-right: 4px;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/net.vue b/packages/client/src/widgets/server-metric/net.vue
deleted file mode 100644
index ab8b0fe471..0000000000
--- a/packages/client/src/widgets/server-metric/net.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-<template>
-<div class="oxxrhrto">
-	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
-		<polygon
-			:points="inPolygonPoints"
-			fill="#94a029"
-			fill-opacity="0.5"
-		/>
-		<polyline
-			:points="inPolylinePoints"
-			fill="none"
-			stroke="#94a029"
-			stroke-width="1"
-		/>
-		<circle
-			:cx="inHeadX"
-			:cy="inHeadY"
-			r="1.5"
-			fill="#94a029"
-		/>
-		<text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text>
-	</svg>
-	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
-		<polygon
-			:points="outPolygonPoints"
-			fill="#ff9156"
-			fill-opacity="0.5"
-		/>
-		<polyline
-			:points="outPolylinePoints"
-			fill="none"
-			stroke="#ff9156"
-			stroke-width="1"
-		/>
-		<circle
-			:cx="outHeadX"
-			:cy="outHeadY"
-			r="1.5"
-			fill="#ff9156"
-		/>
-		<text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text>
-	</svg>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onBeforeUnmount } from 'vue';
-import bytes from '@/filters/bytes';
-
-const props = defineProps<{
-	connection: any,
-	meta: any
-}>();
-
-let viewBoxX: number = $ref(50);
-let viewBoxY: number = $ref(30);
-let stats: any[] = $ref([]);
-let inPolylinePoints: string = $ref('');
-let outPolylinePoints: string = $ref('');
-let inPolygonPoints: string = $ref('');
-let outPolygonPoints: string = $ref('');
-let inHeadX: any = $ref(null);
-let inHeadY: any = $ref(null);
-let outHeadX: any = $ref(null);
-let outHeadY: any = $ref(null);
-let inRecent: number = $ref(0);
-let outRecent: number = $ref(0);
-
-onMounted(() => {
-	props.connection.on('stats', onStats);
-	props.connection.on('statsLog', onStatsLog);
-	props.connection.send('requestLog', {
-		id: Math.random().toString().substr(2, 8),
-	});
-});
-
-onBeforeUnmount(() => {
-	props.connection.off('stats', onStats);
-	props.connection.off('statsLog', onStatsLog);
-});
-
-function onStats(connStats) {
-	stats.push(connStats);
-	if (stats.length > 50) stats.shift();
-
-	const inPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.rx)));
-	const outPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.tx)));
-
-	let inPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * viewBoxY]);
-	let outPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * viewBoxY]);
-	inPolylinePoints = inPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
-	outPolylinePoints = outPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
-
-	inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`;
-	outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`;
-
-	inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0];
-	inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1];
-	outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0];
-	outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1];
-
-	inRecent = connStats.net.rx;
-	outRecent = connStats.net.tx;
-}
-
-function onStatsLog(statsLog) {
-	for (const revStats of [...statsLog].reverse()) {
-		onStats(revStats);
-	}
-}
-</script>
-
-<style lang="scss" scoped>
-.oxxrhrto {
-	display: flex;
-
-	> svg {
-		display: block;
-		padding: 10px;
-		width: 50%;
-
-		&:first-child {
-			padding-right: 5px;
-		}
-
-		&:last-child {
-			padding-left: 5px;
-		}
-
-		> text {
-			font-size: 4.5px;
-			fill: currentColor;
-
-			> tspan {
-				opacity: 0.5;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/server-metric/pie.vue b/packages/client/src/widgets/server-metric/pie.vue
deleted file mode 100644
index 868dbc0484..0000000000
--- a/packages/client/src/widgets/server-metric/pie.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<template>
-<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
-	<circle
-		:r="r"
-		cx="50%" cy="50%"
-		fill="none"
-		stroke-width="0.1"
-		stroke="rgba(0, 0, 0, 0.05)"
-	/>
-	<circle
-		:r="r"
-		cx="50%" cy="50%"
-		:stroke-dasharray="Math.PI * (r * 2)"
-		:stroke-dashoffset="strokeDashoffset"
-		fill="none"
-		stroke-width="0.1"
-		:stroke="color"
-	/>
-	<text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
-</svg>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-
-const props = defineProps<{
-	value: number;
-}>();
-
-const r = 0.45;
-
-const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`);
-const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2)));
-</script>
-
-<style lang="scss" scoped>
-.hsalcinq {
-	display: block;
-	height: 100%;
-
-	> circle {
-		transform-origin: center;
-		transform: rotate(-90deg);
-		transition: stroke-dashoffset 0.5s ease;
-	}
-
-	> text {
-		font-size: 0.15px;
-		fill: currentColor;
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue
deleted file mode 100644
index e317b8ab94..0000000000
--- a/packages/client/src/widgets/slideshow.vue
+++ /dev/null
@@ -1,159 +0,0 @@
-<template>
-<div class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }">
-	<div @click="choose">
-		<p v-if="widgetProps.folderId == null">
-			{{ i18n.ts.folder }}
-		</p>
-		<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
-		<div ref="slideA" class="slide a"></div>
-		<div ref="slideB" class="slide b"></div>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-
-const name = 'slideshow';
-
-const widgetPropsDef = {
-	height: {
-		type: 'number' as const,
-		default: 300,
-	},
-	folderId: {
-		type: 'string' as const,
-		default: null,
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const images = ref([]);
-const fetching = ref(true);
-const slideA = ref<HTMLElement>();
-const slideB = ref<HTMLElement>();
-
-const change = () => {
-	if (images.value.length === 0) return;
-
-	const index = Math.floor(Math.random() * images.value.length);
-	const img = `url(${ images.value[index].url })`;
-
-	slideB.value.style.backgroundImage = img;
-
-	slideB.value.classList.add('anime');
-	window.setTimeout(() => {
-		// 既にこのウィジェットがunmountされていたら要素がない
-		if (slideA.value == null) return;
-
-		slideA.value.style.backgroundImage = img;
-
-		slideB.value.classList.remove('anime');
-	}, 1000);
-};
-
-const fetch = () => {
-	fetching.value = true;
-
-	os.api('drive/files', {
-		folderId: widgetProps.folderId,
-		type: 'image/*',
-		limit: 100,
-	}).then(res => {
-		images.value = res;
-		fetching.value = false;
-		slideA.value.style.backgroundImage = '';
-		slideB.value.style.backgroundImage = '';
-		change();
-	});
-};
-
-const choose = () => {
-	os.selectDriveFolder(false).then(folder => {
-		if (folder == null) {
-			return;
-		}
-		widgetProps.folderId = folder.id;
-		save();
-		fetch();
-	});
-};
-
-useInterval(change, 10000, {
-	immediate: false,
-	afterMounted: true,
-});
-
-onMounted(() => {
-	if (widgetProps.folderId != null) {
-		fetch();
-	}
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.kvausudm {
-	position: relative;
-
-	> div {
-		width: 100%;
-		height: 100%;
-		cursor: pointer;
-
-		> p {
-			display: block;
-			margin: 1em;
-			text-align: center;
-			color: #888;
-		}
-
-		> * {
-			pointer-events: none;
-		}
-
-		> .slide {
-			position: absolute;
-			top: 0;
-			left: 0;
-			width: 100%;
-			height: 100%;
-			background-size: cover;
-			background-position: center;
-
-			&.b {
-				opacity: 0;
-			}
-
-			&.anime {
-				transition: opacity 1s;
-				opacity: 1;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/timeline.vue b/packages/client/src/widgets/timeline.vue
deleted file mode 100644
index e48444d33f..0000000000
--- a/packages/client/src/widgets/timeline.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" class="mkw-timeline">
-	<template #header>
-		<button class="_button" @click="choose">
-			<i v-if="widgetProps.src === 'home'" class="ti ti-home"></i>
-			<i v-else-if="widgetProps.src === 'local'" class="ti ti-messages"></i>
-			<i v-else-if="widgetProps.src === 'social'" class="ti ti-share"></i>
-			<i v-else-if="widgetProps.src === 'global'" class="ti ti-world"></i>
-			<i v-else-if="widgetProps.src === 'list'" class="ti ti-list"></i>
-			<i v-else-if="widgetProps.src === 'antenna'" class="ti ti-antenna"></i>
-			<span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span>
-			<i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i>
-		</button>
-	</template>
-
-	<div>
-		<XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
-import { GetFormResultType } from '@/scripts/form';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import * as os from '@/os';
-import MkContainer from '@/components/MkContainer.vue';
-import XTimeline from '@/components/MkTimeline.vue';
-import { $i } from '@/account';
-import { i18n } from '@/i18n';
-
-const name = 'timeline';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	height: {
-		type: 'number' as const,
-		default: 300,
-	},
-	src: {
-		type: 'string' as const,
-		default: 'home',
-		hidden: true,
-	},
-	antenna: {
-		type: 'object' as const,
-		default: null,
-		hidden: true,
-	},
-	list: {
-		type: 'object' as const,
-		default: null,
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const menuOpened = ref(false);
-
-const setSrc = (src) => {
-	widgetProps.src = src;
-	save();
-};
-
-const choose = async (ev) => {
-	menuOpened.value = true;
-	const [antennas, lists] = await Promise.all([
-		os.api('antennas/list'),
-		os.api('users/lists/list'),
-	]);
-	const antennaItems = antennas.map(antenna => ({
-		text: antenna.name,
-		icon: 'ti ti-antenna',
-		action: () => {
-			widgetProps.antenna = antenna;
-			setSrc('antenna');
-		},
-	}));
-	const listItems = lists.map(list => ({
-		text: list.name,
-		icon: 'ti ti-list',
-		action: () => {
-			widgetProps.list = list;
-			setSrc('list');
-		},
-	}));
-	os.popupMenu([{
-		text: i18n.ts._timelines.home,
-		icon: 'ti ti-home',
-		action: () => { setSrc('home'); },
-	}, {
-		text: i18n.ts._timelines.local,
-		icon: 'ti ti-messages',
-		action: () => { setSrc('local'); },
-	}, {
-		text: i18n.ts._timelines.social,
-		icon: 'ti ti-share',
-		action: () => { setSrc('social'); },
-	}, {
-		text: i18n.ts._timelines.global,
-		icon: 'ti ti-world',
-		action: () => { setSrc('global'); },
-	}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
-		menuOpened.value = false;
-	});
-};
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue
deleted file mode 100644
index 02eec0431e..0000000000
--- a/packages/client/src/widgets/trends.vue
+++ /dev/null
@@ -1,120 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-trends">
-	<template #header><i class="ti ti-hash"></i>{{ i18n.ts._widgets.trends }}</template>
-
-	<div class="wbrkwala">
-		<MkLoading v-if="fetching"/>
-		<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags">
-			<div v-for="stat in stats" :key="stat.tag">
-				<div class="tag">
-					<MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
-					<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
-				</div>
-				<MkMiniChart class="chart" :src="stat.chart"/>
-			</div>
-		</transition-group>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import MkMiniChart from '@/components/MkMiniChart.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-
-const name = 'hashtags';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-const stats = ref([]);
-const fetching = ref(true);
-
-const fetch = () => {
-	os.api('hashtags/trend').then(res => {
-		stats.value = res;
-		fetching.value = false;
-	});
-};
-
-useInterval(fetch, 1000 * 60, {
-	immediate: true,
-	afterMounted: true,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.wbrkwala {
-	height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
-	overflow: hidden;
-
-	> .tags {
-		.chart-move {
-			transition: transform 1s ease;
-		}
-
-		> div {
-			display: flex;
-			align-items: center;
-			padding: 14px 16px;
-			border-bottom: solid 0.5px var(--divider);
-
-			> .tag {
-				flex: 1;
-				overflow: hidden;
-				font-size: 0.9em;
-				color: var(--fg);
-
-				> .a {
-					display: block;
-					width: 100%;
-					white-space: nowrap;
-					overflow: hidden;
-					text-overflow: ellipsis;
-					line-height: 18px;
-				}
-
-				> p {
-					margin: 0;
-					font-size: 75%;
-					opacity: 0.7;
-					line-height: 16px;
-				}
-			}
-
-			> .chart {
-				height: 30px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/unix-clock.vue b/packages/client/src/widgets/unix-clock.vue
deleted file mode 100644
index cf85ac782c..0000000000
--- a/packages/client/src/widgets/unix-clock.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-<template>
-<div class="mkw-unixClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
-	<div v-if="widgetProps.showLabel" class="label">UNIX Epoch</div>
-	<div class="time">
-		<span v-text="ss"></span>
-		<span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span>
-		<span v-if="widgetProps.showMs" v-text="ms"></span>
-	</div>
-	<div v-if="widgetProps.showLabel" class="label">UTC</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-
-const name = 'unixClock';
-
-const widgetPropsDef = {
-	transparent: {
-		type: 'boolean' as const,
-		default: false,
-	},
-	fontSize: {
-		type: 'number' as const,
-		default: 1.5,
-		step: 0.1,
-	},
-	showMs: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	showLabel: {
-		type: 'boolean' as const,
-		default: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-let intervalId;
-const ss = ref('');
-const ms = ref('');
-const showColon = ref(false);
-let prevSec: string | null = null;
-
-watch(showColon, (v) => {
-	if (v) {
-		window.setTimeout(() => {
-			showColon.value = false;
-		}, 30);
-	}
-});
-
-const tick = () => {
-	const now = new Date();
-	ss.value = Math.floor(now.getTime() / 1000).toString();
-	ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0');
-	if (ss.value !== prevSec) showColon.value = true;
-	prevSec = ss.value;
-};
-
-tick();
-
-watch(() => widgetProps.showMs, () => {
-	if (intervalId) window.clearInterval(intervalId);
-	intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000);
-}, { immediate: true });
-
-onUnmounted(() => {
-	window.clearInterval(intervalId);
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" scoped>
-.mkw-unixClock {
-	padding: 16px 0;
-	text-align: center;
-
-	> .label {
-		font-size: 65%;
-		opacity: 0.7;
-	}
-
-	> .time {
-		> .colon {
-			opacity: 0;
-			transition: opacity 1s ease;
-
-			&.showColon {
-				opacity: 1;
-				transition: opacity 0s;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/user-list.vue b/packages/client/src/widgets/user-list.vue
deleted file mode 100644
index 9ffbf0d8e3..0000000000
--- a/packages/client/src/widgets/user-list.vue
+++ /dev/null
@@ -1,136 +0,0 @@
-<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList">
-	<template #header><i class="ti ti-users"></i>{{ list ? list.name : i18n.ts._widgets.userList }}</template>
-	<template #func><button class="_button" @click="configure()"><i class="ti ti-settings"></i></button></template>
-
-	<div :class="$style.root">
-		<div v-if="widgetProps.listId == null" class="init">
-			<MkButton primary @click="chooseList">{{ i18n.ts._widgets._userList.chooseList }}</MkButton>
-		</div>
-		<MkLoading v-else-if="fetching"/>
-		<div v-else class="users">
-			<MkA v-for="user in users" :key="user.id" class="user">
-				<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-			</MkA>
-		</div>
-	</div>
-</MkContainer>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
-import { GetFormResultType } from '@/scripts/form';
-import MkContainer from '@/components/MkContainer.vue';
-import * as os from '@/os';
-import { useInterval } from '@/scripts/use-interval';
-import { i18n } from '@/i18n';
-import MkButton from '@/components/MkButton.vue';
-
-const name = 'userList';
-
-const widgetPropsDef = {
-	showHeader: {
-		type: 'boolean' as const,
-		default: true,
-	},
-	listId: {
-		type: 'string' as const,
-		default: null,
-		hidden: true,
-	},
-};
-
-type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
-const { widgetProps, configure, save } = useWidgetPropsManager(name,
-	widgetPropsDef,
-	props,
-	emit,
-);
-
-let list = $ref();
-let users = $ref([]);
-let fetching = $ref(true);
-
-async function chooseList() {
-	const lists = await os.api('users/lists/list');
-	const { canceled, result: list } = await os.select({
-		title: i18n.ts.selectList,
-		items: lists.map(x => ({
-			value: x, text: x.name,
-		})),
-		default: widgetProps.listId,
-	});
-	if (canceled) return;
-
-	widgetProps.listId = list.id;
-	save();
-	fetch();
-}
-
-const fetch = () => {
-	if (widgetProps.listId == null) {
-		fetching = false;
-		return;
-	}
-
-	os.api('users/lists/show', {
-		listId: widgetProps.listId,
-	}).then(_list => {
-		list = _list;
-		os.api('users/show', {
-			userIds: list.userIds,
-		}).then(_users => {
-			users = _users;
-			fetching = false;
-		});
-	});
-};
-
-useInterval(fetch, 1000 * 60, {
-	immediate: true,
-	afterMounted: true,
-});
-
-defineExpose<WidgetComponentExpose>({
-	name,
-	configure,
-	id: props.widget ? props.widget.id : null,
-});
-</script>
-
-<style lang="scss" module>
-.root {
-	&:global {
-		> .init {
-			padding: 16px;
-		}
-
-		> .users {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
-			grid-gap: 12px;
-			place-content: center;
-			padding: 16px;
-
-			> .user {
-				width: 100%;
-				height: 100%;
-				aspect-ratio: 1;
-
-				> .avatar {
-					width: 100%;
-					height: 100%;
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/widgets/widget.ts b/packages/client/src/widgets/widget.ts
deleted file mode 100644
index 8bd56a5966..0000000000
--- a/packages/client/src/widgets/widget.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { reactive, watch } from 'vue';
-import { throttle } from 'throttle-debounce';
-import { Form, GetFormResultType } from '@/scripts/form';
-import * as os from '@/os';
-import { deepClone } from '@/scripts/clone';
-
-export type Widget<P extends Record<string, unknown>> = {
-	id: string;
-	data: Partial<P>;
-};
-
-export type WidgetComponentProps<P extends Record<string, unknown>> = {
-	widget?: Widget<P>;
-};
-
-export type WidgetComponentEmits<P extends Record<string, unknown>> = {
-	(ev: 'updateProps', props: P);
-};
-
-export type WidgetComponentExpose = {
-	name: string;
-	id: string | null;
-	configure: () => void;
-};
-
-export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>(
-	name: string,
-	propsDef: F,
-	props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
-	emit: WidgetComponentEmits<GetFormResultType<F>>,
-): {
-	widgetProps: GetFormResultType<F>;
-	save: () => void;
-	configure: () => void;
-} => {
-	const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {});
-
-	const mergeProps = () => {
-		for (const prop of Object.keys(propsDef)) {
-			if (typeof widgetProps[prop] === 'undefined') {
-				widgetProps[prop] = propsDef[prop].default;
-			}
-		}
-	};
-	watch(widgetProps, () => {
-		mergeProps();
-	}, { deep: true, immediate: true });
-
-	const save = throttle(3000, () => {
-		emit('updateProps', widgetProps);
-	});
-
-	const configure = async () => {
-		const form = deepClone(propsDef);
-		for (const item of Object.keys(form)) {
-			form[item].default = widgetProps[item];
-		}
-		const { canceled, result } = await os.form(name, form);
-		if (canceled) return;
-
-		for (const key of Object.keys(result)) {
-			widgetProps[key] = result[key];
-		}
-
-		save();
-	};
-
-	return {
-		widgetProps,
-		save,
-		configure,
-	};
-};
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
deleted file mode 100644
index 86109f600a..0000000000
--- a/packages/client/tsconfig.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
-	"compilerOptions": {
-		"allowJs": true,
-		"noEmitOnError": false,
-		"noImplicitAny": false,
-		"noImplicitReturns": true,
-		"noUnusedParameters": false,
-		"noUnusedLocals": true,
-		"noFallthroughCasesInSwitch": true,
-		"declaration": false,
-		"sourceMap": false,
-		"target": "es2017",
-		"module": "esnext",
-		"moduleResolution": "node",
-		"removeComments": false,
-		"noLib": false,
-		"strict": true,
-		"strictNullChecks": true,
-		"experimentalDecorators": true,
-		"resolveJsonModule": true,
-		"allowSyntheticDefaultImports": true,
-		"isolatedModules": true,
-		"useDefineForClassFields": true,
-		"baseUrl": ".",
-		"paths": {
-			"@/*": ["./src/*"],
-		},
-		"typeRoots": [
-			"node_modules/@types",
-			"@types",
-		],
-		"types": [
-			"vite/client",
-		],
-		"lib": [
-			"esnext",
-			"dom"
-		],
-		"jsx": "preserve"
-	},
-	"compileOnSave": false,
-	"include": [
-		".eslintrc.js",
-		"./**/*.ts",
-		"./**/*.vue"
-	]
-}
diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts
deleted file mode 100644
index 1acf5301b7..0000000000
--- a/packages/client/vite.config.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import pluginVue from '@vitejs/plugin-vue';
-import { defineConfig } from 'vite';
-
-import locales from '../../locales';
-import meta from '../../package.json';
-import pluginJson5 from './vite.json5';
-
-const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
-
-export default defineConfig(({ command, mode }) => {
-
-	return {
-		base: '/vite/',
-
-		plugins: [
-			pluginVue({
-				reactivityTransform: true,
-			}),
-			pluginJson5(),
-		],
-
-		resolve: {
-			extensions,
-			alias: {
-				'@/': __dirname + '/src/',
-				'/client-assets/': __dirname + '/assets/',
-				'/static-assets/': __dirname + '/../backend/assets/',
-			},
-		},
-
-		define: {
-			_VERSION_: JSON.stringify(meta.version),
-			_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
-			_ENV_: JSON.stringify(process.env.NODE_ENV),
-			_DEV_: process.env.NODE_ENV !== 'production',
-			_PERF_PREFIX_: JSON.stringify('Misskey:'),
-			_DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'),
-			_DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'),
-			_DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'),
-			__VUE_OPTIONS_API__: true,
-			__VUE_PROD_DEVTOOLS__: false,
-		},
-
-		build: {
-			target: [
-				'chrome100',
-				'firefox100',
-				'safari15',
-				'es2017', // TODO: そのうち消す
-			],
-			manifest: 'manifest.json',
-			rollupOptions: {
-				input: {
-					app: './src/init.ts',
-				},
-				output: {
-					manualChunks: {
-						vue: ['vue'],
-					},
-				},
-			},
-			cssCodeSplit: true,
-			outDir: __dirname + '/../../built/_vite_',
-			assetsDir: '.',
-			emptyOutDir: false,
-			sourcemap: process.env.NODE_ENV === 'development',
-			reportCompressedSize: false,
-		},
-	};
-});
diff --git a/packages/client/vite.json5.ts b/packages/client/vite.json5.ts
deleted file mode 100644
index 0a37fbff44..0000000000
--- a/packages/client/vite.json5.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json
-
-import JSON5 from 'json5';
-import { Plugin } from 'rollup';
-import { createFilter, dataToEsm } from '@rollup/pluginutils';
-import { RollupJsonOptions } from '@rollup/plugin-json';
-
-export default function json5(options: RollupJsonOptions = {}): Plugin {
-	const filter = createFilter(options.include, options.exclude);
-	const indent = 'indent' in options ? options.indent : '\t';
-
-	return {
-		name: 'json5',
-
-		// eslint-disable-next-line no-shadow
-		transform(json, id) {
-			if (id.slice(-6) !== '.json5' || !filter(id)) return null;
-
-			try {
-				const parsed = JSON5.parse(json);
-				return {
-					code: dataToEsm(parsed, {
-						preferConst: options.preferConst,
-						compact: options.compact,
-						namedExports: options.namedExports,
-						indent,
-					}),
-					map: { mappings: '' },
-				};
-			} catch (err) {
-				const message = 'Could not parse JSON file';
-				const position = parseInt(/[\d]/.exec(err.message)[0], 10);
-				this.warn({ message, id, position });
-				return null;
-			}
-		},
-	};
-}
diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js
new file mode 100644
index 0000000000..6c3bfb5a6e
--- /dev/null
+++ b/packages/frontend/.eslintrc.js
@@ -0,0 +1,89 @@
+module.exports = {
+	root: true,
+	env: {
+		'node': false,
+	},
+	parser: 'vue-eslint-parser',
+	parserOptions: {
+		'parser': '@typescript-eslint/parser',
+		tsconfigRootDir: __dirname,
+		project: ['./tsconfig.json'],
+		extraFileExtensions: ['.vue'],
+	},
+	extends: [
+		'../shared/.eslintrc.js',
+		'plugin:vue/vue3-recommended',
+	],
+	rules: {
+		'@typescript-eslint/no-empty-interface': [
+			'error',
+			{
+				'allowSingleExtends': true,
+			},
+		],
+		'@typescript-eslint/prefer-nullish-coalescing': [
+			'error',
+		],
+		// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
+		// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
+		'id-denylist': ['error', 'window', 'e'],
+		'no-shadow': ['warn'],
+		'vue/attributes-order': ['error', {
+			'alphabetical': false,
+		}],
+		'vue/no-use-v-if-with-v-for': ['error', {
+			'allowUsingIterationVar': false,
+		}],
+		'vue/no-ref-as-operand': 'error',
+		'vue/no-multi-spaces': ['error', {
+			'ignoreProperties': false,
+		}],
+		'vue/no-v-html': 'warn',
+		'vue/order-in-components': 'error',
+		'vue/html-indent': ['warn', 'tab', {
+			'attribute': 1,
+			'baseIndent': 0,
+			'closeBracket': 0,
+			'alignAttributesVertically': true,
+			'ignores': [],
+		}],
+		'vue/html-closing-bracket-spacing': ['warn', {
+			'startTag': 'never',
+			'endTag': 'never',
+			'selfClosingTag': 'never',
+		}],
+		'vue/multi-word-component-names': 'warn',
+		'vue/require-v-for-key': 'warn',
+		'vue/no-unused-components': 'warn',
+		'vue/valid-v-for': 'warn',
+		'vue/return-in-computed-property': 'warn',
+		'vue/no-setup-props-destructure': 'warn',
+		'vue/max-attributes-per-line': 'off',
+		'vue/html-self-closing': 'off',
+		'vue/singleline-html-element-content-newline': 'off',
+		// (vue/vue3-recommended disabled the autofix for Vue 2 compatibility)
+		'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }],
+	},
+	globals: {
+		// Node.js
+		'module': false,
+		'require': false,
+		'__dirname': false,
+
+		// Vue
+		'$$': false,
+		'$ref': false,
+		'$shallowRef': false,
+		'$computed': false,
+
+		// Misskey
+		'_DEV_': false,
+		'_LANGS_': false,
+		'_VERSION_': false,
+		'_ENV_': false,
+		'_PERF_PREFIX_': false,
+		'_DATA_TRANSFER_DRIVE_FILE_': false,
+		'_DATA_TRANSFER_DRIVE_FOLDER_': false,
+		'_DATA_TRANSFER_DECK_COLUMN_': false,
+	},
+};
diff --git a/packages/frontend/.vscode/settings.json b/packages/frontend/.vscode/settings.json
new file mode 100644
index 0000000000..1a79b6a7dc
--- /dev/null
+++ b/packages/frontend/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+	"typescript.tsdk": "node_modules\\typescript\\lib",
+	"path-intellisense.mappings": {
+		"@": "${workspaceRoot}/packages/frontend/src/"
+	},
+	"eslint.validate": [
+		"javascript",
+		"javascriptreact",
+		"vue"
+	]
+}
diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts
new file mode 100644
index 0000000000..c757482900
--- /dev/null
+++ b/packages/frontend/@types/global.d.ts
@@ -0,0 +1,10 @@
+type FIXME = any;
+
+declare const _LANGS_: string[][];
+declare const _VERSION_: string;
+declare const _ENV_: string;
+declare const _DEV_: boolean;
+declare const _PERF_PREFIX_: string;
+declare const _DATA_TRANSFER_DRIVE_FILE_: string;
+declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
+declare const _DATA_TRANSFER_DECK_COLUMN_: string;
diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts
new file mode 100644
index 0000000000..67f724a9aa
--- /dev/null
+++ b/packages/frontend/@types/theme.d.ts
@@ -0,0 +1,7 @@
+declare module '@/themes/*.json5' {
+	import { Theme } from "@/scripts/theme";
+
+	const theme: Theme;
+
+	export default theme;
+}
diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts
new file mode 100644
index 0000000000..9c9c34ccc5
--- /dev/null
+++ b/packages/frontend/@types/vue.d.ts
@@ -0,0 +1,16 @@
+/// <reference types="vue/macros-global" />
+
+import type { $i } from '@/account';
+import type { defaultStore } from '@/store';
+import type { instance } from '@/instance';
+import type { i18n } from '@/i18n';
+
+declare module 'vue' {
+	interface ComponentCustomProperties {
+		$i: typeof $i;
+		$store: typeof defaultStore;
+		$instance: typeof instance;
+		$t: typeof i18n['t'];
+		$ts: typeof i18n['ts'];
+	}
+}
diff --git a/packages/frontend/assets/about-icon.png b/packages/frontend/assets/about-icon.png
new file mode 100644
index 0000000000..afc1f0c728
Binary files /dev/null and b/packages/frontend/assets/about-icon.png differ
diff --git a/packages/frontend/assets/dummy.png b/packages/frontend/assets/dummy.png
new file mode 100644
index 0000000000..39332b0c1b
Binary files /dev/null and b/packages/frontend/assets/dummy.png differ
diff --git a/packages/frontend/assets/fedi.jpg b/packages/frontend/assets/fedi.jpg
new file mode 100644
index 0000000000..cbf3748eb8
Binary files /dev/null and b/packages/frontend/assets/fedi.jpg differ
diff --git a/packages/frontend/assets/label-red.svg b/packages/frontend/assets/label-red.svg
new file mode 100644
index 0000000000..45996aa9ce
--- /dev/null
+++ b/packages/frontend/assets/label-red.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+	 y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
+<polygon fill="#ea2412" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
+</svg>
diff --git a/packages/frontend/assets/label.svg b/packages/frontend/assets/label.svg
new file mode 100644
index 0000000000..b1f85f3c07
--- /dev/null
+++ b/packages/frontend/assets/label.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+	 y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
+<polygon fill="#0B8AEA" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
+</svg>
diff --git a/packages/frontend/assets/misskey.svg b/packages/frontend/assets/misskey.svg
new file mode 100644
index 0000000000..3fcb2d3ecb
--- /dev/null
+++ b/packages/frontend/assets/misskey.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="515px" height="136px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <g id="path2" transform="matrix(0.264585,0,0,0.264585,-50.0005,-50.0001)">
+        <path d="M256.418,188.976C248.558,188.944 240.758,190.308 233.379,193.013C220.308,197.613 209.533,205.888 201.091,217.802C193.02,229.329 188.977,242.195 188.977,256.409L188.977,508.89C188.977,527.332 195.52,543.29 208.576,556.732C222.032,569.803 237.99,576.331 256.418,576.331C275.259,576.331 291.204,569.803 304.274,556.747C317.73,543.291 324.441,527.332 324.441,508.89L324.441,462.983C324.584,453.04 334.824,455.655 340.01,462.983C349.695,479.767 370.251,494.19 394.193,494.105L394.193,494.119C418.119,494.019 438.005,482.196 448.375,462.983C452.304,458.354 463.377,450.455 464.52,462.983L464.52,508.89C464.52,527.332 471.047,543.29 484.104,556.732C497.574,569.803 513.511,576.331 531.953,576.331C550.78,576.331 566.739,569.803 579.809,556.747C593.265,543.291 599.977,527.332 599.977,508.89L599.977,256.409C599.977,242.195 595.752,229.329 587.309,217.802C579.224,205.874 568.653,197.613 555.597,193.013C547.912,190.314 540.228,188.976 532.543,188.976C511.788,188.976 494.301,197.046 480.073,213.188L411.636,293.281C410.107,294.438 405.006,303.247 394.178,303.247C383.379,303.247 378.868,294.439 377.325,293.296L308.297,213.188C294.47,197.046 277.173,188.976 256.418,188.976ZM682.904,188.983C666.763,188.983 652.926,194.748 641.404,206.271C630.261,217.413 624.691,231.054 624.691,247.196C624.691,263.338 630.261,277.174 641.404,288.697C652.926,299.839 666.763,305.41 682.904,305.41C699.046,305.41 712.88,299.839 724.412,288.697C735.935,277.174 741.693,263.338 741.693,247.196C741.693,231.054 735.935,217.413 724.412,206.271C712.88,194.748 699.046,188.983 682.904,188.983ZM1327.58,193.013C1311.45,193.013 1297.62,198.779 1286.1,210.302C1274.96,221.444 1269.38,235.08 1269.38,251.212L1269.38,519.72C1269.38,535.861 1274.96,549.697 1286.1,561.22C1297.62,572.362 1311.45,577.933 1327.58,577.933C1343.73,577.933 1357.55,572.362 1369.08,561.22C1380.6,549.697 1386.37,535.867 1386.37,519.734C1386.37,508.211 1387.9,502.453 1390.96,502.453C1392.51,502.453 1394.05,503.023 1395.58,504.166L1453.2,560.061C1464.72,571.203 1478.36,576.774 1494.11,576.774C1510.62,576.774 1524.45,571.009 1535.6,559.486C1546.74,547.572 1552.31,533.936 1552.31,518.575C1552.31,502.053 1546.36,488.029 1534.45,476.506C1508.32,450.765 1494.69,437.517 1493.54,436.755C1488.54,431.755 1488.73,427.53 1494.11,424.073L1495.27,423.497L1495.27,422.929L1531.57,399.875C1548.85,388.352 1557.5,372.026 1557.5,350.904C1557.5,339.381 1554.42,328.622 1548.28,318.623C1536.76,301.339 1520.43,292.691 1499.3,292.691C1487.78,292.691 1477.02,295.768 1467.04,301.911C1422.48,331.499 1399.42,346.678 1397.88,347.449C1395.2,349.363 1392.7,349.738 1390.4,348.586C1387.7,347.434 1386.35,344.939 1386.35,341.101L1386.35,251.212C1386.35,235.08 1380.59,221.444 1369.07,210.302C1357.55,198.779 1343.72,193.013 1327.58,193.013ZM1716.37,291.738C1676.42,291.738 1642.24,305.949 1613.81,334.376C1585.76,362.422 1571.74,396.227 1571.74,435.795C1571.74,475.745 1585.76,509.932 1613.81,538.359C1642.24,566.404 1676.42,580.428 1716.37,580.428C1755.94,580.428 1789.94,566.404 1818.37,538.359C1827.2,529.521 1831.62,518.773 1831.62,506.107C1831.62,493.423 1827.2,482.664 1818.37,473.827C1809.53,464.999 1798.77,460.584 1786.11,460.584C1773.42,460.584 1762.66,464.999 1753.83,473.827C1743.46,484.588 1730.97,489.963 1716.37,489.963C1701.4,489.963 1688.53,484.78 1677.77,474.41C1667.39,463.649 1662.2,450.775 1662.2,435.795C1662.2,421.206 1667.59,408.72 1678.35,398.34C1683.73,392.578 1690.26,388.74 1697.93,386.817C1699.87,386.436 1701.4,386.623 1702.55,387.385C1703.32,388.547 1702.93,389.702 1701.39,390.854L1689.87,402.953C1681.03,411.791 1676.61,422.359 1676.61,434.644C1676.61,447.319 1680.45,457.497 1688.13,465.182C1695.81,472.868 1706.57,476.705 1720.41,476.705C1730.01,476.705 1739.61,471.91 1749.21,462.311L1816.06,396.044C1824.9,387.197 1829.32,376.436 1829.32,363.77C1829.32,351.086 1824.9,340.332 1816.06,331.504C1789.17,304.992 1755.94,291.738 1716.37,291.738ZM877.977,292.668C841.947,292.194 813.839,301.679 793.662,321.133C775.996,338.036 767.168,359.358 767.168,385.089C767.549,417.363 780.035,441.565 804.624,457.697C811.918,462.687 820.941,466.72 831.693,469.796C837.083,471.72 846.111,474.02 858.777,476.705C869.919,479.391 882.023,481.886 895.088,484.191C897.774,484.962 898.924,486.312 898.543,488.236C898.543,490.541 897.58,491.691 895.657,491.691C890.667,492.072 886.059,492.266 881.831,492.266C850.328,488.81 829.001,485.927 817.859,483.622C814.793,482.851 811.535,482.463 808.078,482.463C796.165,482.463 785.787,486.884 776.949,495.721C768.502,504.178 764.282,514.551 764.282,526.836C764.282,536.825 767.352,545.854 773.494,553.92C780.027,561.986 788.486,567.169 798.866,569.473C831.13,576.778 860.317,580.428 886.429,580.428C922.16,580.428 950.013,570.825 969.992,551.617C987.277,535.094 995.925,513.775 995.925,487.653C995.925,455.388 983.626,431.187 959.037,415.045C945.972,406.598 927.915,400.45 904.869,396.612C891.042,393.927 879.518,391.427 870.3,389.112L870.3,389.127C867.605,388.356 866.067,386.818 865.686,384.513C865.686,382.59 867.224,381.44 870.3,381.059C873.757,380.678 877.415,380.678 881.262,381.059C913.146,384.135 934.652,386.823 945.794,389.127C948.861,389.889 951.931,390.271 955.007,390.271C967.301,390.271 977.674,386.051 986.121,377.604C994.959,368.767 999.379,358.393 999.379,346.49C999.379,336.109 996.109,326.894 989.576,318.837C983.043,310.761 974.79,305.566 964.81,303.261C938.298,297.5 911.788,294.042 885.285,292.89C882.813,292.77 880.379,292.7 877.977,292.668ZM1128.73,292.668C1092.7,292.194 1064.59,301.679 1044.42,321.133C1026.75,338.036 1017.92,359.358 1017.92,385.089C1018.3,417.363 1030.79,441.565 1055.38,457.697C1062.67,462.687 1071.7,466.72 1082.46,469.796C1087.84,471.72 1096.86,474.02 1109.54,476.705C1120.68,479.391 1132.79,481.886 1145.84,484.191C1148.53,484.962 1149.68,486.312 1149.3,488.236C1149.3,490.541 1148.34,491.691 1146.41,491.691C1141.42,492.072 1136.81,492.266 1132.59,492.266C1101.09,488.81 1079.77,485.927 1068.63,483.622C1065.55,482.851 1062.29,482.463 1058.83,482.463C1046.92,482.463 1036.55,486.884 1027.72,495.721C1019.26,504.178 1015.04,514.551 1015.04,526.836C1015.04,536.825 1018.11,545.854 1024.26,553.92C1030.79,561.986 1039.24,567.169 1049.62,569.473C1081.88,576.778 1111.08,580.428 1137.2,580.428C1172.92,580.428 1200.77,570.825 1220.75,551.617C1238.03,535.094 1246.68,513.775 1246.68,487.653C1246.68,455.388 1234.39,431.187 1209.81,415.045C1196.74,406.598 1178.68,400.45 1155.64,396.612C1141.81,393.927 1130.29,391.427 1121.07,389.112L1121.05,389.127C1118.37,388.356 1116.84,386.818 1116.45,384.513C1116.45,382.59 1117.99,381.44 1121.05,381.059C1124.52,380.678 1128.17,380.678 1132.01,381.059C1163.89,384.135 1185.41,386.823 1196.55,389.127C1199.62,389.889 1202.69,390.271 1205.76,390.271C1218.06,390.271 1228.43,386.051 1236.89,377.604C1245.72,368.767 1250.13,358.393 1250.13,346.49C1250.13,336.109 1246.87,326.894 1240.35,318.837C1233.81,310.761 1225.55,305.566 1215.56,303.261C1189.06,297.5 1162.55,294.042 1136.04,292.89C1133.57,292.77 1131.13,292.7 1128.73,292.668ZM1910.17,296.736C1894.04,296.736 1880.21,302.501 1868.69,314.024C1857.55,325.157 1851.98,338.793 1851.98,354.934L1851.98,435.028C1851.98,473.825 1865.8,507.05 1893.45,534.705C1921.12,562.36 1954.36,576.191 1993.15,576.191C2000.84,576.191 2007.95,575.614 2014.48,574.471C2018.32,573.699 2021,574.469 2022.53,576.774C2023.69,578.307 2023.3,580.42 2021.39,583.115C2016.39,590.029 2005.82,593.486 1989.69,593.486C1983.55,593.486 1975.68,591.949 1966.07,588.873C1956.47,585.797 1948.98,584.259 1943.6,584.259C1920.93,584.259 1904.99,594.638 1895.77,615.388C1892.32,622.302 1890.58,629.598 1890.58,637.283C1890.58,659.948 1900.77,675.892 1921.13,685.11C1941.49,694.709 1964.34,699.505 1989.69,699.505C2033.49,699.505 2068.25,686.639 2093.98,660.898C2120.11,635.166 2133.18,600.784 2133.18,557.758L2133.18,452.308C2133.94,446.157 2134.32,440.399 2134.32,435.028L2134.32,354.934C2134.32,338.802 2128.57,325.166 2117.04,314.024C2105.9,302.501 2092.27,296.736 2076.13,296.736C2059.99,296.736 2046.16,302.501 2034.63,314.024C2023.11,325.157 2017.35,338.793 2017.35,354.934L2017.35,435.028C2017.35,441.551 2015.04,447.309 2010.43,452.308C2005.83,456.918 2000.07,459.225 1993.15,459.225C1986.62,459.225 1980.86,456.918 1975.87,452.308C1971.26,447.309 1968.95,441.551 1968.95,435.028L1968.95,354.934C1968.95,338.802 1963.19,325.166 1951.67,314.024C1940.14,302.501 1926.3,296.736 1910.17,296.736ZM683.473,316.947C667.331,316.947 653.495,322.713 641.972,334.236C630.449,345.768 624.691,359.602 624.691,375.744L624.691,518.118C624.691,534.259 630.449,548.095 641.972,559.618C653.504,570.761 667.341,576.331 683.473,576.331C699.624,576.331 713.27,570.761 724.412,559.618C735.935,548.095 741.693,534.259 741.693,518.118L741.693,375.744C741.693,359.593 735.935,345.759 724.412,334.236C713.261,322.713 699.614,316.947 683.473,316.947Z" style="fill:white;fill-rule:nonzero;"/>
+    </g>
+</svg>
diff --git a/packages/frontend/assets/remove.png b/packages/frontend/assets/remove.png
new file mode 100644
index 0000000000..c2e222a0fc
Binary files /dev/null and b/packages/frontend/assets/remove.png differ
diff --git a/packages/frontend/assets/sounds/aisha/1.mp3 b/packages/frontend/assets/sounds/aisha/1.mp3
new file mode 100644
index 0000000000..d8e9a2f265
Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/1.mp3 differ
diff --git a/packages/frontend/assets/sounds/aisha/2.mp3 b/packages/frontend/assets/sounds/aisha/2.mp3
new file mode 100644
index 0000000000..477c2eba43
Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/2.mp3 differ
diff --git a/packages/frontend/assets/sounds/aisha/3.mp3 b/packages/frontend/assets/sounds/aisha/3.mp3
new file mode 100644
index 0000000000..fe0d8063df
Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/3.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3
new file mode 100644
index 0000000000..616b506c4f
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3
new file mode 100644
index 0000000000..33c2837620
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3
new file mode 100644
index 0000000000..1791f26573
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3
new file mode 100644
index 0000000000..5f8bf468e5
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3
new file mode 100644
index 0000000000..dabe754b5b
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3
new file mode 100644
index 0000000000..57ecb01bda
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 differ
diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3
new file mode 100644
index 0000000000..6ba317deb1
Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/down.mp3 b/packages/frontend/assets/sounds/syuilo/down.mp3
new file mode 100644
index 0000000000..4cd421139d
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/down.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/kick.mp3 b/packages/frontend/assets/sounds/syuilo/kick.mp3
new file mode 100644
index 0000000000..4e0e72091c
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/kick.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3
new file mode 100644
index 0000000000..babf1fce60
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 b/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3
new file mode 100644
index 0000000000..25e2c46a64
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/pirori.mp3 b/packages/frontend/assets/sounds/syuilo/pirori.mp3
new file mode 100644
index 0000000000..a745415ac0
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/poi1.mp3 b/packages/frontend/assets/sounds/syuilo/poi1.mp3
new file mode 100644
index 0000000000..59dae90965
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/poi1.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/poi2.mp3 b/packages/frontend/assets/sounds/syuilo/poi2.mp3
new file mode 100644
index 0000000000..a65c653891
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/poi2.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/pope1.mp3 b/packages/frontend/assets/sounds/syuilo/pope1.mp3
new file mode 100644
index 0000000000..d6f53cfacc
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pope1.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/pope2.mp3 b/packages/frontend/assets/sounds/syuilo/pope2.mp3
new file mode 100644
index 0000000000..fe5d95e292
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pope2.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/popo.mp3 b/packages/frontend/assets/sounds/syuilo/popo.mp3
new file mode 100644
index 0000000000..a2a1605bbb
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/popo.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 b/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3
new file mode 100644
index 0000000000..99e0c437fe
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/reverved.mp3 b/packages/frontend/assets/sounds/syuilo/reverved.mp3
new file mode 100644
index 0000000000..47588ef270
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/reverved.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 b/packages/frontend/assets/sounds/syuilo/ryukyu.mp3
new file mode 100644
index 0000000000..9e935e3f37
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/snare.mp3 b/packages/frontend/assets/sounds/syuilo/snare.mp3
new file mode 100644
index 0000000000..9244189c2d
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/snare.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/square-pico.mp3 b/packages/frontend/assets/sounds/syuilo/square-pico.mp3
new file mode 100644
index 0000000000..c4d8305ae7
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/square-pico.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/triple.mp3 b/packages/frontend/assets/sounds/syuilo/triple.mp3
new file mode 100644
index 0000000000..54ab974d46
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/triple.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/up.mp3 b/packages/frontend/assets/sounds/syuilo/up.mp3
new file mode 100644
index 0000000000..3f30867764
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/up.mp3 differ
diff --git a/packages/frontend/assets/sounds/syuilo/waon.mp3 b/packages/frontend/assets/sounds/syuilo/waon.mp3
new file mode 100644
index 0000000000..a4af473861
Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/waon.mp3 differ
diff --git a/packages/frontend/assets/tagcanvas.min.js b/packages/frontend/assets/tagcanvas.min.js
new file mode 100644
index 0000000000..bcee46e682
--- /dev/null
+++ b/packages/frontend/assets/tagcanvas.min.js
@@ -0,0 +1,21 @@
+/**
+ * Copyright (C) 2010-2021 Graham Breach
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+/**
+ * TagCanvas 2.11
+ * For more information, please contact <graham@goat1000.com>
+ */
+ (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a<e;++a)b.push(c[a]);return b.sort(d),b}function ai(a){for(var b=a.length-1,d,c;b;)c=~~(Math.random()*b),d=a[b],a[b]=a[c],a[c]=d,--b}function ag(){var a=window.AudioContext||window.webkitAudioContext;if(e=new a,!e){e='off';return}return e}function $(u,a,t,s,r,q,p){var j=s,h=r,i=t*.01,n=80*i,m=100*i,o=40*i,l=30*i,c=l/2,b=j+n,f=b-o,k=h+m,d=k-l,g=h+l,e=h+m/2;if(a.setTransform(1,0,0,1,0,0),a.setLineDash([]),a.globalAlpha=1,a.strokeStyle=p,a.lineWidth=q,a.lineJoin='round',a.beginPath(),a.moveTo(f,g),a.lineTo(f,d),a.moveTo(b,k),a.lineTo(f,d),a.lineTo(j,d),a.lineTo(j,g),a.lineTo(f,g),a.lineTo(b,h),u){a.lineTo(b,g),a.moveTo(b,d),a.lineTo(b,k),a.moveTo(b-c,e-c),a.lineTo(b+c,e+c),a.moveTo(b+c,e-c),a.lineTo(b-c,e+c),a.stroke();return}a.closePath(),a.stroke()}function s(a,b,c){this.x=a,this.y=b,this.z=c}z=s.prototype,z.length=function(){return E(this.x*this.x+this.y*this.y+this.z*this.z)},z.dot=function(a){return this.x*a.x+this.y*a.y+this.z*a.z},z.cross=function(a){var b=this.y*a.z-this.z*a.y,c=this.z*a.x-this.x*a.z,d=this.x*a.y-this.y*a.x;return new s(b,c,d)},z.angle=function(b){var c=this.dot(b),a;return c==0?Math.PI/2:(a=c/(this.length()*b.length()),a>=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a<g;++a)b=a*h-1+h/2,c=E(1-b*b),d=a*i,e.push([l(d)*c*j,b*k,o(d)*c*m]);return e}function U(n,p,m,k,h,g){var b,f=[],i=2/n,j,a,d,c,e;j=Math.PI*(3-E(5)+(parseFloat(g)?parseFloat(g):0));for(a=0;a<n;++a)d=a*i-1+i/2,b=a*j,c=l(b),e=o(b),f.push(p?[d*m,c*k,e*h]:[c*m,d*k,e*h]);return f}function aa(k,e,f,h,i,j){var b,g=[],m=Math.PI*2/e,a,c,d;for(a=0;a<e;++a)b=a*m,c=l(b),d=o(b),g.push(k?[j*f,c*h,d*i]:[c*f,j*h,d*i]);return g}function ax(a,b,c,d,e){return U(a,0,b,c,d,e)}function aH(a,b,c,d,e){return U(a,1,b,c,d,e)}function aG(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(0,b,c,d,e,a)}function aF(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(1,b,c,d,e,a)}function av(b){var a=new Image;a.onload=function(){var c=a.width/2,d=a.height/2;b.centreFunc=function(b,g,h,e,f){b.setTransform(1,0,0,1,0,0),b.globalAlpha=1,b.drawImage(a,e-c,f-d)}},a.src=b.centreImage}function aE(a,c){var b=a,d,e,f=(c*1).toPrecision(3)+')';return a[0]==='#'?(I[a]||(a.length===4?I[a]='rgba('+R[a[1]]+R[a[2]]+R[a[3]]:I[a]='rgba('+D[a.substr(1,2)]+D[a.substr(3,2)]+D[a.substr(5,2)]),b=I[a]+f):a.substr(0,4)==='rgb('||a.substr(0,4)==='hsl('?b=a.replace('(','a(').replace(')',','+f):(a.substr(0,5)==='rgba('||a.substr(0,5)==='hsla(')&&(d=a.lastIndexOf(',')+1,e=a.indexOf(')'),c*=parseFloat(a.substring(d,e)),b=a.substr(0,d)+c.toPrecision(3)+')'),b}function k(b,d){if(window.G_vmlCanvasManager)return null;var a=c.createElement('canvas');return a.width=b,a.height=d,a}function aD(){var b=k(3,3),a,c;return!!b&&(a=b.getContext('2d'),a.strokeStyle='#000',a.shadowColor='#fff',a.shadowBlur=3,a.globalAlpha=0,a.strokeRect(2,2,2,2),a.globalAlpha=1,c=a.getImageData(2,2,1,1),b=null,c.data[0]>0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b<d;++b)aC(f,l,b,e[b])}return j=g(h(j||0,d-1),0),c=f.getImageData(~~((l-1)*m),j,1,1).data,'rgba('+c[0]+','+c[1]+','+c[2]+','+c[3]/255+')'}function Y(b,i,q,k,o,n,h,d,a,g,f,l){var m=o+(d||0)+(a.length&&a[0]<0?p(a[0]):0),j=n+(d||0)+(a.length&&a[1]<0?p(a[1]):0),c,e;b.font=i,b.textBaseline='top',b.fillStyle=q,h&&(b.shadowColor=h),d&&(b.shadowBlur=d),a.length&&(b.shadowOffsetX=a[0],b.shadowOffsetY=a[1]);for(c=0;c<k.length;++c)e=0,f&&('right'==l?e=g-f[c]:'centre'==l&&(e=(g-f[c])/2)),b.fillText(k[c],m+e,j),j+=parseInt(i)}function y(d,a,b,f,e,c,g){c?(d.beginPath(),d.moveTo(a,b+e-c),d.arcTo(a,b,a+c,b,c),d.arcTo(a+f,b,a+f,b+c,c),d.arcTo(a+f,b+e,a+f-c,b+e,c),d.arcTo(a,b+e,a,b+e-c,c),d.closePath(),d[g?'stroke':'fill']()):d[g?'strokeRect':'fillRect'](a,b,f,e)}function O(a,b,c,d,e,f,g,h,i){this.strings=a,this.font=b,this.width=c,this.height=d,this.maxWidth=e,this.stringWidths=f,this.align=g,this.valign=h,this.scale=i}M=O.prototype,M.SetImage=function(a,b,c,d,e,f,g,h){this.image=a,this.iwidth=b*this.scale,this.iheight=c*this.scale,this.ipos=d,this.ipad=e*this.scale,this.iscale=h,this.ialign=f,this.ivalign=g},M.Align=function(c,d,a){var b=0;return a=='right'||a=='bottom'?b=d-c:a!='left'&&a!='top'&&(b=(d-c)/2),b},M.Create=function(G,D,F,b,A,m,q,j,E){var o,e,f,a,l,s,i,u,v,r,w,n,c,d,x,B=p(q[0]),C=p(q[1]),t,z;return j=g(j,B+m,C+m),l=2*(j+b),i=2*(j+b),e=this.width+l,f=this.height+i,v=r=j+b,this.image&&(w=n=j+b,c=this.iwidth,d=this.iheight,this.ipos=='top'||this.ipos=='bottom'?(c<this.width?w+=this.Align(c,this.width,this.ialign):v+=this.Align(this.width,c,this.align),this.ipos=='top'?r+=d+this.ipad:n+=this.height+this.ipad,e=g(e,c+l),f+=d+this.ipad):(d<this.height?n+=this.Align(d,this.height,this.ivalign):r+=this.Align(this.height,d,this.valign),this.ipos=='right'?w+=this.width+this.ipad:v+=c+this.ipad,e+=c+this.ipad,f=g(f,d+i))),o=k(e,f),!o?null:(l=i=b/2,s=e-b,u=f-b,x=h(E,s/2,u/2),a=o.getContext('2d'),D&&(a.fillStyle=D,y(a,l,i,s,u,x)),b&&(a.strokeStyle=F,a.lineWidth=b,y(a,l,i,s,u,x,!0)),(m||B||C)&&(t=k(e,f),t&&(z=a,a=t.getContext('2d'))),Y(a,this.font,G,this.strings,v,r,0,0,[],this.maxWidth,this.stringWidths,this.align),this.image&&a.drawImage(this.image,w,n,c,d),z&&(a=z,A&&(a.shadowColor=A),m&&(a.shadowBlur=m),a.shadowOffsetX=q[0],a.shadowOffsetY=q[1],a.drawImage(t,0,0)),o)};function H(a,c,d){var b=k(c,d),e;return b?(e=b.getContext('2d'),e.drawImage(a,(c-a.width)/2,(d-a.height)/2),b):null}function S(e,b,c){var a=k(b,c),d;return a?(d=a.getContext('2d'),d.drawImage(e,0,0,b,c),a):null}function W(n,u,t,e,s,c,v,d,r,w){var g=u+(2*d+c)*e,f=t+(2*d+c)*e,l=k(g,f),b,i,q,m,j,o,a,p;return l?(c*=e,r*=e,i=q=c/2,m=g-c,j=f-c,d=d*e+i,b=l.getContext('2d'),p=h(r,m/2,j/2),s&&(b.fillStyle=s,y(b,i,q,m,j,p)),c&&(b.strokeStyle=v,b.lineWidth=c,y(b,i,q,m,j,p,!0)),w?(o=k(g,f),a=o.getContext('2d'),a.drawImage(n,d,d,u,t),a.globalCompositeOperation='source-in',a.fillStyle=v,a.fillRect(0,0,g,f),a.globalCompositeOperation='destination-over',a.drawImage(l,0,0),a.globalCompositeOperation='source-over',b.drawImage(o,0,0)):b.drawImage(n,d,d,n.width,n.height),{image:l,width:g/e,height:f/e}):null}function at(l,f,c,d,j){var e,a,b=parseFloat(f),i=g(c,d);return e=k(c,d),!e?null:(f.indexOf('%')>0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d<f;++d)for(b=0;b<e;++b)n=(d*e+b)*4,i.data[n+1]>0&&(b<a.min.x&&(a.min.x=b),b>a.max.x&&(a.max.x=b),d<a.min.y&&(a.min.y=d),d>a.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f<e.naturalWidth||d<e.naturalHeight?(c=S(g,f,d),c&&(a.fimage=c)):(f=a.iw,d=a.ih,h=1),parseFloat(b.imageRadius)&&(a.image=a.fimage=g=at(a.image,b.imageRadius,f,d,h)),a.HasText()||(b.shadow&&(c=ao(a.image,f,d,h,b.shadow,b.shadowBlur,b.shadowOffset),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),(b.bgColour||b.bgOutlineThickness)&&(k=b.bgColour=='tag'?i(a.a,'background-color'):b.bgColour,m=b.bgOutline=='tag'?i(a.a,'color'):b.bgOutline||b.textColour,f=a.fimage.width,d=a.fimage.height,b.outlineMethod=='colour'&&(c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,a.outline.colour,b.padding,b.bgRadius,1),c&&(a.oimage=c.image)),c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,m,b.padding,b.bgRadius),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),b.outlineMethod=='size'&&(b.outlineIncrease>0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;a<this.text.length;++a)if(this.text[a].length)return!1;return!0},F.Lines=function(c){var e=c?1:0,b,d,a;c=c||this.e,b=c.childNodes,d=b.length;for(a=0;a<d;++a)b[a].nodeName=='BR'?(this.text.push(this.line.join(' ')),this.br=1):b[a].nodeType==3?this.br?(this.line=[b[a].nodeValue],this.br=0):this.line.push(b[a].nodeValue):this.Lines(b[a]);return e||this.br||this.text.push(this.line.join(' ')),this.text},F.SplitWidth=function(h,e,f,g){var c,b,a,d=[];e.font=g+'px '+f;for(c=0;c<this.text.length;++c){a=this.text[c].split(/\s+/),this.line=[a[0]];for(b=1;b<a.length;++b)e.measureText(this.line.join(' ')+' '+a[b]).width>h?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a<e;++a)this.line_widths[a]=c=d.measureText(this.text[a]).width,b=g(b,c);return b},d.Measure=function(e,a){var f=ae(this.text,this.textFont,this.textHeight),b,k,h,i,g,l,j,c,d;j=f?f.max.y+f.min.y:this.textHeight,e.font=this.font=this.textHeight+'px '+this.textFont,l=this.MeasureText(e),a.txtOpt&&(b=a.txtScale,k=b*this.textHeight,h=k+'px '+this.textFont,i=[b*a.shadowOffset[0],b*a.shadowOffset[1]],e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*j+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw,this.ih,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),c=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),a.outlineMethod=='colour'?this.oimage=d.Create(this.outline.colour,this.bgColour,this.outline.colour,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius):a.outlineMethod=='size'&&(f=ae(this.text,this.textFont,this.textHeight+a.outlineIncrease),k=f.max.y+f.min.y,h=b*(this.textHeight+a.outlineIncrease)+'px '+this.textFont,e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*k+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw+a.outlineIncrease,this.ih+a.outlineIncrease,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),this.oimage=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),this.oscale=this.oimage.width/c.width,a.outlineIncrease>0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a<f;++a)d=e[a]||'size','both'==d?(this.Weight(c[a],b.ctxt,b,'size',b.min_weight[a],b.max_weight[a],a),this.Weight(c[a],b.ctxt,b,'colour',b.min_weight[a],b.max_weight[a],a)):this.Weight(c[a],b.ctxt,b,d,b.min_weight[a],b.max_weight[a],a);this.Measure(b.ctxt,b)},d.Weight=function(b,i,a,d,f,h,e){b=isNaN(b)?1:b;var c=(b-f)/(h-f);'colour'==d?this.colour=L(a,c,e):'bgcolour'==d?this.bgColour=L(a,c,e):'bgoutline'==d?this.bgOutline=L(a,c,e):'outline'==d?this.outline.colour=L(a,c,e):'size'==d&&(a.weightSizeMin>0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b<this.text.length;++b)d=g,'right'==e.textAlign?d+=this.w/2-this.line_widths[b]:'centre'==e.textAlign?d-=this.line_widths[b]/2:d-=this.w/2,a.setTransform(c,0,0,c,c*d,c*f),a.fillText(this.text[b],0,0),f+=this.textHeight},d.DrawImage=function(b,i,k,l){var e=this.x,f=this.y,a=this.sc,j=l||this.fimage,c=this.w,d=this.h,g=this.alpha,h=this.shadow;b.globalAlpha=g,h&&this.SetShadowColour(b,h,g),e+=i/a-c/2,f+=k/a-d/2,b.setTransform(a,0,0,a,a*e,a*f),b.drawImage(j,0,0,c,d)},d.DrawImageIE=function(b,d,e){var c=this.fimage,a=this.sc,f=c.width=this.w*a,g=c.height=this.h*a,h=this.x*a+d-f/2,i=this.y*a+e-g/2;b.setTransform(1,0,0,1,0,0),b.globalAlpha=this.alpha,b.drawImage(c,h,i)},d.Calc=function(g,e){var a,b=this.tc,d=b.minBrightness,f=b.maxBrightness,c=b.max_radius;return a=g.xform(this.position),this.xformed=a,a=aI(b,a,b.stretchX,b.stretchY),this.x=a.x,this.y=a.y,this.z=a.z,this.sc=a.w,this.alpha=e*G(d+(f-d)*(c-this.z)/(2*c),0,1),this.xformed},d.UpdateActive=function(h,e,f){var a=this.outline,b=this.w,c=this.h,d=this.x-b/2,g=this.y-c/2;return a.Update(d,g,b,c,this.sc,this.z,e,f),a},d.CheckActive=function(a,d,e){var b=this.tc,c=this.UpdateActive(a,d,e);return c.Active(a,b.mx,b.my)?c:null},d.Clicked=function(f){var b=this.a,a=b.target,d=b.href,e;if(a!=''&&a!='_self'){if(self.frames[a])self.frames[a].document.location=d;else{try{if(top.frames[a]){top.frames[a].document.location=d;return}}catch(a){}window.open(d,a)}return}if(c.createEvent){if(e=c.createEvent('MouseEvents'),e.initMouseEvent('click',1,1,window,0,0,0,0,0,0,0,0,0,0,null),!b.dispatchEvent(e))return}else if(b.fireEvent)if(!b.fireEvent('onclick'))return;c.location=d},d.StopAudio=function(){this.audio&&this.playing&&this.audio.pause(),this.stopped=1,this.playing=0},d.PlayAudio=function(){if(e==='off'||this.tc.audioOff)return;if(!e&&!ag())return;var a=this.tc.audio,c=this.tc.gain,d='suspended',b;if(this.audio)if(this.track||(this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination)),a=this.audio,c=this.gain,!a.paused)return 1;if(a){if(e.state==d&&e.resume(),e.state==d)return;return c.gain.value=h(2,g(0,this.tc.audioVolume*1)),a.currentTime=0,this.stopped=0,b=a.play(),b!==void 0&&b.then(a=>{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d<l.length;++d)i[l[d]]=b[l[d]];throw b.parentNode.insertBefore(i,b),b.parentNode.removeChild(b),0}for(d in a.options)this[d]=k&&n(k[d])?k[d]:n(a[d])?a[d]:a.options[d];if(this.canvas=b,this.ctxt=b.getContext('2d'),this.z1=250/g(this.depth,.001),this.z2=this.z1/this.zoom,this.radius=h(b.height,b.width)*.0075,this.max_radius=100,this.max_weight=[],this.min_weight=[],this.textFont=this.textFont&&Q(this.textFont),this.textHeight*=1,this.imageRadius=this.imageRadius.toString(),this.pulsateTo=G(this.pulsateTo,0,1),this.minBrightness=G(this.minBrightness,0,1),this.maxBrightness=G(this.maxBrightness,this.minBrightness,1),this.ctxt.textBaseline='top',this.lx=(this.lock+'').indexOf('x')+1,this.ly=(this.lock+'').indexOf('y')+1,this.frozen=this.dx=this.dy=this.fixedAnim=this.touchState=0,this.fixedAlpha=1,this.source=o||f,this.repeatTags=h(64,~~this.repeatTags),this.minTags=h(200,~~this.minTags),~~this.scrollPause>0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;d<j[f].length;++d)i=j[f][d],t(i[0],i[1],i[2]?i[2]:b)}a.started||(a.NextFrame=window.requestAnimationFrame?az:aA,a.interval=this.interval,a.NextFrame(this.interval),a.started=1)}b=a.prototype,b.SourceElements=function(){return c.querySelectorAll?c.querySelectorAll('#'+this.source):[c.getElementById(this.source)]},b.HideTags=function(){var b=this.SourceElements(),a;for(a=0;a<b.length;++a)b[a].style.display='none'},b.GetTags=function(){var e=this.SourceElements(),c,f=[],a,b,d;for(d=0;d<=this.repeatTags;++d)for(a=0;a<e.length;++a){c=e[a].getElementsByTagName('a');for(b=0;b<c.length;++b)f.push(c[b])}return f},b.Message=function(j){var g=[],a,f,b=j.split(''),d,e,h,i;for(a=0;a<b.length;++a)b[a]!=' '&&(f=a-b.length/2,d=c.createElement('A'),d.href='#',d.innerText=b[a],h=100*o(f/9),i=-100*l(f/9),e=new J(this,b[a],d,[h,0,i],2,18,'#000','#fff',0,0,0,'monospace',2,b[a]),e.Init(),g.push(e));return g},b.AddAudio=function(b,c){if(e==='off')return;var a=b.getElementsByTagName('audio');a.length&&(c.SetAudio(a[0]),this.hasAudio=1)},b.CreateTag=function(b){var e,c,a,f,d,g,h,j,k=[0,0,0],l;if('text'!=this.imageMode)if(e=b.getElementsByTagName('img'),e.length)if(c=new Image,c.src=e[0].src,!this.imageMode)return a=new J(this,"",b,k,0,0),a.SetImage(c),A(c,e[0],e[1],a,this),this.AddAudio(b,a),a;if('image'!=this.imageMode&&(d=new V(b),f=d.Lines(),d.Empty()?d=null:(g=this.textFont||Q(i(b,'font-family')),this.splitWidth&&(f=d.SplitWidth(this.splitWidth,this.ctxt,g,this.textHeight)),h=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,j=this.bgOutline=='tag'?i(b,'color'):this.bgOutline)),d||c)return a=new J(this,f,b,k,2,this.textHeight+2,this.textColour||i(b,'color'),h,this.bgRadius,j,this.bgOutlineThickness,g,this.padding,d&&d.original),c?(a.SetImage(c),A(c,e[0],e[1],a,this)):a.Init(),this.AddAudio(b,a),a},b.UpdateTag=function(a,b){var c=this.textColour||i(b,'color'),d=this.textFont||Q(i(b,'font-family')),e=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,f=this.bgOutline=='tag'?i(b,'color'):this.bgOutline;a.a=b,a.title=b.title,(a.colour!=c||a.textFont!=d||a.bgColour!=e||a.bgOutline!=f)&&a.SetFont(d,c,e,f)},b.Weight=function(d){var f=d.length,c,b,a,e=[],g,h=this.weightFrom?this.weightFrom.split(/[, ]/):[null],i=h.length;for(b=0;b<f;++b){e[b]=[];for(a=0;a<i;++a)c=aj(d[b].a,h[a],this.textHeight),(!this.max_weight[a]||c>this.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||c<this.min_weight[a])&&(this.min_weight[a]=c),e[b][a]=c}for(a=0;a<i;++a)this.max_weight[a]>this.min_weight[a]&&(g=1);if(g)for(b=0;b<f;++b)d[b].SetWeight(e[b])},b.Load=function(){var c=this.GetTags(),b=[],d,k,l,h,i,j,f,a,e=[],m={sphere:aB,vcylinder:ax,hcylinder:aH,vring:aG,hring:aF};if(c.length){e.length=c.length;for(a=0;a<c.length;++a)e[a]=a;this.shuffleTags&&ai(e),h=100*this.radiusX,i=100*this.radiusY,j=100*this.radiusZ,this.max_radius=g(h,g(i,j));for(a=0;a<c.length;++a)k=this.CreateTag(c[e[a]]),k&&b.push(k);this.weight&&this.Weight(b,!0),this.shapeArgs?this.shapeArgs[0]=b.length:(l=this.shape.toString().split(/[(),]/),d=l.shift(),typeof window[d]=='function'?this.shape=window[d]:this.shape=m[d]||m.sphere,this.shapeArgs=[b.length,h,i,j].concat(l)),f=this.shape.apply(this,this.shapeArgs),this.listLength=b.length;for(a=0;a<b.length;++a)b[a].position=new s(f[a][0],f[a][1],f[a][2])}this.noTagsMessage&&!b.length&&(a=this.imageMode&&this.imageMode!='both'?this.imageMode+' ':'',b=this.Message('No '+a+'tags')),this.taglist=b},b.Update=function(){var e=this.GetTags(),d=[],j=this.taglist,k,f=[],c=[],h,i,g,a,b;if(!this.shapeArgs)return this.Load();if(e.length){g=this.listLength=e.length,i=j.length;for(a=0;a<i;++a)d.push(j[a]),c.push(a);for(a=0;a<g;++a){for(b=0,k=0;b<i;++b)j[b].EqualTo(e[a])&&(this.UpdateTag(d[b],e[a]),k=c[b]=-1);k||f.push(a)}for(a=0,b=0;a<i;++a)c[b]==-1?c.splice(b,1):++b;if(c.length){for(ai(c);c.length&&f.length;)a=c.shift(),b=f.shift(),d[a]=this.CreateTag(e[b]);for(c.sort(function(a,b){return a-b});c.length;)d.splice(c.pop(),1)}for(b=d.length/(f.length+1),a=0;f.length;)d.splice(af(++a*b),0,this.CreateTag(e[f.shift()]));this.shapeArgs[0]=g=d.length,h=this.shape.apply(this,this.shapeArgs);for(a=0;a<g;++a)d[a].position=new s(h[a][0],h[a][1],h[a][2]);this.weight&&this.Weight(d)}this.taglist=d},b.SetShadow=function(a){a.shadowBlur=this.shadowBlur,a.shadowOffsetX=this.shadowOffset[0],a.shadowOffsetY=this.shadowOffset[1]},b.LoadAudio=function(){if(!e&&!ag())return;this.audio=c.createElement('audio'),this.audio.src=this.activeAudio,this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination),this.hasAudio=1,P=function(a){e.resume(),c.removeEventListener('click',P)},c.addEventListener('click',P)},b.ShowAudioIcon=function(){var a=this.audioIconSize,c=this.canvas,d=this.ctxt,k=c.width-a-3,f=c.height-a-3,g=this.audioIconThickness,h='#000',i='#fff',j=this.audioIconDark,b=this.audioOff,l='suspended';if(!e)return;b||(b=e.state===l),this.audioIcon&&this.hasAudio&&($(b,d,a,k,f,g+1,j?i:h),$(b,d,a,k,f,g,j?h:i))},b.CheckAudioIcon=function(){var a=this.audioIconSize,b=this.canvas,c=this.audioIconThickness/2,d=b.width-a-3-c,e=b.height-a-3-c;if(this.audioIcon&&this.mx>=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c<k;++c)e[c].Calc(this.transform,this.fixedAlpha);if(e=ak(e,function(a,b){return b.z-a.z}),n&&this.fixedAnim.active)b=this.fixedAnim.tag.UpdateActive(d,h,g);else if(this.active=null,this.CheckAudioIcon())m='pointer';else{for(c=0;c<k;++c)f=this.mx>=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c<k;++c){if(!r&&e[c].z<=0){try{this.centreFunc(d,i,j,h,g)}catch(a){alert(a),this.centreFunc=x}r=!0}b&&b.tag==e[c]&&b.PreDraw(d,e[c],h,g)||e[c].Draw(d,h,g),b&&b.tag==e[c]&&b.PostDraw(d)}this.freezeActive&&b?this.Freeze():(this.UnFreeze(),this.drawn=k==this.listLength),this.fixedCallback&&(this.fixedCallback(this,this.fixedCallbackTag),this.fixedCallback=null),n||this.Animate(i,j,p),b&&(b.LastDraw(d),b.tag!=t&&(this.currentAudio&&this.currentAudio!=b.tag&&this.currentAudio.StopAudio(),b.tag.PlayAudio()&&(this.currentAudio=b.tag)),m=this.activeCursor),l.style.cursor=m,this.Tooltip(b,this.taglist[o]),this.audioIcon&&this.ShowAudioIcon()},b.TooltipNone=function(){},b.TooltipNative=function(b,a){b?this.canvas.title=a&&a.title?a.title:'':this.canvas.title=this.ctitle},b.SetTTDiv=function(c,d){var a=this,b=a.ttdiv.style;c!=a.ttdiv.innerHTML&&(b.display='none'),a.ttdiv.innerHTML=c,d&&(d.title=a.ttdiv.innerHTML),b.display=='none'&&!a.tttimer&&(a.tttimer=setTimeout(function(){var c=X(a.canvas.id);b.display='block',b.left=c.x+a.mx+'px',b.top=c.y+a.my+24+'px',a.tttimer=null},a.tooltipDelay))},b.TooltipDiv=function(b,a){b&&a&&a.title?this.SetTTDiv(a.title,a):!b&&this.mx!=-1&&this.my!=-1&&this.ctitle.length?this.SetTTDiv(this.ctitle):this.ttdiv.style.display='none'},b.Transform=function(c,a,b){if(a||b){var d=o(a),e=l(a),f=o(b),g=l(b),h=new m([g,0,f,0,1,0,-f,0,g]),i=new m([1,0,0,0,e,-d,0,d,e]);c.transform=c.transform.mul(h.mul(i))}},b.AnimateFixed=function(){var a,b,c,d,e;return!!(this.fadeIn&&(b=q()-this.startTime,b>=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&d<g&&e<h?(b=a.maxSpeed,c=a.reverse?-1:1,a.lx||(a.yaw=(d*2*b/g-b)*c*f),a.ly||(a.pitch=(e*2*b/h-b)*-c*f),a.initial=null):a.initial||(a.frozen&&!a.freezeDecel?a.yaw=a.pitch=0:a.Decel(a)),this.Transform(a,a.pitch,a.yaw)},b.AnimateDrag=function(d,e,c){var a=this,b=100*c*a.maxSpeed/a.max_radius/a.zoom;a.dx||a.dy?(a.lx||(a.yaw=a.dx*b/a.stretchX),a.ly||(a.pitch=a.dy*-b/a.stretchY),a.dx=a.dy=0,a.initial=null):a.initial||a.Decel(a),this.Transform(a,a.pitch,a.yaw)},b.Freeze=function(){this.frozen||(this.preFreeze=[this.yaw,this.pitch],this.frozen=1,this.drawn=0)},b.UnFreeze=function(){this.frozen&&(this.yaw=this.preFreeze[0],this.pitch=this.preFreeze[1],this.frozen=0)},b.Decel=function(a){var b=a.minSpeed,c=p(a.yaw),d=p(a.pitch);!a.lx&&c>b&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b<this.taglist.length;++b)if(this.taglist[b].a[c]==d)return this.taglist[b]},b.RotateTag=function(a,h,i,j,f,g){var b=a.Calc(this.transform,1),c=new s(b.x,b.y,b.z),d=ay(i,h),e=c.angle(d),k=c.cross(d).unit();e==0?(this.fixedCallbackTag=a,this.fixedCallback=f):this.fixedAnim={angle:-e,axis:k,t:j,t0:q(),cb:f,tag:a,active:g}},b.TagToFront=function(a,b,c,d){this.RotateTag(a,0,0,b,c,d)},b.Volume=function(a){this.audioVolume=a*1},a.Start=function(b,c,d){a.Delete(b),a.tc[b]=new a(b,c,d)};function N(c,b){a.tc[b]&&a.tc[b][c]()}a.Linear=function(a,b){return b/a},a.Smooth=function(a,b){return.5-l(b*Math.PI/a)/2},a.Pause=function(a){N('Pause',a)},a.Resume=function(a){N('Resume',a)},a.Reload=function(a){N('Load',a)},a.Update=function(a){N('Update',a)},a.SetSpeed=function(c,b){return!!(B(b)&&a.tc[c]&&!isNaN(b[0])&&!isNaN(b[1]))&&(a.tc[c].SetSpeed(b),!0)},a.TagToFront=function(c,b){return!!B(b)&&(b.lat=b.lng=0,a.RotateTag(c,b))},a.RotateTag=function(c,b){if(B(b)&&a.tc[c]){isNaN(b.time)&&(b.time=500);var d=a.tc[c].FindTag(b);if(d)return a.tc[c].RotateTag(d,b.lat,b.lng,b.time,b.callback,b.active),!0}return!1},a.Delete=function(b){var d,e;if(j[b])if(e=c.getElementById(b),e)for(d=0;d<j[b].length;++d)am(j[b][d][0],j[b][d][1],e);delete j[b],delete a.tc[b]},a.tc={},a.options={z1:2e4,z2:2e4,z0:2e-4,freezeActive:!1,freezeDecel:!1,activeCursor:'pointer',pulsateTo:1,pulsateTime:3,reverse:!1,depth:.5,maxSpeed:.05,minSpeed:0,decel:.95,interval:20,minBrightness:.1,maxBrightness:1,outlineColour:'#ffff99',outlineThickness:2,outlineOffset:5,outlineMethod:'outline',outlineRadius:0,textColour:'#ff99ff',textHeight:15,textFont:'Helvetica, Arial, sans-serif',shadow:'#000',shadowBlur:0,shadowOffset:[0,0],initial:null,hideTags:!0,zoom:1,weight:!1,weightMode:'size',weightFrom:null,weightSize:1,weightSizeMin:null,weightSizeMax:null,weightGradient:{0:'#f00',0.33:'#ff0',0.66:'#0f0',1:'#00f'},txtOpt:!0,txtScale:2,frontSelect:!1,wheelZoom:!0,zoomMin:.3,zoomMax:3,zoomStep:.05,shape:'sphere',lock:null,tooltip:null,tooltipDelay:300,tooltipClass:'tctooltip',radiusX:1,radiusY:1,radiusZ:1,stretchX:1,stretchY:1,offsetX:0,offsetY:0,shuffleTags:!1,noSelect:!1,noMouse:!1,imageScale:1,paused:!1,dragControl:!1,dragThreshold:4,centreFunc:x,splitWidth:0,animTiming:'Smooth',clickToFront:!1,fadeIn:0,padding:0,bgColour:null,bgRadius:0,bgOutline:null,bgOutlineThickness:0,outlineIncrease:4,textAlign:'centre',textVAlign:'middle',imageMode:null,imagePosition:null,imagePadding:2,imageAlign:'centre',imageVAlign:'middle',noTagsMessage:!0,centreImage:null,pinchZoom:!1,repeatTags:0,minTags:0,imageRadius:0,scrollPause:!1,outlineDash:0,outlineDashSpace:0,outlineDashSpeed:1,activeAudio:'',audioVolume:1,audioIcon:1,audioIconSize:20,audioIconThickness:2,audioIconDark:0,altImage:0};for(r in a.options)a[r]=a.options[r];window.TagCanvas=a,t('load',function(){a.loaded=1},window)})()
diff --git a/packages/frontend/assets/unread.svg b/packages/frontend/assets/unread.svg
new file mode 100644
index 0000000000..8c3cc9f475
--- /dev/null
+++ b/packages/frontend/assets/unread.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+	 y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
+<circle fill="#3AA2DC" cx="16.5" cy="16.5" r="6"/>
+</svg>
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
new file mode 100644
index 0000000000..c23adf7c70
--- /dev/null
+++ b/packages/frontend/package.json
@@ -0,0 +1,94 @@
+{
+	"name": "frontend",
+	"private": true,
+	"scripts": {
+		"watch": "vite",
+		"build": "vite build",
+		"lint": "vue-tsc --noEmit && eslint --quiet \"src/**/*.{ts,vue}\""
+	},
+	"dependencies": {
+		"@discordapp/twemoji": "14.0.2",
+		"@rollup/plugin-alias": "4.0.2",
+		"@rollup/plugin-json": "6.0.0",
+		"@rollup/pluginutils": "5.0.2",
+		"@syuilo/aiscript": "0.11.1",
+		"@tabler/icons": "^1.118.0",
+		"@vitejs/plugin-vue": "4.0.0",
+		"@vue/compiler-sfc": "3.2.45",
+		"autobind-decorator": "2.4.0",
+		"autosize": "5.0.2",
+		"blurhash": "2.0.4",
+		"broadcast-channel": "4.18.1",
+		"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
+		"chart.js": "4.1.1",
+		"chartjs-adapter-date-fns": "3.0.0",
+		"chartjs-chart-matrix": "^1.3.0",
+		"chartjs-plugin-gradient": "0.6.1",
+		"chartjs-plugin-zoom": "2.0.0",
+		"compare-versions": "5.0.1",
+		"cropperjs": "2.0.0-beta",
+		"date-fns": "2.29.3",
+		"escape-regexp": "0.0.1",
+		"eventemitter3": "5.0.0",
+		"idb-keyval": "6.2.0",
+		"insert-text-at-cursor": "0.3.0",
+		"is-file-animated": "1.0.2",
+		"json5": "2.2.2",
+		"katex": "0.15.6",
+		"matter-js": "0.18.0",
+		"mfm-js": "0.23.0",
+		"misskey-js": "0.0.14",
+		"photoswipe": "5.3.4",
+		"prismjs": "1.29.0",
+		"punycode": "2.1.1",
+		"querystring": "0.2.1",
+		"rndstr": "1.0.0",
+		"rollup": "3.8.0",
+		"s-age": "1.1.2",
+		"sass": "1.57.1",
+		"seedrandom": "3.0.5",
+		"strict-event-emitter-types": "2.0.0",
+		"stringz": "2.1.0",
+		"syuilo-password-strength": "0.0.1",
+		"textarea-caret": "3.1.0",
+		"three": "0.148.0",
+		"throttle-debounce": "5.0.0",
+		"tinycolor2": "1.4.2",
+		"tsc-alias": "1.8.2",
+		"tsconfig-paths": "4.1.1",
+		"twemoji-parser": "14.0.0",
+		"typescript": "4.9.4",
+		"uuid": "9.0.0",
+		"vanilla-tilt": "1.8.0",
+		"vite": "4.0.3",
+		"vue": "3.2.45",
+		"vue-prism-editor": "2.0.0-alpha.2",
+		"vuedraggable": "next"
+	},
+	"devDependencies": {
+		"@types/escape-regexp": "0.0.1",
+		"@types/glob": "8.0.0",
+		"@types/gulp": "4.0.10",
+		"@types/gulp-rename": "2.0.1",
+		"@types/katex": "0.14.0",
+		"@types/matter-js": "0.18.2",
+		"@types/punycode": "2.1.0",
+		"@types/seedrandom": "3.0.3",
+		"@types/throttle-debounce": "5.0.0",
+		"@types/tinycolor2": "1.4.3",
+		"@types/uuid": "9.0.0",
+		"@types/websocket": "1.0.5",
+		"@types/ws": "8.5.3",
+		"@typescript-eslint/eslint-plugin": "5.47.0",
+		"@typescript-eslint/parser": "5.47.0",
+		"@vue/runtime-core": "3.2.45",
+		"cross-env": "7.0.3",
+		"cypress": "12.2.0",
+		"eslint": "8.30.0",
+		"eslint-plugin-import": "2.26.0",
+		"eslint-plugin-vue": "9.8.0",
+		"start-server-and-test": "1.15.2",
+		"vue-eslint-parser": "^9.1.0",
+		"vue-tsc": "^1.0.16"
+	}
+}
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
new file mode 100644
index 0000000000..0e991cdfb5
--- /dev/null
+++ b/packages/frontend/src/account.ts
@@ -0,0 +1,238 @@
+import { defineAsyncComponent, reactive } from 'vue';
+import * as misskey from 'misskey-js';
+import { showSuspendedDialog } from './scripts/show-suspended-dialog';
+import { i18n } from './i18n';
+import { del, get, set } from '@/scripts/idb-proxy';
+import { apiUrl } from '@/config';
+import { waiting, api, popup, popupMenu, success, alert } from '@/os';
+import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+type Account = misskey.entities.MeDetailed;
+
+const accountData = localStorage.getItem('account');
+
+// TODO: 外部からはreadonlyに
+export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
+
+export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
+export const iAmAdmin = $i != null && $i.isAdmin;
+
+export async function signout() {
+	waiting();
+	localStorage.removeItem('account');
+
+	await removeAccount($i.id);
+
+	const accounts = await getAccounts();
+
+	//#region Remove service worker registration
+	try {
+		if (navigator.serviceWorker.controller) {
+			const registration = await navigator.serviceWorker.ready;
+			const push = await registration.pushManager.getSubscription();
+			if (push) {
+				await window.fetch(`${apiUrl}/sw/unregister`, {
+					method: 'POST',
+					body: JSON.stringify({
+						i: $i.token,
+						endpoint: push.endpoint,
+					}),
+					headers: {
+						'Content-Type': 'application/json',
+					},
+				});
+			}
+		}
+
+		if (accounts.length === 0) {
+			await navigator.serviceWorker.getRegistrations()
+				.then(registrations => {
+					return Promise.all(registrations.map(registration => registration.unregister()));
+				});
+		}
+	} catch (err) {}
+	//#endregion
+
+	document.cookie = 'igi=; path=/';
+
+	if (accounts.length > 0) login(accounts[0].token);
+	else unisonReload('/');
+}
+
+export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> {
+	return (await get('accounts')) || [];
+}
+
+export async function addAccount(id: Account['id'], token: Account['token']) {
+	const accounts = await getAccounts();
+	if (!accounts.some(x => x.id === id)) {
+		await set('accounts', accounts.concat([{ id, token }]));
+	}
+}
+
+export async function removeAccount(id: Account['id']) {
+	const accounts = await getAccounts();
+	accounts.splice(accounts.findIndex(x => x.id === id), 1);
+
+	if (accounts.length > 0) await set('accounts', accounts);
+	else await del('accounts');
+}
+
+function fetchAccount(token: string): Promise<Account> {
+	return new Promise((done, fail) => {
+		// Fetch user
+		window.fetch(`${apiUrl}/i`, {
+			method: 'POST',
+			body: JSON.stringify({
+				i: token,
+			}),
+			headers: {
+				'Content-Type': 'application/json',
+			},
+		})
+			.then(res => res.json())
+			.then(res => {
+				if (res.error) {
+					if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
+						showSuspendedDialog().then(() => {
+							signout();
+						});
+					} else {
+						alert({
+							type: 'error',
+							title: i18n.ts.failedToFetchAccountInformation,
+							text: JSON.stringify(res.error),
+						});
+					}
+				} else {
+					res.token = token;
+					done(res);
+				}
+			})
+			.catch(fail);
+	});
+}
+
+export function updateAccount(accountData) {
+	for (const [key, value] of Object.entries(accountData)) {
+		$i[key] = value;
+	}
+	localStorage.setItem('account', JSON.stringify($i));
+}
+
+export function refreshAccount() {
+	return fetchAccount($i.token).then(updateAccount);
+}
+
+export async function login(token: Account['token'], redirect?: string) {
+	waiting();
+	if (_DEV_) console.log('logging as token ', token);
+	const me = await fetchAccount(token);
+	localStorage.setItem('account', JSON.stringify(me));
+	document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
+	await addAccount(me.id, token);
+
+	if (redirect) {
+		// 他のタブは再読み込みするだけ
+		reloadChannel.postMessage(null);
+		// このページはredirectで指定された先に移動
+		location.href = redirect;
+		return;
+	}
+
+	unisonReload();
+}
+
+export async function openAccountMenu(opts: {
+	includeCurrentAccount?: boolean;
+	withExtraOperation: boolean;
+	active?: misskey.entities.UserDetailed['id'];
+	onChoose?: (account: misskey.entities.UserDetailed) => void;
+}, ev: MouseEvent) {
+	function showSigninDialog() {
+		popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+			done: res => {
+				addAccount(res.id, res.i);
+				success();
+			},
+		}, 'closed');
+	}
+
+	function createAccount() {
+		popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
+			done: res => {
+				addAccount(res.id, res.i);
+				switchAccountWithToken(res.i);
+			},
+		}, 'closed');
+	}
+
+	async function switchAccount(account: misskey.entities.UserDetailed) {
+		const storedAccounts = await getAccounts();
+		const token = storedAccounts.find(x => x.id === account.id).token;
+		switchAccountWithToken(token);
+	}
+
+	function switchAccountWithToken(token: string) {
+		login(token);
+	}
+
+	const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
+	const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) });
+
+	function createItem(account: misskey.entities.UserDetailed) {
+		return {
+			type: 'user',
+			user: account,
+			active: opts.active != null ? opts.active === account.id : false,
+			action: () => {
+				if (opts.onChoose) {
+					opts.onChoose(account);
+				} else {
+					switchAccount(account);
+				}
+			},
+		};
+	}
+
+	const accountItemPromises = storedAccounts.map(a => new Promise(res => {
+		accountsPromise.then(accounts => {
+			const account = accounts.find(x => x.id === a.id);
+			if (account == null) return res(null);
+			res(createItem(account));
+		});
+	}));
+
+	if (opts.withExtraOperation) {
+		popupMenu([...[{
+			type: 'link',
+			text: i18n.ts.profile,
+			to: `/@${ $i.username }`,
+			avatar: $i,
+		}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
+			type: 'parent',
+			icon: 'ti ti-plus',
+			text: i18n.ts.addAccount,
+			children: [{
+				text: i18n.ts.existingAccount,
+				action: () => { showSigninDialog(); },
+			}, {
+				text: i18n.ts.createAccount,
+				action: () => { createAccount(); },
+			}],
+		}, {
+			type: 'link',
+			icon: 'ti ti-users',
+			text: i18n.ts.manageAccounts,
+			to: '/settings/accounts',
+		}]], ev.currentTarget ?? ev.target, {
+			align: 'left',
+		});
+	} else {
+		popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
+			align: 'left',
+		});
+	}
+}
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
new file mode 100644
index 0000000000..9a3464b640
--- /dev/null
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="bcekxzvu _gap _panel">
+	<div class="target">
+		<MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`">
+			<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/>
+			<div class="names">
+				<MkUserName class="name" :user="report.targetUser"/>
+				<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
+			</div>
+		</MkA>
+		<MkKeyValue class="_formBlock">
+			<template #key>{{ i18n.ts.registeredDate }}</template>
+			<template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
+		</MkKeyValue>
+	</div>
+	<div class="detail">
+		<div>
+			<Mfm :text="report.comment"/>
+		</div>
+		<hr/>
+		<div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div>
+		<div v-if="report.assignee">
+			{{ i18n.ts.moderator }}:
+			<MkAcct :user="report.assignee"/>
+		</div>
+		<div><MkTime :time="report.createdAt"/></div>
+		<div class="action">
+			<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
+				{{ i18n.ts.forwardReport }}
+				<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
+			</MkSwitch>
+			<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import MkButton from '@/components/MkButton.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import { acct, userPage } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	report: any;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'resolved', reportId: string): void;
+}>();
+
+let forward = $ref(props.report.forwarded);
+
+function resolve() {
+	os.apiWithDialog('admin/resolve-abuse-user-report', {
+		forward: forward,
+		reportId: props.report.id,
+	}).then(() => {
+		emit('resolved', props.report.id);
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.bcekxzvu {
+	display: flex;
+
+	> .target {
+		width: 35%;
+		box-sizing: border-box;
+		text-align: left;
+		padding: 24px;
+		border-right: solid 1px var(--divider);
+
+		> .info {
+			display: flex;
+			box-sizing: border-box;
+			align-items: center;
+			padding: 14px;
+			border-radius: 8px;
+			--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;
+
+			> .avatar {
+				width: 42px;
+				height: 42px;
+			}
+
+			> .names {
+				margin-left: 0.3em;
+				padding: 0 8px;
+				flex: 1;
+
+				> .name {
+					font-weight: bold;
+				}
+			}
+		}
+	}
+
+	> .detail {
+		flex: 1;
+		padding: 24px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue
new file mode 100644
index 0000000000..039f77c859
--- /dev/null
+++ b/packages/frontend/src/components/MkAbuseReportWindow.vue
@@ -0,0 +1,65 @@
+<template>
+<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
+	<template #header>
+		<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
+		<I18n :src="i18n.ts.reportAbuseOf" tag="span">
+			<template #name>
+				<b><MkAcct :user="user"/></b>
+			</template>
+		</I18n>
+	</template>
+	<div class="dpvffvvy _monolithic_">
+		<div class="_section">
+			<MkTextarea v-model="comment">
+				<template #label>{{ i18n.ts.details }}</template>
+				<template #caption>{{ i18n.ts.fillAbuseReportDescription }}</template>
+			</MkTextarea>
+		</div>
+		<div class="_section">
+			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
+		</div>
+	</div>
+</XWindow>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XWindow from '@/components/MkWindow.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	user: Misskey.entities.User;
+	initialComment?: string;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const uiWindow = ref<InstanceType<typeof XWindow>>();
+const comment = ref(props.initialComment || '');
+
+function send() {
+	os.apiWithDialog('users/report-abuse', {
+		userId: props.user.id,
+		comment: comment.value,
+	}, undefined).then(res => {
+		os.alert({
+			type: 'success',
+			text: i18n.ts.abuseReported,
+		});
+		uiWindow.value?.close();
+		emit('closed');
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.dpvffvvy {
+	--root-margin: 16px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkActiveUsersHeatmap.vue b/packages/frontend/src/components/MkActiveUsersHeatmap.vue
new file mode 100644
index 0000000000..02b2eeeb36
--- /dev/null
+++ b/packages/frontend/src/components/MkActiveUsersHeatmap.vue
@@ -0,0 +1,236 @@
+<template>
+<div ref="rootEl">
+	<MkLoading v-if="fetching"/>
+	<div v-else>
+		<canvas ref="chartEl"></canvas>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import * as os from '@/os';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	MatrixController, MatrixElement,
+);
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const rootEl = $ref<HTMLDivElement>(null);
+const chartEl = $ref<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip({
+	position: 'middle',
+});
+
+async function renderChart() {
+	if (chartInstance) {
+		chartInstance.destroy();
+	}
+
+	const wide = rootEl.offsetWidth > 700;
+	const narrow = rootEl.offsetWidth < 400;
+
+	const weeks = wide ? 50 : narrow ? 10 : 25;
+	const chartLimit = 7 * weeks;
+
+	const getDate = (ago: number) => {
+		const y = now.getFullYear();
+		const m = now.getMonth();
+		const d = now.getDate();
+
+		return new Date(y, m, d - ago);
+	};
+
+	const format = (arr) => {
+		return arr.map((v, i) => {
+			const dt = getDate(i);
+			const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
+			return {
+				x: iso,
+				y: dt.getDay(),
+				d: iso,
+				v,
+			};
+		});
+	};
+
+	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
+
+	fetching = false;
+
+	await nextTick();
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	const color = '#3498db';
+
+	const max = Math.max(...raw.readWrite);
+
+	const marginEachCell = 4;
+
+	chartInstance = new Chart(chartEl, {
+		type: 'matrix',
+		data: {
+			datasets: [{
+				label: 'Read & Write',
+				data: format(raw.readWrite),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 3,
+				backgroundColor(c) {
+					const value = c.dataset.data[c.dataIndex].v;
+					const a = value / max;
+					return alpha(color, a);
+				},
+				fill: true,
+				width(c) {
+					const a = c.chart.chartArea ?? {};
+					// 20週間
+					return (a.right - a.left) / weeks - marginEachCell;
+				},
+				height(c) {
+					const a = c.chart.chartArea ?? {};
+					// 7日
+					return (a.bottom - a.top) / 7 - marginEachCell;
+				},
+			}],
+		},
+		options: {
+			aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
+			layout: {
+				padding: {
+					left: 8,
+					right: 0,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					offset: true,
+					position: 'bottom',
+					time: {
+						unit: 'week',
+						round: 'week',
+						isoWeekday: 0,
+						displayFormats: {
+							week: 'MMM dd',
+						},
+					},
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						maxRotation: 0,
+						autoSkipPadding: 8,
+					},
+				},
+				y: {
+					offset: true,
+					reverse: true,
+					position: 'right',
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						maxRotation: 0,
+						autoSkip: true,
+						padding: 1,
+						font: {
+							size: 9,
+						},
+						callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value],
+					},
+				},
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					callbacks: {
+						title(context) {
+							const v = context[0].dataset.data[context[0].dataIndex];
+							return v.d;
+						},
+						label(context) {
+							const v = context.dataset.data[context.dataIndex];
+							return ['Active: ' + v.v];
+						},
+					},
+					//mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+	});
+}
+
+onMounted(async () => {
+	renderChart();
+});
+</script>
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
new file mode 100644
index 0000000000..40ef626aed
--- /dev/null
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -0,0 +1,225 @@
+<template>
+<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
+	<template v-if="props.graduations === 'dots'">
+		<circle
+			v-for="(angle, i) in graduationsMajor"
+			:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
+			:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
+			:r="0.125"
+			:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : majorGraduationColor"
+			:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
+		/>
+	</template>
+	<template v-else-if="props.graduations === 'numbers'">
+		<text
+			v-for="(angle, i) in texts"
+			:x="5 + (Math.sin(angle) * (5 - textsPadding))"
+			:y="5 - (Math.cos(angle) * (5 - textsPadding))"
+			text-anchor="middle"
+			dominant-baseline="middle"
+			:font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7"
+			:font-weight="(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'"
+			:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : 'currentColor'"
+			:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
+		>
+			{{ i === 0 ? (props.twentyfour ? '24' : '12') : i }}
+		</text>
+	</template>
+
+	<!--
+	<line
+		:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
+		:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
+		:x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+		:y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+		:stroke="sHandColor"
+		:stroke-width="thickness / 2"
+		stroke-linecap="round"
+	/>
+	-->
+
+	<line
+		class="s"
+		:class="{ animate: !disableSAnimate && sAnimation !== 'none', elastic: sAnimation === 'elastic', easeOut: sAnimation === 'easeOut' }"
+		:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
+		:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
+		:x2="5 + (0 * ((sHandLengthRatio * 5) - handsPadding))"
+		:y2="5 - (1 * ((sHandLengthRatio * 5) - handsPadding))"
+		:stroke="sHandColor"
+		:stroke-width="thickness / 2"
+		:style="`transform: rotateZ(${sAngle}rad)`"
+		stroke-linecap="round"
+	/>
+
+	<line
+		:x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
+		:y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
+		:x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+		:y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+		:stroke="mHandColor"
+		:stroke-width="thickness"
+		stroke-linecap="round"
+	/>
+
+	<line
+		:x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
+		:y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
+		:x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+		:y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+		:stroke="hHandColor"
+		:stroke-width="thickness"
+		stroke-linecap="round"
+	/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, onBeforeUnmount, shallowRef, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { globalEvents } from '@/events.js';
+
+// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
+const angleDiff = (a: number, b: number) => {
+	const x = Math.abs(a - b);
+	return Math.abs((x + Math.PI) % (Math.PI * 2) - Math.PI);
+};
+
+const graduationsPadding = 0.5;
+const textsPadding = 0.6;
+const handsPadding = 1;
+const handsTailLength = 0.7;
+const hHandLengthRatio = 0.75;
+const mHandLengthRatio = 1;
+const sHandLengthRatio = 1;
+const numbersOpacityFactor = 0.35;
+
+const props = withDefaults(defineProps<{
+	thickness?: number;
+	offset?: number;
+	twentyfour?: boolean;
+	graduations?: 'none' | 'dots' | 'numbers';
+	fadeGraduations?: boolean;
+	sAnimation?: 'none' | 'elastic' | 'easeOut';
+}>(), {
+	numbers: false,
+	thickness: 0.1,
+	offset: 0 - new Date().getTimezoneOffset(),
+	twentyfour: false,
+	graduations: 'dots',
+	fadeGraduations: true,
+	sAnimation: 'elastic',
+});
+
+const graduationsMajor = computed(() => {
+	const angles: number[] = [];
+	const times = props.twentyfour ? 24 : 12;
+	for (let i = 0; i < times; i++) {
+		const angle = Math.PI * i / (times / 2);
+		angles.push(angle);
+	}
+	return angles;
+});
+const texts = computed(() => {
+	const angles: number[] = [];
+	const times = props.twentyfour ? 24 : 12;
+	for (let i = 0; i < times; i++) {
+		const angle = Math.PI * i / (times / 2);
+		angles.push(angle);
+	}
+	return angles;
+});
+
+let enabled = true;
+let majorGraduationColor = $ref<string>();
+//let minorGraduationColor = $ref<string>();
+let sHandColor = $ref<string>();
+let mHandColor = $ref<string>();
+let hHandColor = $ref<string>();
+let nowColor = $ref<string>();
+let h = $ref<number>(0);
+let m = $ref<number>(0);
+let s = $ref<number>(0);
+let hAngle = $ref<number>(0);
+let mAngle = $ref<number>(0);
+let sAngle = $ref<number>(0);
+let disableSAnimate = $ref(false);
+let sOneRound = false;
+
+function tick() {
+	const now = new Date();
+	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
+	s = now.getSeconds();
+	m = now.getMinutes();
+	h = now.getHours();
+	hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
+	mAngle = Math.PI * (m + s / 60) / 30;
+	if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
+		sAngle = Math.PI * 60 / 30;
+		window.setTimeout(() => {
+			disableSAnimate = true;
+			window.setTimeout(() => {
+				sAngle = 0;
+				window.setTimeout(() => {
+					disableSAnimate = false;
+				}, 100);
+			}, 100);
+		}, 700);
+	} else {
+		sAngle = Math.PI * s / 30;
+	}
+	sOneRound = s === 59;
+}
+
+tick();
+
+function calcColors() {
+	const computedStyle = getComputedStyle(document.documentElement);
+	const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
+	const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
+	majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
+	//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
+	mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
+	hHandColor = accent;
+	nowColor = accent;
+}
+
+calcColors();
+
+onMounted(() => {
+	const update = () => {
+		if (enabled) {
+			tick();
+			window.setTimeout(update, 1000);
+		}
+	};
+	update();
+
+	globalEvents.on('themeChanged', calcColors);
+});
+
+onBeforeUnmount(() => {
+	enabled = false;
+
+	globalEvents.off('themeChanged', calcColors);
+});
+</script>
+
+<style lang="scss" scoped>
+.mbcofsoe {
+	display: block;
+
+	> .s {
+		will-change: transform;
+		transform-origin: 50% 50%;
+
+		&.animate.elastic {
+			transition: transform .2s cubic-bezier(.4,2.08,.55,.44);
+		}
+
+		&.animate.easeOut {
+			transition: transform .7s cubic-bezier(0,.7,.3,1);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
new file mode 100644
index 0000000000..72783921d5
--- /dev/null
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -0,0 +1,476 @@
+<template>
+<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
+	<ol v-if="type === 'user'" ref="suggests" class="users">
+		<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
+			<img class="avatar" :src="user.avatarUrl"/>
+			<span class="name">
+				<MkUserName :key="user.id" :user="user"/>
+			</span>
+			<span class="username">@{{ acct(user) }}</span>
+		</li>
+		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
+	</ol>
+	<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
+		<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
+			<span class="name">{{ hashtag }}</span>
+		</li>
+	</ol>
+	<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
+		<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
+			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+			<span v-else-if="defaultStore.state.emojiStyle != 'native'" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
+			<span v-else class="emoji">{{ emoji.emoji }}</span>
+			<!-- eslint-disable-next-line vue/no-v-html -->
+			<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
+			<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
+		</li>
+	</ol>
+	<ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags">
+		<li v-for="tag in mfmTags" tabindex="-1" @click="complete(type, tag)" @keydown="onKeydown">
+			<span class="tag">{{ tag }}</span>
+		</li>
+	</ol>
+</div>
+</template>
+
+<script lang="ts">
+import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
+import contains from '@/scripts/contains';
+import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+import { defaultStore } from '@/store';
+import { emojilist } from '@/scripts/emojilist';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+type EmojiDef = {
+	emoji: string;
+	name: string;
+	aliasOf?: string;
+	url?: string;
+	isCustomEmoji?: boolean;
+};
+
+const lib = emojilist.filter(x => x.category !== 'flags');
+
+const emjdb: EmojiDef[] = lib.map(x => ({
+	emoji: x.char,
+	name: x.name,
+	url: char2path(x.char),
+}));
+
+const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
+
+for (const x of lib) {
+	if (x.keywords) {
+		for (const k of x.keywords) {
+			emjdb.push({
+				emoji: x.char,
+				name: k,
+				aliasOf: x.name,
+				url: char2path(x.char),
+			});
+		}
+	}
+}
+
+emjdb.sort((a, b) => a.name.length - b.name.length);
+
+//#region Construct Emoji DB
+const customEmojis = instance.emojis;
+const emojiDefinitions: EmojiDef[] = [];
+
+for (const x of customEmojis) {
+	emojiDefinitions.push({
+		name: x.name,
+		emoji: `:${x.name}:`,
+		url: x.url,
+		isCustomEmoji: true,
+	});
+
+	if (x.aliases) {
+		for (const alias of x.aliases) {
+			emojiDefinitions.push({
+				name: alias,
+				aliasOf: x.name,
+				emoji: `:${x.name}:`,
+				url: x.url,
+				isCustomEmoji: true,
+			});
+		}
+	}
+}
+
+emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
+
+const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
+//#endregion
+
+export default {
+	emojiDb,
+	emojiDefinitions,
+	emojilist,
+	customEmojis,
+};
+</script>
+
+<script lang="ts" setup>
+const props = defineProps<{
+	type: string;
+	q: string | null;
+	textarea: HTMLTextAreaElement;
+	close: () => void;
+	x: number;
+	y: number;
+}>();
+
+const emit = defineEmits<{
+	(event: 'done', value: { type: string; value: any }): void;
+	(event: 'closed'): void;
+}>();
+
+const suggests = ref<Element>();
+const rootEl = ref<HTMLDivElement>();
+
+const fetching = ref(true);
+const users = ref<any[]>([]);
+const hashtags = ref<any[]>([]);
+const emojis = ref<(EmojiDef)[]>([]);
+const items = ref<Element[] | HTMLCollection>([]);
+const mfmTags = ref<string[]>([]);
+const select = ref(-1);
+const zIndex = os.claimZIndex('high');
+
+function complete(type: string, value: any) {
+	emit('done', { type, value });
+	emit('closed');
+	if (type === 'emoji') {
+		let recents = defaultStore.state.recentlyUsedEmojis;
+		recents = recents.filter((emoji: any) => emoji !== value);
+		recents.unshift(value);
+		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
+	}
+}
+
+function setPosition() {
+	if (!rootEl.value) return;
+	if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
+		rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
+	} else {
+		rootEl.value.style.left = `${props.x}px`;
+	}
+	if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
+		rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
+		rootEl.value.style.marginTop = '0';
+	} else {
+		rootEl.value.style.top = props.y + 'px';
+		rootEl.value.style.marginTop = 'calc(1em + 8px)';
+	}
+}
+
+function exec() {
+	select.value = -1;
+	if (suggests.value) {
+		for (const el of Array.from(items.value)) {
+			el.removeAttribute('data-selected');
+		}
+	}
+	if (props.type === 'user') {
+		if (!props.q) {
+			users.value = [];
+			fetching.value = false;
+			return;
+		}
+
+		const cacheKey = `autocomplete:user:${props.q}`;
+		const cache = sessionStorage.getItem(cacheKey);
+
+		if (cache) {
+			users.value = JSON.parse(cache);
+			fetching.value = false;
+		} else {
+			os.api('users/search-by-username-and-host', {
+				username: props.q,
+				limit: 10,
+				detail: false,
+			}).then(searchedUsers => {
+				users.value = searchedUsers as any[];
+				fetching.value = false;
+				// キャッシュ
+				sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
+			});
+		}
+	} else if (props.type === 'hashtag') {
+		if (!props.q || props.q === '') {
+			hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
+			fetching.value = false;
+		} else {
+			const cacheKey = `autocomplete:hashtag:${props.q}`;
+			const cache = sessionStorage.getItem(cacheKey);
+			if (cache) {
+				const hashtags = JSON.parse(cache);
+				hashtags.value = hashtags;
+				fetching.value = false;
+			} else {
+				os.api('hashtags/search', {
+					query: props.q,
+					limit: 30,
+				}).then(searchedHashtags => {
+					hashtags.value = searchedHashtags as any[];
+					fetching.value = false;
+					// キャッシュ
+					sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
+				});
+			}
+		}
+	} else if (props.type === 'emoji') {
+		if (!props.q || props.q === '') {
+			// 最近使った絵文字をサジェスト
+			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
+			return;
+		}
+
+		const matched: EmojiDef[] = [];
+		const max = 30;
+
+		emojiDb.some(x => {
+			if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+			return matched.length === max;
+		});
+
+		if (matched.length < max) {
+			emojiDb.some(x => {
+				if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+				return matched.length === max;
+			});
+		}
+
+		if (matched.length < max) {
+			emojiDb.some(x => {
+				if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+				return matched.length === max;
+			});
+		}
+
+		emojis.value = matched;
+	} else if (props.type === 'mfmTag') {
+		if (!props.q || props.q === '') {
+			mfmTags.value = MFM_TAGS;
+			return;
+		}
+
+		mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
+	}
+}
+
+function onMousedown(event: Event) {
+	if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
+}
+
+function onKeydown(event: KeyboardEvent) {
+	const cancel = () => {
+		event.preventDefault();
+		event.stopPropagation();
+	};
+
+	switch (event.key) {
+		case 'Enter':
+			if (select.value !== -1) {
+				cancel();
+				(items.value[select.value] as any).click();
+			} else {
+				props.close();
+			}
+			break;
+
+		case 'Escape':
+			cancel();
+			props.close();
+			break;
+
+		case 'ArrowUp':
+			if (select.value !== -1) {
+				cancel();
+				selectPrev();
+			} else {
+				props.close();
+			}
+			break;
+
+		case 'Tab':
+		case 'ArrowDown':
+			cancel();
+			selectNext();
+			break;
+
+		default:
+			event.stopPropagation();
+			props.textarea.focus();
+	}
+}
+
+function selectNext() {
+	if (++select.value >= items.value.length) select.value = 0;
+	if (items.value.length === 0) select.value = -1;
+	applySelect();
+}
+
+function selectPrev() {
+	if (--select.value < 0) select.value = items.value.length - 1;
+	applySelect();
+}
+
+function applySelect() {
+	for (const el of Array.from(items.value)) {
+		el.removeAttribute('data-selected');
+	}
+
+	if (select.value !== -1) {
+		items.value[select.value].setAttribute('data-selected', 'true');
+		(items.value[select.value] as any).focus();
+	}
+}
+
+function chooseUser() {
+	props.close();
+	os.selectUser().then(user => {
+		complete('user', user);
+		props.textarea.focus();
+	});
+}
+
+onUpdated(() => {
+	setPosition();
+	items.value = suggests.value?.children ?? [];
+});
+
+onMounted(() => {
+	setPosition();
+
+	props.textarea.addEventListener('keydown', onKeydown);
+
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.addEventListener('mousedown', onMousedown);
+	}
+
+	nextTick(() => {
+		exec();
+
+		watch(() => props.q, () => {
+			nextTick(() => {
+				exec();
+			});
+		});
+	});
+});
+
+onBeforeUnmount(() => {
+	props.textarea.removeEventListener('keydown', onKeydown);
+
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.removeEventListener('mousedown', onMousedown);
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.swhvrteh {
+	position: fixed;
+	max-width: 100%;
+	margin-top: calc(1em + 8px);
+	overflow: hidden;
+	transition: top 0.1s ease, left 0.1s ease;
+
+	> ol {
+		display: block;
+		margin: 0;
+		padding: 4px 0;
+		max-height: 190px;
+		max-width: 500px;
+		overflow: auto;
+		list-style: none;
+
+		> li {
+			display: flex;
+			align-items: center;
+			padding: 4px 12px;
+			white-space: nowrap;
+			overflow: hidden;
+			font-size: 0.9em;
+			cursor: default;
+
+			&, * {
+				user-select: none;
+			}
+
+			* {
+				overflow: hidden;
+				text-overflow: ellipsis;
+			}
+
+			&:hover {
+				background: var(--X3);
+			}
+
+			&[data-selected='true'] {
+				background: var(--accent);
+
+				&, * {
+					color: #fff !important;
+				}
+			}
+
+			&:active {
+				background: var(--accentDarken);
+
+				&, * {
+					color: #fff !important;
+				}
+			}
+		}
+	}
+
+	> .users > li {
+
+		.avatar {
+			min-width: 28px;
+			min-height: 28px;
+			max-width: 28px;
+			max-height: 28px;
+			margin: 0 8px 0 0;
+			border-radius: 100%;
+		}
+
+		.name {
+			margin: 0 8px 0 0;
+		}
+	}
+
+	> .emojis > li {
+
+		.emoji {
+			display: inline-block;
+			margin: 0 4px 0 0;
+			width: 24px;
+
+			> img {
+				width: 24px;
+				vertical-align: bottom;
+			}
+		}
+
+		.alias {
+			margin: 0 0 0 8px;
+		}
+	}
+
+	> .mfmTags > li {
+
+		.name {
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue
new file mode 100644
index 0000000000..162338b639
--- /dev/null
+++ b/packages/frontend/src/components/MkAvatars.vue
@@ -0,0 +1,24 @@
+<template>
+<div>
+	<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
+		<MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import * as os from '@/os';
+
+const props = defineProps<{
+	userIds: string[];
+}>();
+
+const users = ref([]);
+
+onMounted(async () => {
+	users.value = await os.api('users/show', {
+		userIds: props.userIds,
+	});
+});
+</script>
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
new file mode 100644
index 0000000000..891645bb2a
--- /dev/null
+++ b/packages/frontend/src/components/MkButton.vue
@@ -0,0 +1,227 @@
+<template>
+<button
+	v-if="!link"
+	ref="el" class="bghgjjyj _button"
+	:class="{ inline, primary, gradate, danger, rounded, full }"
+	:type="type"
+	@click="emit('click', $event)"
+	@mousedown="onMousedown"
+>
+	<div ref="ripples" class="ripples"></div>
+	<div class="content">
+		<slot></slot>
+	</div>
+</button>
+<MkA
+	v-else class="bghgjjyj _button"
+	:class="{ inline, primary, gradate, danger, rounded, full }"
+	:to="to"
+	@mousedown="onMousedown"
+>
+	<div ref="ripples" class="ripples"></div>
+	<div class="content">
+		<slot></slot>
+	</div>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted } from 'vue';
+
+const props = defineProps<{
+	type?: 'button' | 'submit' | 'reset';
+	primary?: boolean;
+	gradate?: boolean;
+	rounded?: boolean;
+	inline?: boolean;
+	link?: boolean;
+	to?: string;
+	autofocus?: boolean;
+	wait?: boolean;
+	danger?: boolean;
+	full?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'click', payload: MouseEvent): void;
+}>();
+
+let el = $ref<HTMLElement | null>(null);
+let ripples = $ref<HTMLElement | null>(null);
+
+onMounted(() => {
+	if (props.autofocus) {
+		nextTick(() => {
+			el!.focus();
+		});
+	}
+});
+
+function distance(p, q): number {
+	return Math.hypot(p.x - q.x, p.y - q.y);
+}
+
+function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
+	const origin = { x: circleCenterX, y: circleCenterY };
+	const dist1 = distance({ x: 0, y: 0 }, origin);
+	const dist2 = distance({ x: boxW, y: 0 }, origin);
+	const dist3 = distance({ x: 0, y: boxH }, origin);
+	const dist4 = distance({ x: boxW, y: boxH }, origin);
+	return Math.max(dist1, dist2, dist3, dist4) * 2;
+}
+
+function onMousedown(evt: MouseEvent): void {
+	const target = evt.target! as HTMLElement;
+	const rect = target.getBoundingClientRect();
+
+	const ripple = document.createElement('div');
+	ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
+	ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
+
+	ripples!.appendChild(ripple);
+
+	const circleCenterX = evt.clientX - rect.left;
+	const circleCenterY = evt.clientY - rect.top;
+
+	const scale = calcCircleScale(target.clientWidth, target.clientHeight, circleCenterX, circleCenterY);
+
+	window.setTimeout(() => {
+		ripple.style.transform = 'scale(' + (scale / 2) + ')';
+	}, 1);
+	window.setTimeout(() => {
+		ripple.style.transition = 'all 1s ease';
+		ripple.style.opacity = '0';
+	}, 1000);
+	window.setTimeout(() => {
+		if (ripples) ripples.removeChild(ripple);
+	}, 2000);
+}
+</script>
+
+<style lang="scss" scoped>
+.bghgjjyj {
+	position: relative;
+	z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
+	display: block;
+	min-width: 100px;
+	width: max-content;
+	padding: 7px 14px;
+	text-align: center;
+	font-weight: normal;
+	font-size: 95%;
+	box-shadow: none;
+	text-decoration: none;
+	background: var(--buttonBg);
+	border-radius: 5px;
+	overflow: clip;
+	box-sizing: border-box;
+	transition: background 0.1s ease;
+
+	&:not(:disabled):hover {
+		background: var(--buttonHoverBg);
+	}
+
+	&:not(:disabled):active {
+		background: var(--buttonHoverBg);
+	}
+
+	&.full {
+		width: 100%;
+	}
+
+	&.rounded {
+		border-radius: 999px;
+	}
+
+	&.primary {
+		font-weight: bold;
+		color: var(--fgOnAccent) !important;
+		background: var(--accent);
+
+		&:not(:disabled):hover {
+			background: var(--X8);
+		}
+
+		&:not(:disabled):active {
+			background: var(--X8);
+		}
+	}
+
+	&.gradate {
+		font-weight: bold;
+		color: var(--fgOnAccent) !important;
+		background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+
+		&:not(:disabled):hover {
+			background: linear-gradient(90deg, var(--X8), var(--X8));
+		}
+
+		&:not(:disabled):active {
+			background: linear-gradient(90deg, var(--X8), var(--X8));
+		}
+	}
+
+	&.danger {
+		color: #ff2a2a;
+
+		&.primary {
+			color: #fff;
+			background: #ff2a2a;
+
+			&:not(:disabled):hover {
+				background: #ff4242;
+			}
+
+			&:not(:disabled):active {
+				background: #d42e2e;
+			}
+		}
+	}
+
+	&:disabled {
+		opacity: 0.7;
+	}
+
+	&:focus-visible {
+		outline: solid 2px var(--focus);
+		outline-offset: 2px;
+	}
+
+	&.inline {
+		display: inline-block;
+		width: auto;
+		min-width: 100px;
+	}
+
+	> .ripples {
+		position: absolute;
+		z-index: 0;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		border-radius: 6px;
+		overflow: hidden;
+
+		::v-deep(div) {
+			position: absolute;
+			width: 2px;
+			height: 2px;
+			border-radius: 100%;
+			background: rgba(0, 0, 0, 0.1);
+			opacity: 1;
+			transform: scale(1);
+			transition: all 0.5s cubic-bezier(0,.5,0,1);
+		}
+	}
+
+	&.primary > .ripples ::v-deep(div) {
+		background: rgba(0, 0, 0, 0.15);
+	}
+
+	> .content {
+		position: relative;
+		z-index: 1;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
new file mode 100644
index 0000000000..6d218389fc
--- /dev/null
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -0,0 +1,118 @@
+<template>
+<div>
+	<span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span>
+	<div ref="captchaEl"></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+type Captcha = {
+	render(container: string | Node, options: {
+		readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+	}): string;
+	remove(id: string): void;
+	execute(id: string): void;
+	reset(id?: string): void;
+	getResponse(id: string): string;
+};
+
+type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
+
+type CaptchaContainer = {
+	readonly [_ in CaptchaProvider]?: Captcha;
+};
+
+declare global {
+	interface Window extends CaptchaContainer { }
+}
+
+const props = defineProps<{
+	provider: CaptchaProvider;
+	sitekey: string;
+	modelValue?: string | null;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', v: string | null): void;
+}>();
+
+const available = ref(false);
+
+const captchaEl = ref<HTMLDivElement | undefined>();
+
+const variable = computed(() => {
+	switch (props.provider) {
+		case 'hcaptcha': return 'hcaptcha';
+		case 'recaptcha': return 'grecaptcha';
+		case 'turnstile': return 'turnstile';
+	}
+});
+
+const loaded = !!window[variable.value];
+
+const src = computed(() => {
+	switch (props.provider) {
+		case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
+		case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
+		case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+	}
+});
+
+const scriptId = computed(() => `script-${props.provider}`)
+
+const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
+
+if (loaded) {
+	available.value = true;
+} else {
+	(document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), {
+		async: true,
+		id: scriptId.value,
+		src: src.value,
+	})))
+		.addEventListener('load', () => available.value = true);
+}
+
+function reset() {
+	if (captcha.value.reset) captcha.value.reset();
+}
+
+function requestRender() {
+	if (captcha.value.render && captchaEl.value instanceof Element) {
+		captcha.value.render(captchaEl.value, {
+			sitekey: props.sitekey,
+			theme: defaultStore.state.darkMode ? 'dark' : 'light',
+			callback: callback,
+			'expired-callback': callback,
+			'error-callback': callback,
+		});
+	} else {
+		window.setTimeout(requestRender, 1);
+	}
+}
+
+function callback(response?: string) {
+	emit('update:modelValue', typeof response === 'string' ? response : null);
+}
+
+onMounted(() => {
+	if (available.value) {
+		requestRender();
+	} else {
+		watch(available, requestRender);
+	}
+});
+
+onBeforeUnmount(() => {
+	reset();
+});
+
+defineExpose({
+	reset,
+});
+
+</script>
diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue
new file mode 100644
index 0000000000..9e275d6172
--- /dev/null
+++ b/packages/frontend/src/components/MkChannelFollowButton.vue
@@ -0,0 +1,129 @@
+<template>
+<button
+	class="hdcaacmi _button"
+	:class="{ wait, active: isFollowing, full }"
+	:disabled="wait"
+	@click="onClick"
+>
+	<template v-if="!wait">
+		<template v-if="isFollowing">
+			<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
+		</template>
+		<template v-else>
+			<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
+		</template>
+	</template>
+	<template v-else>
+		<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/>
+	</template>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	channel: Record<string, any>;
+	full?: boolean;
+}>(), {
+	full: false,
+});
+
+const isFollowing = ref<boolean>(props.channel.isFollowing);
+const wait = ref(false);
+
+async function onClick() {
+	wait.value = true;
+
+	try {
+		if (isFollowing.value) {
+			await os.api('channels/unfollow', {
+				channelId: props.channel.id,
+			});
+			isFollowing.value = false;
+		} else {
+			await os.api('channels/follow', {
+				channelId: props.channel.id,
+			});
+			isFollowing.value = true;
+		}
+	} catch (err) {
+		console.error(err);
+	} finally {
+		wait.value = false;
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.hdcaacmi {
+	position: relative;
+	display: inline-block;
+	font-weight: bold;
+	color: var(--accent);
+	background: transparent;
+	border: solid 1px var(--accent);
+	padding: 0;
+	height: 31px;
+	font-size: 16px;
+	border-radius: 32px;
+	background: #fff;
+
+	&.full {
+		padding: 0 8px 0 12px;
+		font-size: 14px;
+	}
+
+	&:not(.full) {
+		width: 31px;
+	}
+
+	&:focus-visible {
+		&:after {
+			content: "";
+			pointer-events: none;
+			position: absolute;
+			top: -5px;
+			right: -5px;
+			bottom: -5px;
+			left: -5px;
+			border: 2px solid var(--focus);
+			border-radius: 32px;
+		}
+	}
+
+	&:hover {
+		//background: mix($primary, #fff, 20);
+	}
+
+	&:active {
+		//background: mix($primary, #fff, 40);
+	}
+
+	&.active {
+		color: #fff;
+		background: var(--accent);
+
+		&:hover {
+			background: var(--accentLighten);
+			border-color: var(--accentLighten);
+		}
+
+		&:active {
+			background: var(--accentDarken);
+			border-color: var(--accentDarken);
+		}
+	}
+
+	&.wait {
+		cursor: wait !important;
+		opacity: 0.7;
+	}
+
+	> span {
+		margin-right: 6px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue
new file mode 100644
index 0000000000..6ef50bddcf
--- /dev/null
+++ b/packages/frontend/src/components/MkChannelPreview.vue
@@ -0,0 +1,154 @@
+<template>
+<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
+	<div class="banner" :style="bannerStyle">
+		<div class="fade"></div>
+		<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
+		<div class="status">
+			<div>
+				<i class="ti ti-users ti-fw"></i>
+				<I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;">
+					<template #n>
+						<b>{{ channel.usersCount }}</b>
+					</template>
+				</I18n>
+			</div>
+			<div>
+				<i class="ti ti-pencil ti-fw"></i>
+				<I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;">
+					<template #n>
+						<b>{{ channel.notesCount }}</b>
+					</template>
+				</I18n>
+			</div>
+		</div>
+	</div>
+	<article v-if="channel.description">
+		<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
+	</article>
+	<footer>
+		<span v-if="channel.lastNotedAt">
+			{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
+		</span>
+	</footer>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	channel: Record<string, any>;
+}>();
+
+const bannerStyle = computed(() => {
+	if (props.channel.bannerUrl) {
+		return { backgroundImage: `url(${props.channel.bannerUrl})` };
+	} else {
+		return { backgroundColor: '#4c5e6d' };
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.eftoefju {
+	display: block;
+	overflow: hidden;
+	width: 100%;
+
+	&:hover {
+		text-decoration: none;
+	}
+
+	> .banner {
+		position: relative;
+		width: 100%;
+		height: 200px;
+		background-position: center;
+		background-size: cover;
+
+		> .fade {
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), var(--X15));
+		}
+
+		> .name {
+			position: absolute;
+			top: 16px;
+			left: 16px;
+			padding: 12px 16px;
+			background: rgba(0, 0, 0, 0.7);
+			color: #fff;
+			font-size: 1.2em;
+		}
+
+		> .status {
+			position: absolute;
+			z-index: 1;
+			bottom: 16px;
+			right: 16px;
+			padding: 8px 12px;
+			font-size: 80%;
+			background: rgba(0, 0, 0, 0.7);
+			border-radius: 6px;
+			color: #fff;
+		}
+	}
+
+	> article {
+		padding: 16px;
+
+		> p {
+			margin: 0;
+			font-size: 1em;
+		}
+	}
+
+	> footer {
+		padding: 12px 16px;
+		border-top: solid 0.5px var(--divider);
+
+		> span {
+			opacity: 0.7;
+			font-size: 0.9em;
+		}
+	}
+
+	@media (max-width: 550px) {
+		font-size: 0.9em;
+
+		> .banner {
+			height: 80px;
+
+			> .status {
+				display: none;
+			}
+		}
+
+		> article {
+			padding: 12px;
+		}
+
+		> footer {
+			display: none;
+		}
+	}
+
+	@media (max-width: 500px) {
+		font-size: 0.8em;
+
+		> .banner {
+			height: 70px;
+		}
+
+		> article {
+			padding: 8px;
+		}
+	}
+}
+
+</style>
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
new file mode 100644
index 0000000000..fbbc231b88
--- /dev/null
+++ b/packages/frontend/src/components/MkChart.vue
@@ -0,0 +1,859 @@
+<template>
+<div class="cbbedffa">
+	<canvas ref="chartEl"></canvas>
+	<div v-if="fetching" class="fetching">
+		<MkLoading/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+/* eslint-disable id-denylist --
+  Chart.js has a `data` attribute in most chart definitions, which triggers the
+  id-denylist violation when setting it. This is causing about 60+ lint issues.
+  As this is part of Chart.js's API it makes sense to disable the check here.
+*/
+import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import 'chartjs-adapter-date-fns';
+import { enUS } from 'date-fns/locale';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import gradient from 'chartjs-plugin-gradient';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+
+const props = defineProps({
+	src: {
+		type: String,
+		required: true,
+	},
+	args: {
+		type: Object,
+		required: false,
+	},
+	limit: {
+		type: Number,
+		required: false,
+		default: 90,
+	},
+	span: {
+		type: String as PropType<'hour' | 'day'>,
+		required: true,
+	},
+	detailed: {
+		type: Boolean,
+		required: false,
+		default: false,
+	},
+	stacked: {
+		type: Boolean,
+		required: false,
+		default: false,
+	},
+	bar: {
+		type: Boolean,
+		required: false,
+		default: false,
+	},
+	aspectRatio: {
+		type: Number,
+		required: false,
+		default: null,
+	},
+});
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	zoomPlugin,
+	gradient,
+);
+
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const colors = {
+	blue: '#008FFB',
+	green: '#00E396',
+	yellow: '#FEB019',
+	red: '#FF4560',
+	purple: '#e300db',
+	orange: '#fe6919',
+	lime: '#bde800',
+	cyan: '#00e0e0',
+};
+const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
+const getColor = (i) => {
+	return colorSets[i % colorSets.length];
+};
+
+const now = new Date();
+let chartInstance: Chart = null;
+let chartData: {
+	series: {
+		name: string;
+		type: 'line' | 'area';
+		color?: string;
+		dashed?: boolean;
+		hidden?: boolean;
+		data: {
+			x: number;
+			y: number;
+		}[];
+	}[];
+} = null;
+
+const chartEl = ref<HTMLCanvasElement>(null);
+const fetching = ref(true);
+
+const getDate = (ago: number) => {
+	const y = now.getFullYear();
+	const m = now.getMonth();
+	const d = now.getDate();
+	const h = now.getHours();
+
+	return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+};
+
+const format = (arr) => {
+	return arr.map((v, i) => ({
+		x: getDate(i).getTime(),
+		y: v,
+	}));
+};
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+const render = () => {
+	if (chartInstance) {
+		chartInstance.destroy();
+	}
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
+
+	chartInstance = new Chart(chartEl.value, {
+		type: props.bar ? 'bar' : 'line',
+		data: {
+			labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+			datasets: chartData.series.map((x, i) => ({
+				parsing: false,
+				label: x.name,
+				data: x.data.slice().reverse(),
+				tension: 0.3,
+				pointRadius: 0,
+				borderWidth: props.bar ? 0 : 2,
+				borderColor: x.color ? x.color : getColor(i),
+				borderDash: x.dashed ? [5, 5] : [],
+				borderJoinStyle: 'round',
+				borderRadius: props.bar ? 3 : undefined,
+				backgroundColor: props.bar ? (x.color ? x.color : getColor(i)) : alpha(x.color ? x.color : getColor(i), 0.1),
+				gradient: props.bar ? undefined : {
+					backgroundColor: {
+						axis: 'y',
+						colors: {
+							0: alpha(x.color ? x.color : getColor(i), 0),
+							[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.35),
+						},
+					},
+				},
+				barPercentage: 0.9,
+				categoryPercentage: 0.9,
+				fill: x.type === 'area',
+				clip: 8,
+				hidden: !!x.hidden,
+			})),
+		},
+		options: {
+			aspectRatio: props.aspectRatio || 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					stacked: props.stacked,
+					offset: false,
+					time: {
+						stepSize: 1,
+						unit: props.span === 'day' ? 'month' : 'day',
+					},
+					grid: {
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: props.detailed,
+						maxRotation: 0,
+						autoSkipPadding: 16,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+					min: getDate(props.limit).getTime(),
+				},
+				y: {
+					position: 'left',
+					stacked: props.stacked,
+					suggestedMax: 50,
+					grid: {
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: props.detailed,
+						//mirror: true,
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+				mode: 'index',
+			},
+			elements: {
+				point: {
+					hoverRadius: 5,
+					hoverBorderWidth: 2,
+				},
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: props.detailed,
+					position: 'bottom',
+					labels: {
+						boxWidth: 16,
+					},
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+				zoom: props.detailed ? {
+					pan: {
+						enabled: true,
+					},
+					zoom: {
+						wheel: {
+							enabled: true,
+						},
+						pinch: {
+							enabled: true,
+						},
+						drag: {
+							enabled: false,
+						},
+						mode: 'x',
+					},
+					limits: {
+						x: {
+							min: 'original',
+							max: 'original',
+						},
+						y: {
+							min: 'original',
+							max: 'original',
+						},
+					},
+				} : undefined,
+				gradient,
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+};
+
+const exportData = () => {
+	// TODO
+};
+
+const fetchFederationChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Received',
+			type: 'area',
+			data: format(raw.inboxInstances),
+			color: colors.blue,
+		}, {
+			name: 'Delivered',
+			type: 'area',
+			data: format(raw.deliveredInstances),
+			color: colors.green,
+		}, {
+			name: 'Stalled',
+			type: 'area',
+			data: format(raw.stalled),
+			color: colors.red,
+		}, {
+			name: 'Pub Active',
+			type: 'line',
+			data: format(raw.pubActive),
+			color: colors.purple,
+		}, {
+			name: 'Sub Active',
+			type: 'line',
+			data: format(raw.subActive),
+			color: colors.orange,
+		}, {
+			name: 'Pub & Sub',
+			type: 'line',
+			data: format(raw.pubsub),
+			dashed: true,
+			color: colors.cyan,
+		}, {
+			name: 'Pub',
+			type: 'line',
+			data: format(raw.pub),
+			dashed: true,
+			color: colors.purple,
+		}, {
+			name: 'Sub',
+			type: 'line',
+			data: format(raw.sub),
+			dashed: true,
+			color: colors.orange,
+		}],
+	};
+};
+
+const fetchApRequestChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'In',
+			type: 'area',
+			color: '#008FFB',
+			data: format(raw.inboxReceived),
+		}, {
+			name: 'Out (succ)',
+			type: 'area',
+			color: '#00E396',
+			data: format(raw.deliverSucceeded),
+		}, {
+			name: 'Out (fail)',
+			type: 'area',
+			color: '#FEB019',
+			data: format(raw.deliverFailed),
+		}],
+	};
+};
+
+const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'All',
+			type: 'line',
+			data: format(type === 'combined'
+				? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+				: sum(raw[type].inc, negate(raw[type].dec)),
+			),
+			color: '#888888',
+		}, {
+			name: 'Renotes',
+			type: 'area',
+			data: format(type === 'combined'
+				? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
+				: raw[type].diffs.renote,
+			),
+			color: colors.green,
+		}, {
+			name: 'Replies',
+			type: 'area',
+			data: format(type === 'combined'
+				? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
+				: raw[type].diffs.reply,
+			),
+			color: colors.yellow,
+		}, {
+			name: 'Normal',
+			type: 'area',
+			data: format(type === 'combined'
+				? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
+				: raw[type].diffs.normal,
+			),
+			color: colors.blue,
+		}, {
+			name: 'With file',
+			type: 'area',
+			data: format(type === 'combined'
+				? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
+				: raw[type].diffs.withFile,
+			),
+			color: colors.purple,
+		}],
+	};
+};
+
+const fetchNotesTotalChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Combined',
+			type: 'line',
+			data: format(sum(raw.local.total, raw.remote.total)),
+		}, {
+			name: 'Local',
+			type: 'area',
+			data: format(raw.local.total),
+		}, {
+			name: 'Remote',
+			type: 'area',
+			data: format(raw.remote.total),
+		}],
+	};
+};
+
+const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Combined',
+			type: 'line',
+			data: format(total
+				? sum(raw.local.total, raw.remote.total)
+				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
+			),
+		}, {
+			name: 'Local',
+			type: 'area',
+			data: format(total
+				? raw.local.total
+				: sum(raw.local.inc, negate(raw.local.dec)),
+			),
+		}, {
+			name: 'Remote',
+			type: 'area',
+			data: format(total
+				? raw.remote.total
+				: sum(raw.remote.inc, negate(raw.remote.dec)),
+			),
+		}],
+	};
+};
+
+const fetchActiveUsersChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Read & Write',
+			type: 'area',
+			data: format(raw.readWrite),
+			color: colors.orange,
+		}, {
+			name: 'Write',
+			type: 'area',
+			data: format(raw.write),
+			color: colors.lime,
+		}, {
+			name: 'Read',
+			type: 'area',
+			data: format(raw.read),
+			color: colors.blue,
+		}, {
+			name: '< Week',
+			type: 'area',
+			data: format(raw.registeredWithinWeek),
+			color: colors.green,
+		}, {
+			name: '< Month',
+			type: 'area',
+			data: format(raw.registeredWithinMonth),
+			color: colors.yellow,
+		}, {
+			name: '< Year',
+			type: 'area',
+			data: format(raw.registeredWithinYear),
+			color: colors.red,
+		}, {
+			name: '> Week',
+			type: 'area',
+			data: format(raw.registeredOutsideWeek),
+			color: colors.yellow,
+		}, {
+			name: '> Month',
+			type: 'area',
+			data: format(raw.registeredOutsideMonth),
+			color: colors.red,
+		}, {
+			name: '> Year',
+			type: 'area',
+			data: format(raw.registeredOutsideYear),
+			color: colors.purple,
+		}],
+	};
+};
+
+const fetchDriveChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
+	return {
+		bytes: true,
+		series: [{
+			name: 'All',
+			type: 'line',
+			dashed: true,
+			data: format(
+				sum(
+					raw.local.incSize,
+					negate(raw.local.decSize),
+					raw.remote.incSize,
+					negate(raw.remote.decSize),
+				),
+			),
+		}, {
+			name: 'Local +',
+			type: 'area',
+			data: format(raw.local.incSize),
+		}, {
+			name: 'Local -',
+			type: 'area',
+			data: format(negate(raw.local.decSize)),
+		}, {
+			name: 'Remote +',
+			type: 'area',
+			data: format(raw.remote.incSize),
+		}, {
+			name: 'Remote -',
+			type: 'area',
+			data: format(negate(raw.remote.decSize)),
+		}],
+	};
+};
+
+const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'All',
+			type: 'line',
+			dashed: true,
+			data: format(
+				sum(
+					raw.local.incCount,
+					negate(raw.local.decCount),
+					raw.remote.incCount,
+					negate(raw.remote.decCount),
+				),
+			),
+		}, {
+			name: 'Local +',
+			type: 'area',
+			data: format(raw.local.incCount),
+		}, {
+			name: 'Local -',
+			type: 'area',
+			data: format(negate(raw.local.decCount)),
+		}, {
+			name: 'Remote +',
+			type: 'area',
+			data: format(raw.remote.incCount),
+		}, {
+			name: 'Remote -',
+			type: 'area',
+			data: format(negate(raw.remote.decCount)),
+		}],
+	};
+};
+
+const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'In',
+			type: 'area',
+			color: '#008FFB',
+			data: format(raw.requests.received),
+		}, {
+			name: 'Out (succ)',
+			type: 'area',
+			color: '#00E396',
+			data: format(raw.requests.succeeded),
+		}, {
+			name: 'Out (fail)',
+			type: 'area',
+			color: '#FEB019',
+			data: format(raw.requests.failed),
+		}],
+	};
+};
+
+const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Users',
+			type: 'area',
+			color: '#008FFB',
+			data: format(total
+				? raw.users.total
+				: sum(raw.users.inc, negate(raw.users.dec)),
+			),
+		}],
+	};
+};
+
+const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Notes',
+			type: 'area',
+			color: '#008FFB',
+			data: format(total
+				? raw.notes.total
+				: sum(raw.notes.inc, negate(raw.notes.dec)),
+			),
+		}],
+	};
+};
+
+const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Following',
+			type: 'area',
+			color: '#008FFB',
+			data: format(total
+				? raw.following.total
+				: sum(raw.following.inc, negate(raw.following.dec)),
+			),
+		}, {
+			name: 'Followers',
+			type: 'area',
+			color: '#00E396',
+			data: format(total
+				? raw.followers.total
+				: sum(raw.followers.inc, negate(raw.followers.dec)),
+			),
+		}],
+	};
+};
+
+const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		bytes: true,
+		series: [{
+			name: 'Drive usage',
+			type: 'area',
+			color: '#008FFB',
+			data: format(total
+				? raw.drive.totalUsage
+				: sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
+			),
+		}],
+	};
+};
+
+const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Drive files',
+			type: 'area',
+			color: '#008FFB',
+			data: format(total
+				? raw.drive.totalFiles
+				: sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
+			),
+		}],
+	};
+};
+
+const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
+	return {
+		series: [...(props.args.withoutAll ? [] : [{
+			name: 'All',
+			type: 'line',
+			data: format(sum(raw.inc, negate(raw.dec))),
+			color: '#888888',
+		}]), {
+			name: 'With file',
+			type: 'area',
+			data: format(raw.diffs.withFile),
+			color: colors.purple,
+		}, {
+			name: 'Renotes',
+			type: 'area',
+			data: format(raw.diffs.renote),
+			color: colors.green,
+		}, {
+			name: 'Replies',
+			type: 'area',
+			data: format(raw.diffs.reply),
+			color: colors.yellow,
+		}, {
+			name: 'Normal',
+			type: 'area',
+			data: format(raw.diffs.normal),
+			color: colors.blue,
+		}],
+	};
+};
+
+const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Local',
+			type: 'area',
+			data: format(raw.local.followings.total),
+		}, {
+			name: 'Remote',
+			type: 'area',
+			data: format(raw.remote.followings.total),
+		}],
+	};
+};
+
+const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Local',
+			type: 'area',
+			data: format(raw.local.followers.total),
+		}, {
+			name: 'Remote',
+			type: 'area',
+			data: format(raw.remote.followers.total),
+		}],
+	};
+};
+
+const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Inc',
+			type: 'area',
+			data: format(raw.incSize),
+		}, {
+			name: 'Dec',
+			type: 'area',
+			data: format(raw.decSize),
+		}],
+	};
+};
+
+const fetchAndRender = async () => {
+	const fetchData = () => {
+		switch (props.src) {
+			case 'federation': return fetchFederationChart();
+			case 'ap-request': return fetchApRequestChart();
+			case 'users': return fetchUsersChart(false);
+			case 'users-total': return fetchUsersChart(true);
+			case 'active-users': return fetchActiveUsersChart();
+			case 'notes': return fetchNotesChart('combined');
+			case 'local-notes': return fetchNotesChart('local');
+			case 'remote-notes': return fetchNotesChart('remote');
+			case 'notes-total': return fetchNotesTotalChart();
+			case 'drive': return fetchDriveChart();
+			case 'drive-files': return fetchDriveFilesChart();
+			case 'instance-requests': return fetchInstanceRequestsChart();
+			case 'instance-users': return fetchInstanceUsersChart(false);
+			case 'instance-users-total': return fetchInstanceUsersChart(true);
+			case 'instance-notes': return fetchInstanceNotesChart(false);
+			case 'instance-notes-total': return fetchInstanceNotesChart(true);
+			case 'instance-ff': return fetchInstanceFfChart(false);
+			case 'instance-ff-total': return fetchInstanceFfChart(true);
+			case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false);
+			case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true);
+			case 'instance-drive-files': return fetchInstanceDriveFilesChart(false);
+			case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
+
+			case 'per-user-notes': return fetchPerUserNotesChart();
+			case 'per-user-following': return fetchPerUserFollowingChart();
+			case 'per-user-followers': return fetchPerUserFollowersChart();
+			case 'per-user-drive': return fetchPerUserDriveChart();
+		}
+	};
+	fetching.value = true;
+	chartData = await fetchData();
+	fetching.value = false;
+	render();
+};
+
+watch(() => [props.src, props.span], fetchAndRender);
+
+onMounted(() => {
+	fetchAndRender();
+});
+/* eslint-enable id-denylist */
+</script>
+
+<style lang="scss" scoped>
+.cbbedffa {
+	position: relative;
+
+	> .fetching {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		-webkit-backdrop-filter: var(--blur, blur(12px));
+		backdrop-filter: var(--blur, blur(12px));
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		cursor: wait;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue
new file mode 100644
index 0000000000..d36f45463c
--- /dev/null
+++ b/packages/frontend/src/components/MkChartTooltip.vue
@@ -0,0 +1,53 @@
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
+	<div v-if="title || series" class="qpcyisrl">
+		<div v-if="title" class="title">{{ title }}</div>
+		<template v-if="series">
+			<div v-for="x in series" class="series">
+				<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span>
+				<span>{{ x.text }}</span>
+			</div>
+		</template>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from './MkTooltip.vue';
+
+defineProps<{
+	showing: boolean;
+	x: number;
+	y: number;
+	title?: string;
+	series?: {
+		backgroundColor: string;
+		borderColor: string;
+		text: string;
+	}[];
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.qpcyisrl {
+	> .title {
+		margin-bottom: 4px;
+	}
+
+	> .series {
+		> .color {
+			display: inline-block;
+			width: 8px;
+			height: 8px;
+			border-width: 1px;
+			border-style: solid;
+			margin-right: 8px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
new file mode 100644
index 0000000000..b074028821
--- /dev/null
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -0,0 +1,20 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+<code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code>
+<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import Prism from 'prismjs';
+import 'prismjs/themes/prism-okaidia.css';
+
+const props = defineProps<{
+	code: string;
+	lang?: string;
+	inline?: boolean;
+}>();
+
+const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
+const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
+</script>
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
new file mode 100644
index 0000000000..1640258d5b
--- /dev/null
+++ b/packages/frontend/src/components/MkCode.vue
@@ -0,0 +1,15 @@
+<template>
+<XCode :code="code" :lang="lang" :inline="inline"/>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+
+defineProps<{
+	code: string;
+	lang?: string;
+	inline?: boolean;
+}>();
+
+const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
+</script>
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
new file mode 100644
index 0000000000..6d4d5be2bc
--- /dev/null
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -0,0 +1,275 @@
+<template>
+<div v-size="{ max: [380] }" class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }">
+	<header v-if="showHeader" ref="header">
+		<div class="title"><slot name="header"></slot></div>
+		<div class="sub">
+			<slot name="func"></slot>
+			<button v-if="foldable" class="_button" @click="() => showBody = !showBody">
+				<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
+				<template v-else><i class="ti ti-chevron-down"></i></template>
+			</button>
+		</div>
+	</header>
+	<transition
+		:name="$store.state.animation ? 'container-toggle' : ''"
+		@enter="enter"
+		@after-enter="afterEnter"
+		@leave="leave"
+		@after-leave="afterLeave"
+	>
+		<div v-show="showBody" ref="content" class="content" :class="{ omitted }">
+			<slot></slot>
+			<button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
+				<span>{{ $ts.showMore }}</span>
+			</button>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+	props: {
+		showHeader: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
+		thin: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		naked: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		foldable: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		expanded: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
+		scrollable: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		maxHeight: {
+			type: Number,
+			required: false,
+			default: null,
+		},
+	},
+	data() {
+		return {
+			showBody: this.expanded,
+			omitted: null,
+			ignoreOmit: false,
+		};
+	},
+	mounted() {
+		this.$watch('showBody', showBody => {
+			const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
+			this.$el.style.minHeight = `${headerHeight}px`;
+			if (showBody) {
+				this.$el.style.flexBasis = 'auto';
+			} else {
+				this.$el.style.flexBasis = `${headerHeight}px`;
+			}
+		}, {
+			immediate: true,
+		});
+
+		this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
+
+		const calcOmit = () => {
+			if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
+			const height = this.$refs.content.offsetHeight;
+			this.omitted = height > this.maxHeight;
+		};
+
+		calcOmit();
+		new ResizeObserver((entries, observer) => {
+			calcOmit();
+		}).observe(this.$refs.content);
+	},
+	methods: {
+		toggleContent(show: boolean) {
+			if (!this.foldable) return;
+			this.showBody = show;
+		},
+
+		enter(el) {
+			const elementHeight = el.getBoundingClientRect().height;
+			el.style.height = 0;
+			el.offsetHeight; // reflow
+			el.style.height = elementHeight + 'px';
+		},
+		afterEnter(el) {
+			el.style.height = null;
+		},
+		leave(el) {
+			const elementHeight = el.getBoundingClientRect().height;
+			el.style.height = elementHeight + 'px';
+			el.offsetHeight; // reflow
+			el.style.height = 0;
+		},
+		afterLeave(el) {
+			el.style.height = null;
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.container-toggle-enter-active, .container-toggle-leave-active {
+	overflow-y: hidden;
+	transition: opacity 0.5s, height 0.5s !important;
+}
+.container-toggle-enter-from {
+	opacity: 0;
+}
+.container-toggle-leave-to {
+	opacity: 0;
+}
+
+.ukygtjoj {
+	position: relative;
+	overflow: clip;
+	contain: content;
+
+	&.naked {
+		background: transparent !important;
+		box-shadow: none !important;
+	}
+
+	&.scrollable {
+		display: flex;
+		flex-direction: column;
+
+		> .content {
+			overflow: auto;
+		}
+	}
+
+	> header {
+		position: sticky;
+		top: var(--stickyTop, 0px);
+		left: 0;
+		color: var(--panelHeaderFg);
+		background: var(--panelHeaderBg);
+		border-bottom: solid 0.5px var(--panelHeaderDivider);
+		z-index: 2;
+		line-height: 1.4em;
+
+		> .title {
+			margin: 0;
+			padding: 12px 16px;
+
+			> ::v-deep(i) {
+				margin-right: 6px;
+			}
+
+			&:empty {
+				display: none;
+			}
+		}
+
+		> .sub {
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			right: 0;
+			height: 100%;
+
+			> ::v-deep(button) {
+				width: 42px;
+				height: 100%;
+			}
+		}
+	}
+
+	> .content {
+		--stickyTop: 0px;
+
+		&.omitted {
+			position: relative;
+			max-height: var(--maxHeight);
+			overflow: hidden;
+
+			> .fade {
+				display: block;
+				position: absolute;
+				z-index: 10;
+				bottom: 0;
+				left: 0;
+				width: 100%;
+				height: 64px;
+				background: linear-gradient(0deg, var(--panel), var(--X15));
+
+				> span {
+					display: inline-block;
+					background: var(--panel);
+					padding: 6px 10px;
+					font-size: 0.8em;
+					border-radius: 999px;
+					box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+				}
+
+				&:hover {
+					> span {
+						background: var(--panelHighlight);
+					}
+				}
+			}
+		}
+	}
+
+	&.max-width_380px, &.thin {
+		> header {
+			> .title {
+				padding: 8px 10px;
+				font-size: 0.9em;
+			}
+		}
+
+		> .content {
+		}
+	}
+}
+
+@container (max-width: 380px) {
+	.ukygtjoj {
+		> header {
+			> .title {
+				padding: 8px 10px;
+				font-size: 0.9em;
+			}
+		}
+	}
+}
+
+._forceContainerFull_ .ukygtjoj {
+	> header {
+		> .title {
+			padding: 12px 16px !important;
+		}
+	}
+}
+
+._forceContainerFull_.ukygtjoj {
+	> header {
+		> .title {
+			padding: 12px 16px !important;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
new file mode 100644
index 0000000000..cfc9502b41
--- /dev/null
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -0,0 +1,85 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" appear>
+	<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
+		<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import MkMenu from './MkMenu.vue';
+import { MenuItem } from './types/menu.vue';
+import contains from '@/scripts/contains';
+import * as os from '@/os';
+
+const props = defineProps<{
+	items: MenuItem[];
+	ev: MouseEvent;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+let rootEl = $ref<HTMLDivElement>();
+
+let zIndex = $ref<number>(os.claimZIndex('high'));
+
+onMounted(() => {
+	let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+	let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+
+	const width = rootEl.offsetWidth;
+	const height = rootEl.offsetHeight;
+
+	if (left + width - window.pageXOffset > window.innerWidth) {
+		left = window.innerWidth - width + window.pageXOffset;
+	}
+
+	if (top + height - window.pageYOffset > window.innerHeight) {
+		top = window.innerHeight - height + window.pageYOffset;
+	}
+
+	if (top < 0) {
+		top = 0;
+	}
+
+	if (left < 0) {
+		left = 0;
+	}
+
+	rootEl.style.top = `${top}px`;
+	rootEl.style.left = `${left}px`;
+
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.addEventListener('mousedown', onMousedown);
+	}
+});
+
+onBeforeUnmount(() => {
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.removeEventListener('mousedown', onMousedown);
+	}
+});
+
+function onMousedown(evt: Event) {
+	if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed');
+}
+</script>
+
+<style lang="scss" scoped>
+.nvlagfpb {
+	position: absolute;
+}
+
+.fade-enter-active, .fade-leave-active {
+	transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
+	transform-origin: left top;
+}
+
+.fade-enter-from, .fade-leave-to {
+	opacity: 0;
+	transform: scale(0.9);
+}
+</style>
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
new file mode 100644
index 0000000000..ae18160dea
--- /dev/null
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -0,0 +1,174 @@
+<template>
+<XModalWindow
+	ref="dialogEl"
+	:width="800"
+	:height="500"
+	:scroll="false"
+	:with-ok-button="true"
+	@close="cancel()"
+	@ok="ok()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ i18n.ts.cropImage }}</template>
+	<template #default="{ width, height }">
+		<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
+			<Transition name="fade">
+				<div v-if="loading" class="loading">
+					<MkLoading/>
+				</div>
+			</Transition>
+			<div class="container">
+				<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
+			</div>
+		</div>
+	</template>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import Cropper from 'cropperjs';
+import tinycolor from 'tinycolor2';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+import { apiUrl } from '@/config';
+import { i18n } from '@/i18n';
+import { getProxiedImageUrl } from '@/scripts/media-proxy';
+
+const emit = defineEmits<{
+	(ev: 'ok', cropped: misskey.entities.DriveFile): void;
+	(ev: 'cancel'): void;
+	(ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+	file: misskey.entities.DriveFile;
+	aspectRatio: number;
+}>();
+
+const imgUrl = getProxiedImageUrl(props.file.url);
+let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
+let imgEl = $ref<HTMLImageElement>();
+let cropper: Cropper | null = null;
+let loading = $ref(true);
+
+const ok = async () => {
+	const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
+		const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
+		croppedCanvas.toBlob(blob => {
+			const formData = new FormData();
+			formData.append('file', blob);
+			formData.append('i', $i.token);
+			if (defaultStore.state.uploadFolder) {
+				formData.append('folderId', defaultStore.state.uploadFolder);
+			}
+
+			window.fetch(apiUrl + '/drive/files/create', {
+				method: 'POST',
+				body: formData,
+			})
+				.then(response => response.json())
+				.then(f => {
+					res(f);
+				});
+		});
+	});
+
+	os.promiseDialog(promise);
+
+	const f = await promise;
+
+	emit('ok', f);
+	dialogEl.close();
+};
+
+const cancel = () => {
+	emit('cancel');
+	dialogEl.close();
+};
+
+const onImageLoad = () => {
+	loading = false;
+
+	if (cropper) {
+		cropper.getCropperImage()!.$center('contain');
+		cropper.getCropperSelection()!.$center();
+	}
+};
+
+onMounted(() => {
+	cropper = new Cropper(imgEl, {
+	});
+
+	const computedStyle = getComputedStyle(document.documentElement);
+
+	const selection = cropper.getCropperSelection()!;
+	selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
+	selection.aspectRatio = props.aspectRatio;
+	selection.initialAspectRatio = props.aspectRatio;
+	selection.outlined = true;
+
+	window.setTimeout(() => {
+		cropper.getCropperImage()!.$center('contain');
+		selection.$center();
+	}, 100);
+
+	// モーダルオープンアニメーションが終わったあとで再度調整
+	window.setTimeout(() => {
+		cropper.getCropperImage()!.$center('contain');
+		selection.$center();
+	}, 500);
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.5s ease 0.5s;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.mk-cropper-dialog {
+	display: flex;
+	flex-direction: column;
+	width: var(--vw);
+	height: var(--vh);
+	position: relative;
+
+	> .loading {
+		position: absolute;
+		z-index: 10;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		-webkit-backdrop-filter: var(--blur, blur(10px));
+		backdrop-filter: var(--blur, blur(10px));
+		background: rgba(0, 0, 0, 0.5);
+	}
+
+	> .container {
+		flex: 1;
+		width: 100%;
+		height: 100%;
+
+		> ::v-deep(cropper-canvas) {
+			width: 100%;
+			height: 100%;
+
+			> cropper-selection > cropper-handle[action="move"] {
+				background: transparent;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue
new file mode 100644
index 0000000000..ee611921ef
--- /dev/null
+++ b/packages/frontend/src/components/MkCwButton.vue
@@ -0,0 +1,62 @@
+<template>
+<button class="nrvgflfu _button" @click="toggle">
+	<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
+	<span v-if="!modelValue">{{ label }}</span>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { length } from 'stringz';
+import * as misskey from 'misskey-js';
+import { concat } from '@/scripts/array';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: boolean;
+	note: misskey.entities.Note;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', v: boolean): void;
+}>();
+
+const label = computed(() => {
+	return concat([
+		props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
+		props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
+		props.note.poll != null ? [i18n.ts.poll] : [],
+	] as string[][]).join(' / ');
+});
+
+const toggle = () => {
+	emit('update:modelValue', !props.modelValue);
+};
+</script>
+
+<style lang="scss" scoped>
+.nrvgflfu {
+	display: inline-block;
+	padding: 4px 8px;
+	font-size: 0.7em;
+	color: var(--cwFg);
+	background: var(--cwBg);
+	border-radius: 2px;
+
+	&:hover {
+		background: var(--cwHoverBg);
+	}
+
+	> span {
+		margin-left: 4px;
+
+		&:before {
+			content: '(';
+		}
+
+		&:after {
+			content: ')';
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
new file mode 100644
index 0000000000..1f88bdf137
--- /dev/null
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -0,0 +1,189 @@
+<script lang="ts">
+import { defineComponent, h, PropType, TransitionGroup } from 'vue';
+import MkAd from '@/components/global/MkAd.vue';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+	props: {
+		items: {
+			type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
+			required: true,
+		},
+		direction: {
+			type: String,
+			required: false,
+			default: 'down',
+		},
+		reversed: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		noGap: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		ad: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+	},
+
+	setup(props, { slots, expose }) {
+		function getDateText(time: string) {
+			const date = new Date(time).getDate();
+			const month = new Date(time).getMonth() + 1;
+			return i18n.t('monthAndDay', {
+				month: month.toString(),
+				day: date.toString(),
+			});
+		}
+
+		if (props.items.length === 0) return;
+
+		const renderChildren = () => props.items.map((item, i) => {
+			if (!slots || !slots.default) return;
+
+			const el = slots.default({
+				item: item,
+			})[0];
+			if (el.key == null && item.id) el.key = item.id;
+
+			if (
+				i !== props.items.length - 1 &&
+				new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
+			) {
+				const separator = h('div', {
+					class: 'separator',
+					key: item.id + ':separator',
+				}, h('p', {
+					class: 'date',
+				}, [
+					h('span', [
+						h('i', {
+							class: 'ti ti-chevron-up icon',
+						}),
+						getDateText(item.createdAt),
+					]),
+					h('span', [
+						getDateText(props.items[i + 1].createdAt),
+						h('i', {
+							class: 'ti ti-chevron-down icon',
+						}),
+					]),
+				]));
+
+				return [el, separator];
+			} else {
+				if (props.ad && item._shouldInsertAd_) {
+					return [h(MkAd, {
+						class: 'a', // advertiseの意(ブロッカー対策)
+						key: item.id + ':ad',
+						prefer: ['horizontal', 'horizontal-big'],
+					}), el];
+				} else {
+					return el;
+				}
+			}
+		});
+
+		return () => h(
+			defaultStore.state.animation ? TransitionGroup : 'div',
+			defaultStore.state.animation ? {
+				class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
+				name: 'list',
+				tag: 'div',
+				'data-direction': props.direction,
+				'data-reversed': props.reversed ? 'true' : 'false',
+			} : {
+				class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
+			},
+			{ default: renderChildren });
+	},
+});
+</script>
+
+<style lang="scss">
+.sqadhkmv {
+	container-type: inline-size;
+
+	> *:empty {
+		display: none;
+	}
+
+	> *:not(:last-child) {
+		margin-bottom: var(--margin);
+	}
+
+	> .list-move {
+		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+	}
+
+	> .list-enter-active {
+		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+	}
+
+	&[data-direction="up"] {
+		> .list-enter-from {
+			opacity: 0;
+			transform: translateY(64px);
+		}
+	}
+
+	&[data-direction="down"] {
+		> .list-enter-from {
+			opacity: 0;
+			transform: translateY(-64px);
+		}
+	}
+
+	> .separator {
+		text-align: center;
+
+		> .date {
+			display: inline-block;
+			position: relative;
+			margin: 0;
+			padding: 0 16px;
+			line-height: 32px;
+			text-align: center;
+			font-size: 12px;
+			color: var(--dateLabelFg);
+
+			> span {
+				&:first-child {
+					margin-right: 8px;
+
+					> .icon {
+						margin-right: 8px;
+					}
+				}
+
+				&:last-child {
+					margin-left: 8px;
+
+					> .icon {
+						margin-left: 8px;
+					}
+				}
+			}
+		}
+	}
+
+	&.noGap {
+		> * {
+			margin: 0 !important;
+			border: none;
+			border-radius: 0;
+			box-shadow: none;
+
+			&:not(:last-child) {
+				border-bottom: solid 0.5px var(--divider);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
new file mode 100644
index 0000000000..374ecd8abf
--- /dev/null
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -0,0 +1,208 @@
+<template>
+<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
+	<div class="mk-dialog">
+		<div v-if="icon" class="icon">
+			<i :class="icon"></i>
+		</div>
+		<div v-else-if="!input && !select" class="icon" :class="type">
+			<i v-if="type === 'success'" class="ti ti-check"></i>
+			<i v-else-if="type === 'error'" class="ti ti-circle-x"></i>
+			<i v-else-if="type === 'warning'" class="ti ti-alert-triangle"></i>
+			<i v-else-if="type === 'info'" class="ti ti-info-circle"></i>
+			<i v-else-if="type === 'question'" class="ti ti-question-circle"></i>
+			<MkLoading v-else-if="type === 'waiting'" :em="true"/>
+		</div>
+		<header v-if="title"><Mfm :text="title"/></header>
+		<div v-if="text" class="body"><Mfm :text="text"/></div>
+		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
+			<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
+		</MkInput>
+		<MkSelect v-if="select" v-model="selectedValue" autofocus>
+			<template v-if="select.items">
+				<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
+			</template>
+			<template v-else>
+				<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
+					<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
+				</optgroup>
+			</template>
+		</MkSelect>
+		<div v-if="(showOkButton || showCancelButton) && !actions" class="buttons">
+			<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton>
+			<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
+		</div>
+		<div v-if="actions" class="buttons">
+			<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
+		</div>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import { i18n } from '@/i18n';
+
+type Input = {
+	type: HTMLInputElement['type'];
+	placeholder?: string | null;
+	default: any | null;
+};
+
+type Select = {
+	items: {
+		value: string;
+		text: string;
+	}[];
+	groupedItems: {
+		label: string;
+		items: {
+			value: string;
+			text: string;
+		}[];
+	}[];
+	default: string | null;
+};
+
+const props = withDefaults(defineProps<{
+	type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
+	title: string;
+	text?: string;
+	input?: Input;
+	select?: Select;
+	icon?: string;
+	actions?: {
+		text: string;
+		primary?: boolean,
+		callback: (...args: any[]) => void;
+	}[];
+	showOkButton?: boolean;
+	showCancelButton?: boolean;
+	cancelableByBgClick?: boolean;
+}>(), {
+	type: 'info',
+	showOkButton: true,
+	showCancelButton: false,
+	cancelableByBgClick: true,
+});
+
+const emit = defineEmits<{
+	(ev: 'done', v: { canceled: boolean; result: any }): void;
+	(ev: 'closed'): void;
+}>();
+
+const modal = ref<InstanceType<typeof MkModal>>();
+
+const inputValue = ref(props.input?.default || null);
+const selectedValue = ref(props.select?.default || null);
+
+function done(canceled: boolean, result?) {
+	emit('done', { canceled, result });
+	modal.value?.close();
+}
+
+async function ok() {
+	if (!props.showOkButton) return;
+
+	const result =
+		props.input ? inputValue.value :
+		props.select ? selectedValue.value :
+		true;
+	done(false, result);
+}
+
+function cancel() {
+	done(true);
+}
+/*
+function onBgClick() {
+	if (props.cancelableByBgClick) cancel();
+}
+*/
+function onKeydown(evt: KeyboardEvent) {
+	if (evt.key === 'Escape') cancel();
+}
+
+function onInputKeydown(evt: KeyboardEvent) {
+	if (evt.key === 'Enter') {
+		evt.preventDefault();
+		evt.stopPropagation();
+		ok();
+	}
+}
+
+onMounted(() => {
+	document.addEventListener('keydown', onKeydown);
+});
+
+onBeforeUnmount(() => {
+	document.removeEventListener('keydown', onKeydown);
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-dialog {
+	position: relative;
+	padding: 32px;
+	min-width: 320px;
+	max-width: 480px;
+	box-sizing: border-box;
+	text-align: center;
+	background: var(--panel);
+	border-radius: var(--radius);
+
+	> .icon {
+		font-size: 24px;
+
+		&.info {
+			color: #55c4dd;
+		}
+
+		&.success {
+			color: var(--success);
+		}
+
+		&.error {
+			color: var(--error);
+		}
+
+		&.warning {
+			color: var(--warn);
+		}
+
+		> * {
+			display: block;
+			margin: 0 auto;
+		}
+
+		& + header {
+			margin-top: 8px;
+		}
+	}
+
+	> header {
+		margin: 0 0 8px 0;
+		font-weight: bold;
+		font-size: 1.1em;
+
+		& + .body {
+			margin-top: 8px;
+		}
+	}
+
+	> .body {
+		margin: 16px 0 0 0;
+	}
+
+	> .buttons {
+		margin-top: 16px;
+
+		> * {
+			margin: 0 8px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue
new file mode 100644
index 0000000000..9ed8d63d19
--- /dev/null
+++ b/packages/frontend/src/components/MkDigitalClock.vue
@@ -0,0 +1,77 @@
+<template>
+<span class="zjobosdg">
+	<span v-text="hh"></span>
+	<span class="colon" :class="{ showColon }">:</span>
+	<span v-text="mm"></span>
+	<span v-if="showS" class="colon" :class="{ showColon }">:</span>
+	<span v-if="showS" v-text="ss"></span>
+	<span v-if="showMs" class="colon" :class="{ showColon }">:</span>
+	<span v-if="showMs" v-text="ms"></span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+
+const props = withDefaults(defineProps<{
+	showS?: boolean;
+	showMs?: boolean;
+	offset?: number;
+}>(), {
+	showS: true,
+	showMs: false,
+	offset: 0 - new Date().getTimezoneOffset(),
+});
+
+let intervalId;
+const hh = ref('');
+const mm = ref('');
+const ss = ref('');
+const ms = ref('');
+const showColon = ref(false);
+let prevSec: number | null = null;
+
+watch(showColon, (v) => {
+	if (v) {
+		window.setTimeout(() => {
+			showColon.value = false;
+		}, 30);
+	}
+});
+
+const tick = () => {
+	const now = new Date();
+	now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
+	hh.value = now.getHours().toString().padStart(2, '0');
+	mm.value = now.getMinutes().toString().padStart(2, '0');
+	ss.value = now.getSeconds().toString().padStart(2, '0');
+	ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
+	if (now.getSeconds() !== prevSec) showColon.value = true;
+	prevSec = now.getSeconds();
+};
+
+tick();
+
+watch(() => props.showMs, () => {
+	if (intervalId) window.clearInterval(intervalId);
+	intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
+}, { immediate: true });
+
+onUnmounted(() => {
+	window.clearInterval(intervalId);
+});
+</script>
+
+<style lang="scss" scoped>
+.zjobosdg {
+	> .colon {
+		opacity: 0;
+		transition: opacity 1s ease;
+
+		&.showColon {
+			opacity: 1;
+			transition: opacity 0s;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
new file mode 100644
index 0000000000..8c17c0530a
--- /dev/null
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -0,0 +1,334 @@
+<template>
+<div
+	class="ncvczrfv"
+	:class="{ isSelected }"
+	draggable="true"
+	:title="title"
+	@click="onClick"
+	@contextmenu.stop="onContextmenu"
+	@dragstart="onDragstart"
+	@dragend="onDragend"
+>
+	<div v-if="$i?.avatarId == file.id" class="label">
+		<img src="/client-assets/label.svg"/>
+		<p>{{ i18n.ts.avatar }}</p>
+	</div>
+	<div v-if="$i?.bannerId == file.id" class="label">
+		<img src="/client-assets/label.svg"/>
+		<p>{{ i18n.ts.banner }}</p>
+	</div>
+	<div v-if="file.isSensitive" class="label red">
+		<img src="/client-assets/label-red.svg"/>
+		<p>{{ i18n.ts.nsfw }}</p>
+	</div>
+
+	<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+
+	<p class="name">
+		<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
+		<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
+	</p>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+
+const props = withDefaults(defineProps<{
+	file: Misskey.entities.DriveFile;
+	isSelected?: boolean;
+	selectMode?: boolean;
+}>(), {
+	isSelected: false,
+	selectMode: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'chosen', r: Misskey.entities.DriveFile): void;
+	(ev: 'dragstart'): void;
+	(ev: 'dragend'): void;
+}>();
+
+const isDragging = ref(false);
+
+const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
+
+function getMenu() {
+	return [{
+		text: i18n.ts.rename,
+		icon: 'ti ti-forms',
+		action: rename,
+	}, {
+		text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
+		icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
+		action: toggleSensitive,
+	}, {
+		text: i18n.ts.describeFile,
+		icon: 'ti ti-text-caption',
+		action: describe,
+	}, null, {
+		text: i18n.ts.copyUrl,
+		icon: 'ti ti-link',
+		action: copyUrl,
+	}, {
+		type: 'a',
+		href: props.file.url,
+		target: '_blank',
+		text: i18n.ts.download,
+		icon: 'ti ti-download',
+		download: props.file.name,
+	}, null, {
+		text: i18n.ts.delete,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: deleteFile,
+	}];
+}
+
+function onClick(ev: MouseEvent) {
+	if (props.selectMode) {
+		emit('chosen', props.file);
+	} else {
+		os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
+	}
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(getMenu(), ev);
+}
+
+function onDragstart(ev: DragEvent) {
+	if (ev.dataTransfer) {
+		ev.dataTransfer.effectAllowed = 'move';
+		ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
+	}
+	isDragging.value = true;
+
+	emit('dragstart');
+}
+
+function onDragend() {
+	isDragging.value = false;
+	emit('dragend');
+}
+
+function rename() {
+	os.inputText({
+		title: i18n.ts.renameFile,
+		placeholder: i18n.ts.inputNewFileName,
+		default: props.file.name,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		os.api('drive/files/update', {
+			fileId: props.file.id,
+			name: name,
+		});
+	});
+}
+
+function describe() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
+		default: props.file.comment != null ? props.file.comment : '',
+		file: props.file,
+	}, {
+		done: caption => {
+			os.api('drive/files/update', {
+				fileId: props.file.id,
+				comment: caption.length === 0 ? null : caption,
+			});
+		},
+	}, 'closed');
+}
+
+function toggleSensitive() {
+	os.api('drive/files/update', {
+		fileId: props.file.id,
+		isSensitive: !props.file.isSensitive,
+	});
+}
+
+function copyUrl() {
+	copyToClipboard(props.file.url);
+	os.success();
+}
+/*
+function addApp() {
+	alert('not implemented yet');
+}
+*/
+async function deleteFile() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
+	});
+
+	if (canceled) return;
+	os.api('drive/files/delete', {
+		fileId: props.file.id,
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.ncvczrfv {
+	position: relative;
+	padding: 8px 0 0 0;
+	min-height: 180px;
+	border-radius: 8px;
+
+	&, * {
+		cursor: pointer;
+	}
+
+	> * {
+		pointer-events: none;
+	}
+
+	&:hover {
+		background: rgba(#000, 0.05);
+
+		> .label {
+			&:before,
+			&:after {
+				background: #0b65a5;
+			}
+
+			&.red {
+				&:before,
+				&:after {
+					background: #c12113;
+				}
+			}
+		}
+	}
+
+	&:active {
+		background: rgba(#000, 0.1);
+
+		> .label {
+			&:before,
+			&:after {
+				background: #0b588c;
+			}
+
+			&.red {
+				&:before,
+				&:after {
+					background: #ce2212;
+				}
+			}
+		}
+	}
+
+	&.isSelected {
+		background: var(--accent);
+
+		&:hover {
+			background: var(--accentLighten);
+		}
+
+		&:active {
+			background: var(--accentDarken);
+		}
+
+		> .label {
+			&:before,
+			&:after {
+				display: none;
+			}
+		}
+
+		> .name {
+			color: #fff;
+		}
+
+		> .thumbnail {
+			color: #fff;
+		}
+	}
+
+	> .label {
+		position: absolute;
+		top: 0;
+		left: 0;
+		pointer-events: none;
+
+		&:before,
+		&:after {
+			content: "";
+			display: block;
+			position: absolute;
+			z-index: 1;
+			background: #0c7ac9;
+		}
+
+		&:before {
+			top: 0;
+			left: 57px;
+			width: 28px;
+			height: 8px;
+		}
+
+		&:after {
+			top: 57px;
+			left: 0;
+			width: 8px;
+			height: 28px;
+		}
+
+		&.red {
+			&:before,
+			&:after {
+				background: #c12113;
+			}
+		}
+
+		> img {
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			left: 0;
+		}
+
+		> p {
+			position: absolute;
+			z-index: 3;
+			top: 19px;
+			left: -28px;
+			width: 120px;
+			margin: 0;
+			text-align: center;
+			line-height: 28px;
+			color: #fff;
+			transform: rotate(-45deg);
+		}
+	}
+
+	> .thumbnail {
+		width: 110px;
+		height: 110px;
+		margin: auto;
+	}
+
+	> .name {
+		display: block;
+		margin: 4px 0 0 0;
+		font-size: 0.8em;
+		text-align: center;
+		word-break: break-all;
+		color: var(--fg);
+		overflow: hidden;
+
+		> .ext {
+			opacity: 0.5;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
new file mode 100644
index 0000000000..82653ca0b4
--- /dev/null
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -0,0 +1,330 @@
+<template>
+<div
+	class="rghtznwe"
+	:class="{ draghover }"
+	draggable="true"
+	:title="title"
+	@click="onClick"
+	@contextmenu.stop="onContextmenu"
+	@mouseover="onMouseover"
+	@mouseout="onMouseout"
+	@dragover.prevent.stop="onDragover"
+	@dragenter.prevent="onDragenter"
+	@dragleave="onDragleave"
+	@drop.prevent.stop="onDrop"
+	@dragstart="onDragstart"
+	@dragend="onDragend"
+>
+	<p class="name">
+		<template v-if="hover"><i class="ti ti-folder ti-fw"></i></template>
+		<template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template>
+		{{ folder.name }}
+	</p>
+	<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
+		{{ i18n.ts.uploadFolder }}
+	</p>
+	<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+const props = withDefaults(defineProps<{
+	folder: Misskey.entities.DriveFolder;
+	isSelected?: boolean;
+	selectMode?: boolean;
+}>(), {
+	isSelected: false,
+	selectMode: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
+	(ev: 'move', v: Misskey.entities.DriveFolder): void;
+	(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
+	(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
+	(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
+	(ev: 'dragstart'): void;
+	(ev: 'dragend'): void;
+}>();
+
+const hover = ref(false);
+const draghover = ref(false);
+const isDragging = ref(false);
+
+const title = computed(() => props.folder.name);
+
+function checkboxClicked() {
+	emit('chosen', props.folder);
+}
+
+function onClick() {
+	emit('move', props.folder);
+}
+
+function onMouseover() {
+	hover.value = true;
+}
+
+function onMouseout() {
+	hover.value = false;
+}
+
+function onDragover(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	// 自分自身がドラッグされている場合
+	if (isDragging.value) {
+		// 自分自身にはドロップさせない
+		ev.dataTransfer.dropEffect = 'none';
+		return;
+	}
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
+
+	if (isFile || isDriveFile || isDriveFolder) {
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	} else {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+}
+
+function onDragenter() {
+	if (!isDragging.value) draghover.value = true;
+}
+
+function onDragleave() {
+	draghover.value = false;
+}
+
+function onDrop(ev: DragEvent) {
+	draghover.value = false;
+
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length > 0) {
+		for (const file of Array.from(ev.dataTransfer.files)) {
+			emit('upload', file, props.folder);
+		}
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		emit('removeFile', file.id);
+		os.api('drive/files/update', {
+			fileId: file.id,
+			folderId: props.folder.id,
+		});
+	}
+	//#endregion
+
+	//#region ドライブのフォルダ
+	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+	if (driveFolder != null && driveFolder !== '') {
+		const folder = JSON.parse(driveFolder);
+
+		// 移動先が自分自身ならreject
+		if (folder.id === props.folder.id) return;
+
+		emit('removeFolder', folder.id);
+		os.api('drive/folders/update', {
+			folderId: folder.id,
+			parentId: props.folder.id,
+		}).then(() => {
+			// noop
+		}).catch(err => {
+			switch (err) {
+				case 'detected-circular-definition':
+					os.alert({
+						title: i18n.ts.unableToProcess,
+						text: i18n.ts.circularReferenceFolder,
+					});
+					break;
+				default:
+					os.alert({
+						type: 'error',
+						text: i18n.ts.somethingHappened,
+					});
+			}
+		});
+	}
+	//#endregion
+}
+
+function onDragstart(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	ev.dataTransfer.effectAllowed = 'move';
+	ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
+	isDragging.value = true;
+
+	// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+	// (=あなたの子供が、ドラッグを開始しましたよ)
+	emit('dragstart');
+}
+
+function onDragend() {
+	isDragging.value = false;
+	emit('dragend');
+}
+
+function go() {
+	emit('move', props.folder.id);
+}
+
+function rename() {
+	os.inputText({
+		title: i18n.ts.renameFolder,
+		placeholder: i18n.ts.inputNewFolderName,
+		default: props.folder.name,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		os.api('drive/folders/update', {
+			folderId: props.folder.id,
+			name: name,
+		});
+	});
+}
+
+function deleteFolder() {
+	os.api('drive/folders/delete', {
+		folderId: props.folder.id,
+	}).then(() => {
+		if (defaultStore.state.uploadFolder === props.folder.id) {
+			defaultStore.set('uploadFolder', null);
+		}
+	}).catch(err => {
+		switch (err.id) {
+			case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+				os.alert({
+					type: 'error',
+					title: i18n.ts.unableToDelete,
+					text: i18n.ts.hasChildFilesOrFolders,
+				});
+				break;
+			default:
+				os.alert({
+					type: 'error',
+					text: i18n.ts.unableToDelete,
+				});
+		}
+	});
+}
+
+function setAsUploadFolder() {
+	defaultStore.set('uploadFolder', props.folder.id);
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu([{
+		text: i18n.ts.openInWindow,
+		icon: 'ti ti-app-window',
+		action: () => {
+			os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
+				initialFolder: props.folder,
+			}, {
+			}, 'closed');
+		},
+	}, null, {
+		text: i18n.ts.rename,
+		icon: 'ti ti-forms',
+		action: rename,
+	}, null, {
+		text: i18n.ts.delete,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: deleteFolder,
+	}], ev);
+}
+</script>
+
+<style lang="scss" scoped>
+.rghtznwe {
+	position: relative;
+	padding: 8px;
+	height: 64px;
+	background: var(--driveFolderBg);
+	border-radius: 4px;
+
+	&, * {
+		cursor: pointer;
+	}
+
+	*:not(.checkbox) {
+		pointer-events: none;
+	}
+
+	> .checkbox {
+		position: absolute;
+		bottom: 8px;
+		right: 8px;
+		width: 16px;
+		height: 16px;
+		background: #fff;
+		border: solid 1px #000;
+
+		&.checked {
+			background: var(--accent);
+		}
+	}
+
+	&.draghover {
+		&:after {
+			content: "";
+			pointer-events: none;
+			position: absolute;
+			top: -4px;
+			right: -4px;
+			bottom: -4px;
+			left: -4px;
+			border: 2px dashed var(--focus);
+			border-radius: 4px;
+		}
+	}
+
+	> .name {
+		margin: 0;
+		font-size: 0.9em;
+		color: var(--desktopDriveFolderFg);
+
+		> i {
+			margin-right: 4px;
+			margin-left: 2px;
+			text-align: left;
+		}
+	}
+
+	> .upload {
+		margin: 4px 4px;
+		font-size: 0.8em;
+		text-align: right;
+		color: var(--desktopDriveFolderFg);
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue
new file mode 100644
index 0000000000..dbbfef5f05
--- /dev/null
+++ b/packages/frontend/src/components/MkDrive.navFolder.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="drylbebk"
+	:class="{ draghover }"
+	@click="onClick"
+	@dragover.prevent.stop="onDragover"
+	@dragenter="onDragenter"
+	@dragleave="onDragleave"
+	@drop.stop="onDrop"
+>
+	<i v-if="folder == null" class="ti ti-cloud"></i>
+	<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	folder?: Misskey.entities.DriveFolder;
+	parentFolder: Misskey.entities.DriveFolder | null;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'move', v?: Misskey.entities.DriveFolder): void;
+	(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
+	(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
+	(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
+}>();
+
+const hover = ref(false);
+const draghover = ref(false);
+
+function onClick() {
+	emit('move', props.folder);
+}
+
+function onMouseover() {
+	hover.value = true;
+}
+
+function onMouseout() {
+	hover.value = false;
+}
+
+function onDragover(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	// このフォルダがルートかつカレントディレクトリならドロップ禁止
+	if (props.folder == null && props.parentFolder == null) {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
+
+	if (isFile || isDriveFile || isDriveFolder) {
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	} else {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+
+	return false;
+}
+
+function onDragenter() {
+	if (props.folder || props.parentFolder) draghover.value = true;
+}
+
+function onDragleave() {
+	if (props.folder || props.parentFolder) draghover.value = false;
+}
+
+function onDrop(ev: DragEvent) {
+	draghover.value = false;
+
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length > 0) {
+		for (const file of Array.from(ev.dataTransfer.files)) {
+			emit('upload', file, props.folder);
+		}
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		emit('removeFile', file.id);
+		os.api('drive/files/update', {
+			fileId: file.id,
+			folderId: props.folder ? props.folder.id : null,
+		});
+	}
+	//#endregion
+
+	//#region ドライブのフォルダ
+	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+	if (driveFolder != null && driveFolder !== '') {
+		const folder = JSON.parse(driveFolder);
+		// 移動先が自分自身ならreject
+		if (props.folder && folder.id === props.folder.id) return;
+		emit('removeFolder', folder.id);
+		os.api('drive/folders/update', {
+			folderId: folder.id,
+			parentId: props.folder ? props.folder.id : null,
+		});
+	}
+	//#endregion
+}
+</script>
+
+<style lang="scss" scoped>
+.drylbebk {
+	> * {
+		pointer-events: none;
+	}
+
+	&.draghover {
+		background: #eee;
+	}
+
+	> i {
+		margin-right: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
new file mode 100644
index 0000000000..4053870950
--- /dev/null
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -0,0 +1,801 @@
+<template>
+<div class="yfudmmck">
+	<nav>
+		<div class="path" @contextmenu.prevent.stop="() => {}">
+			<XNavFolder
+				:class="{ current: folder == null }"
+				:parent-folder="folder"
+				@move="move"
+				@upload="upload"
+				@remove-file="removeFile"
+				@remove-folder="removeFolder"
+			/>
+			<template v-for="f in hierarchyFolders">
+				<span class="separator"><i class="ti ti-chevron-right"></i></span>
+				<XNavFolder
+					:folder="f"
+					:parent-folder="folder"
+					@move="move"
+					@upload="upload"
+					@remove-file="removeFile"
+					@remove-folder="removeFolder"
+				/>
+			</template>
+			<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
+			<span v-if="folder != null" class="folder current">{{ folder.name }}</span>
+		</div>
+		<button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
+	</nav>
+	<div
+		ref="main" class="main"
+		:class="{ uploading: uploadings.length > 0, fetching }"
+		@dragover.prevent.stop="onDragover"
+		@dragenter="onDragenter"
+		@dragleave="onDragleave"
+		@drop.prevent.stop="onDrop"
+		@contextmenu.stop="onContextmenu"
+	>
+		<div ref="contents" class="contents">
+			<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
+				<XFolder
+					v-for="(f, i) in folders"
+					:key="f.id"
+					v-anim="i"
+					class="folder"
+					:folder="f"
+					:select-mode="select === 'folder'"
+					:is-selected="selectedFolders.some(x => x.id === f.id)"
+					@chosen="chooseFolder"
+					@move="move"
+					@upload="upload"
+					@remove-file="removeFile"
+					@remove-folder="removeFolder"
+					@dragstart="isDragSource = true"
+					@dragend="isDragSource = false"
+				/>
+				<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+				<div v-for="(n, i) in 16" :key="i" class="padding"></div>
+				<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
+			</div>
+			<div v-show="files.length > 0" ref="filesContainer" class="files">
+				<XFile
+					v-for="(file, i) in files"
+					:key="file.id"
+					v-anim="i"
+					class="file"
+					:file="file"
+					:select-mode="select === 'file'"
+					:is-selected="selectedFiles.some(x => x.id === file.id)"
+					@chosen="chooseFile"
+					@dragstart="isDragSource = true"
+					@dragend="isDragSource = false"
+				/>
+				<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+				<div v-for="(n, i) in 16" :key="i" class="padding"></div>
+				<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
+			</div>
+			<div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
+				<p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
+				<p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
+				<p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
+			</div>
+		</div>
+		<MkLoading v-if="fetching"/>
+	</div>
+	<div v-if="draghover" class="dropzone"></div>
+	<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from './MkButton.vue';
+import XNavFolder from '@/components/MkDrive.navFolder.vue';
+import XFolder from '@/components/MkDrive.folder.vue';
+import XFile from '@/components/MkDrive.file.vue';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { uploadFile, uploads } from '@/scripts/upload';
+
+const props = withDefaults(defineProps<{
+	initialFolder?: Misskey.entities.DriveFolder;
+	type?: string;
+	multiple?: boolean;
+	select?: 'file' | 'folder' | null;
+}>(), {
+	multiple: false,
+	select: null,
+});
+
+const emit = defineEmits<{
+	(ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void;
+	(ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
+	(ev: 'move-root'): void;
+	(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
+	(ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
+}>();
+
+const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
+const fileInput = ref<HTMLInputElement>();
+
+const folder = ref<Misskey.entities.DriveFolder | null>(null);
+const files = ref<Misskey.entities.DriveFile[]>([]);
+const folders = ref<Misskey.entities.DriveFolder[]>([]);
+const moreFiles = ref(false);
+const moreFolders = ref(false);
+const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
+const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
+const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
+const uploadings = uploads;
+const connection = stream.useChannel('drive');
+const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
+
+// ドロップされようとしているか
+const draghover = ref(false);
+
+// 自身の所有するアイテムがドラッグをスタートさせたか
+// (自分自身の階層にドロップできないようにするためのフラグ)
+const isDragSource = ref(false);
+
+const fetching = ref(true);
+
+const ilFilesObserver = new IntersectionObserver(
+	(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
+);
+
+watch(folder, () => emit('cd', folder.value));
+
+function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
+	addFile(file, true);
+}
+
+function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) {
+	const current = folder.value ? folder.value.id : null;
+	if (current !== file.folderId) {
+		removeFile(file);
+	} else {
+		addFile(file, true);
+	}
+}
+
+function onStreamDriveFileDeleted(fileId: string) {
+	removeFile(fileId);
+}
+
+function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) {
+	addFolder(createdFolder, true);
+}
+
+function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) {
+	const current = folder.value ? folder.value.id : null;
+	if (current !== updatedFolder.parentId) {
+		removeFolder(updatedFolder);
+	} else {
+		addFolder(updatedFolder, true);
+	}
+}
+
+function onStreamDriveFolderDeleted(folderId: string) {
+	removeFolder(folderId);
+}
+
+function onDragover(ev: DragEvent): any {
+	if (!ev.dataTransfer) return;
+
+	// ドラッグ元が自分自身の所有するアイテムだったら
+	if (isDragSource.value) {
+		// 自分自身にはドロップさせない
+		ev.dataTransfer.dropEffect = 'none';
+		return;
+	}
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
+	if (isFile || isDriveFile || isDriveFolder) {
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	} else {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+
+	return false;
+}
+
+function onDragenter() {
+	if (!isDragSource.value) draghover.value = true;
+}
+
+function onDragleave() {
+	draghover.value = false;
+}
+
+function onDrop(ev: DragEvent): any {
+	draghover.value = false;
+
+	if (!ev.dataTransfer) return;
+
+	// ドロップされてきたものがファイルだったら
+	if (ev.dataTransfer.files.length > 0) {
+		for (const file of Array.from(ev.dataTransfer.files)) {
+			upload(file, folder.value);
+		}
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		if (files.value.some(f => f.id === file.id)) return;
+		removeFile(file.id);
+		os.api('drive/files/update', {
+			fileId: file.id,
+			folderId: folder.value ? folder.value.id : null,
+		});
+	}
+	//#endregion
+
+	//#region ドライブのフォルダ
+	const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+	if (driveFolder != null && driveFolder !== '') {
+		const droppedFolder = JSON.parse(driveFolder);
+
+		// 移動先が自分自身ならreject
+		if (folder.value && droppedFolder.id === folder.value.id) return false;
+		if (folders.value.some(f => f.id === droppedFolder.id)) return false;
+		removeFolder(droppedFolder.id);
+		os.api('drive/folders/update', {
+			folderId: droppedFolder.id,
+			parentId: folder.value ? folder.value.id : null,
+		}).then(() => {
+			// noop
+		}).catch(err => {
+			switch (err) {
+				case 'detected-circular-definition':
+					os.alert({
+						title: i18n.ts.unableToProcess,
+						text: i18n.ts.circularReferenceFolder,
+					});
+					break;
+				default:
+					os.alert({
+						type: 'error',
+						text: i18n.ts.somethingHappened,
+					});
+			}
+		});
+	}
+	//#endregion
+}
+
+function selectLocalFile() {
+	fileInput.value?.click();
+}
+
+function urlUpload() {
+	os.inputText({
+		title: i18n.ts.uploadFromUrl,
+		type: 'url',
+		placeholder: i18n.ts.uploadFromUrlDescription,
+	}).then(({ canceled, result: url }) => {
+		if (canceled || !url) return;
+		os.api('drive/files/upload-from-url', {
+			url: url,
+			folderId: folder.value ? folder.value.id : undefined,
+		});
+
+		os.alert({
+			title: i18n.ts.uploadFromUrlRequested,
+			text: i18n.ts.uploadFromUrlMayTakeTime,
+		});
+	});
+}
+
+function createFolder() {
+	os.inputText({
+		title: i18n.ts.createFolder,
+		placeholder: i18n.ts.folderName,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		os.api('drive/folders/create', {
+			name: name,
+			parentId: folder.value ? folder.value.id : undefined,
+		}).then(createdFolder => {
+			addFolder(createdFolder, true);
+		});
+	});
+}
+
+function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
+	os.inputText({
+		title: i18n.ts.renameFolder,
+		placeholder: i18n.ts.inputNewFolderName,
+		default: folderToRename.name,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		os.api('drive/folders/update', {
+			folderId: folderToRename.id,
+			name: name,
+		}).then(updatedFolder => {
+			// FIXME: 画面を更新するために自分自身に移動
+			move(updatedFolder);
+		});
+	});
+}
+
+function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
+	os.api('drive/folders/delete', {
+		folderId: folderToDelete.id,
+	}).then(() => {
+		// 削除時に親フォルダに移動
+		move(folderToDelete.parentId);
+	}).catch(err => {
+		switch (err.id) {
+			case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+				os.alert({
+					type: 'error',
+					title: i18n.ts.unableToDelete,
+					text: i18n.ts.hasChildFilesOrFolders,
+				});
+				break;
+			default:
+				os.alert({
+					type: 'error',
+					text: i18n.ts.unableToDelete,
+				});
+		}
+	});
+}
+
+function onChangeFileInput() {
+	if (!fileInput.value?.files) return;
+	for (const file of Array.from(fileInput.value.files)) {
+		upload(file, folder.value);
+	}
+}
+
+function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) {
+	uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
+		addFile(res, true);
+	});
+}
+
+function chooseFile(file: Misskey.entities.DriveFile) {
+	const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
+	if (props.multiple) {
+		if (isAlreadySelected) {
+			selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
+		} else {
+			selectedFiles.value.push(file);
+		}
+		emit('change-selection', selectedFiles.value);
+	} else {
+		if (isAlreadySelected) {
+			emit('selected', file);
+		} else {
+			selectedFiles.value = [file];
+			emit('change-selection', [file]);
+		}
+	}
+}
+
+function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
+	const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id);
+	if (props.multiple) {
+		if (isAlreadySelected) {
+			selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id);
+		} else {
+			selectedFolders.value.push(folderToChoose);
+		}
+		emit('change-selection', selectedFolders.value);
+	} else {
+		if (isAlreadySelected) {
+			emit('selected', folderToChoose);
+		} else {
+			selectedFolders.value = [folderToChoose];
+			emit('change-selection', [folderToChoose]);
+		}
+	}
+}
+
+function move(target?: Misskey.entities.DriveFolder) {
+	if (!target) {
+		goRoot();
+		return;
+	} else if (typeof target === 'object') {
+		target = target.id;
+	}
+
+	fetching.value = true;
+
+	os.api('drive/folders/show', {
+		folderId: target,
+	}).then(folderToMove => {
+		folder.value = folderToMove;
+		hierarchyFolders.value = [];
+
+		const dive = folderToDive => {
+			hierarchyFolders.value.unshift(folderToDive);
+			if (folderToDive.parent) dive(folderToDive.parent);
+		};
+
+		if (folderToMove.parent) dive(folderToMove.parent);
+
+		emit('open-folder', folderToMove);
+		fetch();
+	});
+}
+
+function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
+	const current = folder.value ? folder.value.id : null;
+	if (current !== folderToAdd.parentId) return;
+
+	if (folders.value.some(f => f.id === folderToAdd.id)) {
+		const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id);
+		folders.value[exist] = folderToAdd;
+		return;
+	}
+
+	if (unshift) {
+		folders.value.unshift(folderToAdd);
+	} else {
+		folders.value.push(folderToAdd);
+	}
+}
+
+function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
+	const current = folder.value ? folder.value.id : null;
+	if (current !== fileToAdd.folderId) return;
+
+	if (files.value.some(f => f.id === fileToAdd.id)) {
+		const exist = files.value.map(f => f.id).indexOf(fileToAdd.id);
+		files.value[exist] = fileToAdd;
+		return;
+	}
+
+	if (unshift) {
+		files.value.unshift(fileToAdd);
+	} else {
+		files.value.push(fileToAdd);
+	}
+}
+
+function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
+	const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove;
+	folders.value = folders.value.filter(f => f.id !== folderIdToRemove);
+}
+
+function removeFile(file: Misskey.entities.DriveFile | string) {
+	const fileId = typeof file === 'object' ? file.id : file;
+	files.value = files.value.filter(f => f.id !== fileId);
+}
+
+function appendFile(file: Misskey.entities.DriveFile) {
+	addFile(file);
+}
+
+function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
+	addFolder(folderToAppend);
+}
+/*
+function prependFile(file: Misskey.entities.DriveFile) {
+	addFile(file, true);
+}
+
+function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) {
+	addFolder(folderToPrepend, true);
+}
+*/
+function goRoot() {
+	// 既にrootにいるなら何もしない
+	if (folder.value == null) return;
+
+	folder.value = null;
+	hierarchyFolders.value = [];
+	emit('move-root');
+	fetch();
+}
+
+async function fetch() {
+	folders.value = [];
+	files.value = [];
+	moreFolders.value = false;
+	moreFiles.value = false;
+	fetching.value = true;
+
+	const foldersMax = 30;
+	const filesMax = 30;
+
+	const foldersPromise = os.api('drive/folders', {
+		folderId: folder.value ? folder.value.id : null,
+		limit: foldersMax + 1,
+	}).then(fetchedFolders => {
+		if (fetchedFolders.length === foldersMax + 1) {
+			moreFolders.value = true;
+			fetchedFolders.pop();
+		}
+		return fetchedFolders;
+	});
+
+	const filesPromise = os.api('drive/files', {
+		folderId: folder.value ? folder.value.id : null,
+		type: props.type,
+		limit: filesMax + 1,
+	}).then(fetchedFiles => {
+		if (fetchedFiles.length === filesMax + 1) {
+			moreFiles.value = true;
+			fetchedFiles.pop();
+		}
+		return fetchedFiles;
+	});
+
+	const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]);
+
+	for (const x of fetchedFolders) appendFolder(x);
+	for (const x of fetchedFiles) appendFile(x);
+
+	fetching.value = false;
+}
+
+function fetchMoreFiles() {
+	fetching.value = true;
+
+	const max = 30;
+
+	// ファイル一覧取得
+	os.api('drive/files', {
+		folderId: folder.value ? folder.value.id : null,
+		type: props.type,
+		untilId: files.value[files.value.length - 1].id,
+		limit: max + 1,
+	}).then(files => {
+		if (files.length === max + 1) {
+			moreFiles.value = true;
+			files.pop();
+		} else {
+			moreFiles.value = false;
+		}
+		for (const x of files) appendFile(x);
+		fetching.value = false;
+	});
+}
+
+function getMenu() {
+	return [{
+		type: 'switch',
+		text: i18n.ts.keepOriginalUploading,
+		ref: keepOriginal,
+	}, null, {
+		text: i18n.ts.addFile,
+		type: 'label',
+	}, {
+		text: i18n.ts.upload,
+		icon: 'ti ti-upload',
+		action: () => { selectLocalFile(); },
+	}, {
+		text: i18n.ts.fromUrl,
+		icon: 'ti ti-link',
+		action: () => { urlUpload(); },
+	}, null, {
+		text: folder.value ? folder.value.name : i18n.ts.drive,
+		type: 'label',
+	}, folder.value ? {
+		text: i18n.ts.renameFolder,
+		icon: 'ti ti-forms',
+		action: () => { renameFolder(folder.value); },
+	} : undefined, folder.value ? {
+		text: i18n.ts.deleteFolder,
+		icon: 'ti ti-trash',
+		action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
+	} : undefined, {
+		text: i18n.ts.createFolder,
+		icon: 'ti ti-folder-plus',
+		action: () => { createFolder(); },
+	}];
+}
+
+function showMenu(ev: MouseEvent) {
+	os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(getMenu(), ev);
+}
+
+onMounted(() => {
+	if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
+		nextTick(() => {
+			ilFilesObserver.observe(loadMoreFiles.value?.$el);
+		});
+	}
+
+	connection.on('fileCreated', onStreamDriveFileCreated);
+	connection.on('fileUpdated', onStreamDriveFileUpdated);
+	connection.on('fileDeleted', onStreamDriveFileDeleted);
+	connection.on('folderCreated', onStreamDriveFolderCreated);
+	connection.on('folderUpdated', onStreamDriveFolderUpdated);
+	connection.on('folderDeleted', onStreamDriveFolderDeleted);
+
+	if (props.initialFolder) {
+		move(props.initialFolder);
+	} else {
+		fetch();
+	}
+});
+
+onActivated(() => {
+	if (defaultStore.state.enableInfiniteScroll) {
+		nextTick(() => {
+			ilFilesObserver.observe(loadMoreFiles.value?.$el);
+		});
+	}
+});
+
+onBeforeUnmount(() => {
+	connection.dispose();
+	ilFilesObserver.disconnect();
+});
+</script>
+
+<style lang="scss" scoped>
+.yfudmmck {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+
+	> nav {
+		display: flex;
+		z-index: 2;
+		width: 100%;
+		padding: 0 8px;
+		box-sizing: border-box;
+		overflow: auto;
+		font-size: 0.9em;
+		box-shadow: 0 1px 0 var(--divider);
+
+		&, * {
+			user-select: none;
+		}
+
+		> .path {
+			display: inline-block;
+			vertical-align: bottom;
+			line-height: 42px;
+			white-space: nowrap;
+
+			> * {
+				display: inline-block;
+				margin: 0;
+				padding: 0 8px;
+				line-height: 42px;
+				cursor: pointer;
+
+				* {
+					pointer-events: none;
+				}
+
+				&:hover {
+					text-decoration: underline;
+				}
+
+				&.current {
+					font-weight: bold;
+					cursor: default;
+
+					&:hover {
+						text-decoration: none;
+					}
+				}
+
+				&.separator {
+					margin: 0;
+					padding: 0;
+					opacity: 0.5;
+					cursor: default;
+
+					> i {
+						margin: 0;
+					}
+				}
+			}
+		}
+
+		> .menu {
+			margin-left: auto;
+			padding: 0 12px;
+		}
+	}
+
+	> .main {
+		flex: 1;
+		overflow: auto;
+		padding: var(--margin);
+
+		&, * {
+			user-select: none;
+		}
+
+		&.fetching {
+			cursor: wait !important;
+
+			* {
+				pointer-events: none;
+			}
+
+			> .contents {
+				opacity: 0.5;
+			}
+		}
+
+		&.uploading {
+			height: calc(100% - 38px - 100px);
+		}
+
+		> .contents {
+
+			> .folders,
+			> .files {
+				display: flex;
+				flex-wrap: wrap;
+
+				> .folder,
+				> .file {
+					flex-grow: 1;
+					width: 128px;
+					margin: 4px;
+					box-sizing: border-box;
+				}
+
+				> .padding {
+					flex-grow: 1;
+					pointer-events: none;
+					width: 128px + 8px;
+				}
+			}
+
+			> .empty {
+				padding: 16px;
+				text-align: center;
+				pointer-events: none;
+				opacity: 0.5;
+
+				> p {
+					margin: 0;
+				}
+			}
+		}
+	}
+
+	> .dropzone {
+		position: absolute;
+		left: 0;
+		top: 38px;
+		width: 100%;
+		height: calc(100% - 38px);
+		border: dashed 2px var(--focus);
+		pointer-events: none;
+	}
+
+	> input {
+		display: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue
new file mode 100644
index 0000000000..33379ed5ca
--- /dev/null
+++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue
@@ -0,0 +1,80 @@
+<template>
+<div ref="thumbnail" class="zdjebgpv">
+	<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
+	<i v-else-if="is === 'image'" class="ti ti-photo icon"></i>
+	<i v-else-if="is === 'video'" class="ti ti-video icon"></i>
+	<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i>
+	<i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i>
+	<i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i>
+	<i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i>
+	<i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i>
+	<i v-else class="ti ti-file icon"></i>
+
+	<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+
+const props = defineProps<{
+	file: Misskey.entities.DriveFile;
+	fit: string;
+}>();
+
+const is = computed(() => {
+	if (props.file.type.startsWith('image/')) return 'image';
+	if (props.file.type.startsWith('video/')) return 'video';
+	if (props.file.type === 'audio/midi') return 'midi';
+	if (props.file.type.startsWith('audio/')) return 'audio';
+	if (props.file.type.endsWith('/csv')) return 'csv';
+	if (props.file.type.endsWith('/pdf')) return 'pdf';
+	if (props.file.type.startsWith('text/')) return 'textfile';
+	if ([
+		'application/zip',
+		'application/x-cpio',
+		'application/x-bzip',
+		'application/x-bzip2',
+		'application/java-archive',
+		'application/x-rar-compressed',
+		'application/x-tar',
+		'application/gzip',
+		'application/x-7z-compressed',
+	].some(archiveType => archiveType === props.file.type)) return 'archive';
+	return 'unknown';
+});
+
+const isThumbnailAvailable = computed(() => {
+	return props.file.thumbnailUrl
+		? (is.value === 'image' as const || is.value === 'video')
+		: false;
+});
+</script>
+
+<style lang="scss" scoped>
+.zdjebgpv {
+	position: relative;
+	display: flex;
+	background: var(--panel);
+	border-radius: 8px;
+	overflow: clip;
+
+	> .icon-sub {
+		position: absolute;
+		width: 30%;
+		height: auto;
+		margin: 0;
+		right: 4%;
+		bottom: 4%;
+	}
+
+	> .icon {
+		pointer-events: none;
+		margin: auto;
+		font-size: 32px;
+		color: #777;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue
new file mode 100644
index 0000000000..3ee821b539
--- /dev/null
+++ b/packages/frontend/src/components/MkDriveSelectDialog.vue
@@ -0,0 +1,58 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="800"
+	:height="500"
+	:with-ok-button="true"
+	:ok-button-disabled="(type === 'file') && (selected.length === 0)"
+	@click="cancel()"
+	@close="cancel()"
+	@ok="ok()"
+	@closed="emit('closed')"
+>
+	<template #header>
+		{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
+		<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
+	</template>
+	<XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XDrive from '@/components/MkDrive.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+withDefaults(defineProps<{
+	type?: 'file' | 'folder';
+	multiple: boolean;
+}>(), {
+	type: 'file',
+});
+
+const emit = defineEmits<{
+	(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
+	(ev: 'closed'): void;
+}>();
+
+const dialog = ref<InstanceType<typeof XModalWindow>>();
+
+const selected = ref<Misskey.entities.DriveFile[]>([]);
+
+function ok() {
+	emit('done', selected.value);
+	dialog.value?.close();
+}
+
+function cancel() {
+	emit('done');
+	dialog.value?.close();
+}
+
+function onChangeSelection(files: Misskey.entities.DriveFile[]) {
+	selected.value = files;
+}
+</script>
diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue
new file mode 100644
index 0000000000..617200321b
--- /dev/null
+++ b/packages/frontend/src/components/MkDriveWindow.vue
@@ -0,0 +1,30 @@
+<template>
+<XWindow
+	ref="window"
+	:initial-width="800"
+	:initial-height="500"
+	:can-resize="true"
+	@closed="emit('closed')"
+>
+	<template #header>
+		{{ i18n.ts.drive }}
+	</template>
+	<XDrive :initial-folder="initialFolder"/>
+</XWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import XDrive from '@/components/MkDrive.vue';
+import XWindow from '@/components/MkWindow.vue';
+import { i18n } from '@/i18n';
+
+defineProps<{
+	initialFolder?: Misskey.entities.DriveFolder;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+</script>
diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue
new file mode 100644
index 0000000000..f6ba7abfc4
--- /dev/null
+++ b/packages/frontend/src/components/MkEmojiPicker.section.vue
@@ -0,0 +1,36 @@
+<template>
+<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
+<section>
+	<header class="_acrylic" @click="shown = !shown">
+		<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
+	</header>
+	<div v-if="shown" class="body">
+		<button
+			v-for="emoji in emojis"
+			:key="emoji"
+			class="_button item"
+			@click="emit('chosen', emoji, $event)"
+		>
+			<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
+		</button>
+	</div>
+</section>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const props = defineProps<{
+	emojis: string[];
+	initialShown?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'chosen', v: string, event: MouseEvent): void;
+}>();
+
+const shown = ref(!!props.initialShown);
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
new file mode 100644
index 0000000000..814f71168a
--- /dev/null
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -0,0 +1,569 @@
+<template>
+<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
+	<input ref="search" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()">
+	<div ref="emojis" class="emojis">
+		<section class="result">
+			<div v-if="searchResultCustom.length > 0" class="body">
+				<button
+					v-for="emoji in searchResultCustom"
+					:key="emoji.id"
+					class="_button item"
+					:title="emoji.name"
+					tabindex="0"
+					@click="chosen(emoji, $event)"
+				>
+					<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
+					<img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+				</button>
+			</div>
+			<div v-if="searchResultUnicode.length > 0" class="body">
+				<button
+					v-for="emoji in searchResultUnicode"
+					:key="emoji.name"
+					class="_button item"
+					:title="emoji.name"
+					tabindex="0"
+					@click="chosen(emoji, $event)"
+				>
+					<MkEmoji class="emoji" :emoji="emoji.char"/>
+				</button>
+			</div>
+		</section>
+
+		<div v-if="tab === 'index'" class="group index">
+			<section v-if="showPinned">
+				<div class="body">
+					<button
+						v-for="emoji in pinned"
+						:key="emoji"
+						class="_button item"
+						tabindex="0"
+						@click="chosen(emoji, $event)"
+					>
+						<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
+					</button>
+				</div>
+			</section>
+
+			<section>
+				<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
+				<div class="body">
+					<button
+						v-for="emoji in recentlyUsedEmojis"
+						:key="emoji"
+						class="_button item"
+						@click="chosen(emoji, $event)"
+					>
+						<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
+					</button>
+				</div>
+			</section>
+		</div>
+		<div v-once class="group">
+			<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
+			<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection>
+		</div>
+		<div v-once class="group">
+			<header class="_acrylic">{{ i18n.ts.emoji }}</header>
+			<XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
+		</div>
+	</div>
+	<div class="tabs">
+		<button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ti ti-asterisk ti-fw"></i></button>
+		<button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ti ti-mood-happy ti-fw"></i></button>
+		<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button>
+		<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, onMounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import XSection from '@/components/MkEmojiPicker.section.vue';
+import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import Ripple from '@/components/MkRipple.vue';
+import * as os from '@/os';
+import { isTouchUsing } from '@/scripts/touch';
+import { deviceKind } from '@/scripts/device-kind';
+import { emojiCategories, instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+const props = withDefaults(defineProps<{
+	showPinned?: boolean;
+	asReactionPicker?: boolean;
+	maxHeight?: number;
+	asDrawer?: boolean;
+}>(), {
+	showPinned: true,
+});
+
+const emit = defineEmits<{
+	(ev: 'chosen', v: string): void;
+}>();
+
+const search = ref<HTMLInputElement>();
+const emojis = ref<HTMLDivElement>();
+
+const {
+	reactions: pinned,
+	reactionPickerSize,
+	reactionPickerWidth,
+	reactionPickerHeight,
+	disableShowingAnimatedImages,
+	recentlyUsedEmojis,
+} = defaultStore.reactiveState;
+
+const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
+const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
+const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
+const customEmojiCategories = emojiCategories;
+const customEmojis = instance.emojis;
+const q = ref<string>('');
+const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
+const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
+const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
+
+watch(q, () => {
+	if (emojis.value) emojis.value.scrollTop = 0;
+
+	if (q.value === '') {
+		searchResultCustom.value = [];
+		searchResultUnicode.value = [];
+		return;
+	}
+
+	const newQ = q.value.replace(/:/g, '').toLowerCase();
+
+	const searchCustom = () => {
+		const max = 8;
+		const emojis = customEmojis;
+		const matches = new Set<Misskey.entities.CustomEmoji>();
+
+		const exactMatch = emojis.find(emoji => emoji.name === newQ);
+		if (exactMatch) matches.add(exactMatch);
+
+		if (newQ.includes(' ')) { // AND検索
+			const keywords = newQ.split(' ');
+
+			// 名前にキーワードが含まれている
+			for (const emoji of emojis) {
+				if (keywords.every(keyword => emoji.name.includes(keyword))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			// 名前またはエイリアスにキーワードが含まれている
+			for (const emoji of emojis) {
+				if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+		} else {
+			for (const emoji of emojis) {
+				if (emoji.name.startsWith(newQ)) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.aliases.some(alias => alias.startsWith(newQ))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.name.includes(newQ)) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.aliases.some(alias => alias.includes(newQ))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+		}
+
+		return matches;
+	};
+
+	const searchUnicode = () => {
+		const max = 8;
+		const emojis = emojilist;
+		const matches = new Set<UnicodeEmojiDef>();
+
+		const exactMatch = emojis.find(emoji => emoji.name === newQ);
+		if (exactMatch) matches.add(exactMatch);
+
+		if (newQ.includes(' ')) { // AND検索
+			const keywords = newQ.split(' ');
+
+			// 名前にキーワードが含まれている
+			for (const emoji of emojis) {
+				if (keywords.every(keyword => emoji.name.includes(keyword))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			// 名前またはエイリアスにキーワードが含まれている
+			for (const emoji of emojis) {
+				if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+		} else {
+			for (const emoji of emojis) {
+				if (emoji.name.startsWith(newQ)) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.name.includes(newQ)) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+			if (matches.size >= max) return matches;
+
+			for (const emoji of emojis) {
+				if (emoji.keywords.some(keyword => keyword.includes(newQ))) {
+					matches.add(emoji);
+					if (matches.size >= max) break;
+				}
+			}
+		}
+
+		return matches;
+	};
+
+	searchResultCustom.value = Array.from(searchCustom());
+	searchResultUnicode.value = Array.from(searchUnicode());
+});
+
+function focus() {
+	if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
+		search.value?.focus({
+			preventScroll: true,
+		});
+	}
+}
+
+function reset() {
+	if (emojis.value) emojis.value.scrollTop = 0;
+	q.value = '';
+}
+
+function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string {
+	return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
+}
+
+function chosen(emoji: any, ev?: MouseEvent) {
+	const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
+	if (el) {
+		const rect = el.getBoundingClientRect();
+		const x = rect.left + (el.offsetWidth / 2);
+		const y = rect.top + (el.offsetHeight / 2);
+		os.popup(Ripple, { x, y }, {}, 'end');
+	}
+
+	const key = getKey(emoji);
+	emit('chosen', key);
+
+	// 最近使った絵文字更新
+	if (!pinned.value.includes(key)) {
+		let recents = defaultStore.state.recentlyUsedEmojis;
+		recents = recents.filter((emoji: any) => emoji !== key);
+		recents.unshift(key);
+		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
+	}
+}
+
+function input(): void {
+	// Using custom input event instead of v-model to respond immediately on
+	// Android, where composition happens on all languages
+	// (v-model does not update during composition)
+	q.value = search.value?.value.trim() ?? '';
+}
+
+function paste(event: ClipboardEvent): void {
+	const pasted = event.clipboardData?.getData('text') ?? '';
+	if (done(pasted)) {
+		event.preventDefault();
+	}
+}
+
+function done(query?: string): boolean | void {
+	if (query == null) query = q.value;
+	if (query == null || typeof query !== 'string') return;
+
+	const q2 = query.replace(/:/g, '');
+	const exactMatchCustom = customEmojis.find(emoji => emoji.name === q2);
+	if (exactMatchCustom) {
+		chosen(exactMatchCustom);
+		return true;
+	}
+	const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2);
+	if (exactMatchUnicode) {
+		chosen(exactMatchUnicode);
+		return true;
+	}
+	if (searchResultCustom.value.length > 0) {
+		chosen(searchResultCustom.value[0]);
+		return true;
+	}
+	if (searchResultUnicode.value.length > 0) {
+		chosen(searchResultUnicode.value[0]);
+		return true;
+	}
+}
+
+onMounted(() => {
+	focus();
+});
+
+defineExpose({
+	focus,
+	reset,
+});
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+	$pad: 8px;
+
+	display: flex;
+	flex-direction: column;
+
+	&.s1 {
+		--eachSize: 40px;
+	}
+
+	&.s2 {
+		--eachSize: 45px;
+	}
+
+	&.s3 {
+		--eachSize: 50px;
+	}
+
+	&.w1 {
+		width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+		--columns: 1fr 1fr 1fr 1fr 1fr;
+	}
+
+	&.w2 {
+		width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+		--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
+	}
+
+	&.w3 {
+		width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+	}
+
+	&.w4 {
+		width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+	}
+
+	&.w5 {
+		width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
+		--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+	}
+
+	&.h1 {
+		height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+	}
+
+	&.h2 {
+		height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+	}
+
+	&.h3 {
+		height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+	}
+
+	&.h4 {
+		height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
+	}
+
+	&.asDrawer {
+		width: 100% !important;
+
+		> .emojis {
+			::v-deep(section) {
+				> header {
+					height: 32px;
+					line-height: 32px;
+					padding: 0 12px;
+					font-size: 15px;
+				}
+
+				> .body {
+					display: grid;
+					grid-template-columns: var(--columns);
+					font-size: 30px;
+
+					> .item {
+						aspect-ratio: 1 / 1;
+						width: auto;
+						height: auto;
+						min-width: 0;
+					}
+				}
+			}
+		}
+	}
+
+	> .search {
+		width: 100%;
+		padding: 12px;
+		box-sizing: border-box;
+		font-size: 1em;
+		outline: none;
+		border: none;
+		background: transparent;
+		color: var(--fg);
+
+		&:not(.filled) {
+			order: 1;
+			z-index: 2;
+			box-shadow: 0px -1px 0 0px var(--divider);
+		}
+	}
+
+	> .tabs {
+		display: flex;
+		display: none;
+
+		> .tab {
+			flex: 1;
+			height: 38px;
+			border-top: solid 0.5px var(--divider);
+
+			&.active {
+				border-top: solid 1px var(--accent);
+				color: var(--accent);
+			}
+		}
+	}
+
+	> .emojis {
+		height: 100%;
+		overflow-y: auto;
+		overflow-x: hidden;
+
+		scrollbar-width: none;
+
+		&::-webkit-scrollbar {
+			display: none;
+		}
+
+		> .group {
+			&:not(.index) {
+				padding: 4px 0 8px 0;
+				border-top: solid 0.5px var(--divider);
+			}
+
+			> header {
+				/*position: sticky;
+				top: 0;
+				left: 0;*/
+				height: 32px;
+				line-height: 32px;
+				z-index: 2;
+				padding: 0 8px;
+				font-size: 12px;
+			}
+		}
+
+		::v-deep(section) {
+			> header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				height: 32px;
+				line-height: 32px;
+				z-index: 1;
+				padding: 0 8px;
+				font-size: 12px;
+				cursor: pointer;
+
+				&:hover {
+					color: var(--accent);
+				}
+			}
+
+			> .body {
+				position: relative;
+				padding: $pad;
+
+				> .item {
+					position: relative;
+					padding: 0;
+					width: var(--eachSize);
+					height: var(--eachSize);
+					contain: strict;
+					border-radius: 4px;
+					font-size: 24px;
+
+					&:focus-visible {
+						outline: solid 2px var(--focus);
+						z-index: 1;
+					}
+
+					&:hover {
+						background: rgba(0, 0, 0, 0.05);
+					}
+
+					&:active {
+						background: var(--accent);
+						box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
+					}
+
+					> .emoji {
+						height: 1.25em;
+						vertical-align: -.25em;
+						pointer-events: none;
+					}
+				}
+			}
+
+			&.result {
+				border-bottom: solid 0.5px var(--divider);
+
+				&:empty {
+					display: none;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
new file mode 100644
index 0000000000..3b41f9d75b
--- /dev/null
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -0,0 +1,73 @@
+<template>
+<MkModal
+	ref="modal"
+	v-slot="{ type, maxHeight }"
+	:z-priority="'middle'"
+	:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
+	:transparent-bg="true"
+	:manual-showing="manualShowing"
+	:src="src"
+	@click="modal?.close()"
+	@opening="opening"
+	@close="emit('close')"
+	@closed="emit('closed')"
+>
+	<MkEmojiPicker
+		ref="picker"
+		class="ryghynhb _popup _shadow"
+		:class="{ drawer: type === 'drawer' }"
+		:show-pinned="showPinned"
+		:as-reaction-picker="asReactionPicker"
+		:as-drawer="type === 'drawer'"
+		:max-height="maxHeight"
+		@chosen="chosen"
+	/>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
+import { defaultStore } from '@/store';
+
+withDefaults(defineProps<{
+	manualShowing?: boolean | null;
+	src?: HTMLElement;
+	showPinned?: boolean;
+	asReactionPicker?: boolean;
+}>(), {
+	manualShowing: null,
+	showPinned: true,
+	asReactionPicker: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'done', v: any): void;
+	(ev: 'close'): void;
+	(ev: 'closed'): void;
+}>();
+
+const modal = ref<InstanceType<typeof MkModal>>();
+const picker = ref<InstanceType<typeof MkEmojiPicker>>();
+
+function chosen(emoji: any) {
+	emit('done', emoji);
+	modal.value?.close();
+}
+
+function opening() {
+	picker.value?.reset();
+	picker.value?.focus();
+}
+</script>
+
+<style lang="scss" scoped>
+.ryghynhb {
+	&.drawer {
+		border-radius: 24px;
+		border-bottom-right-radius: 0;
+		border-bottom-left-radius: 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue
new file mode 100644
index 0000000000..523e4ba695
--- /dev/null
+++ b/packages/frontend/src/components/MkEmojiPickerWindow.vue
@@ -0,0 +1,180 @@
+<template>
+<MkWindow ref="window"
+	:initial-width="null"
+	:initial-height="null"
+	:can-resize="false"
+	:mini="true"
+	:front="true"
+	@closed="emit('closed')"
+>
+	<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
+</MkWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkWindow from '@/components/MkWindow.vue';
+import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
+
+withDefaults(defineProps<{
+	src?: HTMLElement;
+	showPinned?: boolean;
+	asReactionPicker?: boolean;
+}>(), {
+	showPinned: true,
+});
+
+const emit = defineEmits<{
+	(ev: 'chosen', v: any): void;
+	(ev: 'closed'): void;
+}>();
+
+function chosen(emoji: any) {
+	emit('chosen', emoji);
+}
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+	$pad: 8px;
+	--eachSize: 40px;
+
+	display: flex;
+	flex-direction: column;
+	contain: content;
+
+	&.big {
+		--eachSize: 44px;
+	}
+
+	&.w1 {
+		width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+	}
+
+	&.w2 {
+		width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+	}
+
+	&.w3 {
+		width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+	}
+
+	&.h1 {
+		--height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+	}
+
+	&.h2 {
+		--height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+	}
+
+	&.h3 {
+		--height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+	}
+
+	> .search {
+		width: 100%;
+		padding: 12px;
+		box-sizing: border-box;
+		font-size: 1em;
+		outline: none;
+		border: none;
+		background: transparent;
+		color: var(--fg);
+
+		&:not(.filled) {
+			order: 1;
+			z-index: 2;
+			box-shadow: 0px -1px 0 0px var(--divider);
+		}
+	}
+
+	> .emojis {
+		height: var(--height);
+		overflow-y: auto;
+		overflow-x: hidden;
+
+		scrollbar-width: none;
+
+		&::-webkit-scrollbar {
+			display: none;
+		}
+
+		> .index {
+			min-height: var(--height);
+			position: relative;
+			border-bottom: solid 0.5px var(--divider);
+				
+			> .arrow {
+				position: absolute;
+				bottom: 0;
+				left: 0;
+				width: 100%;
+				padding: 16px 0;
+				text-align: center;
+				opacity: 0.5;
+				pointer-events: none;
+			}
+		}
+
+		section {
+			> header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				z-index: 1;
+				padding: 8px;
+				font-size: 12px;
+			}
+
+			> div {
+				padding: $pad;
+
+				> button {
+					position: relative;
+					padding: 0;
+					width: var(--eachSize);
+					height: var(--eachSize);
+					border-radius: 4px;
+
+					&:focus-visible {
+						outline: solid 2px var(--focus);
+						z-index: 1;
+					}
+
+					&:hover {
+						background: rgba(0, 0, 0, 0.05);
+					}
+
+					&:active {
+						background: var(--accent);
+						box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
+					}
+
+					> * {
+						font-size: 24px;
+						height: 1.25em;
+						vertical-align: -.25em;
+						pointer-events: none;
+					}
+				}
+			}
+
+			&.result {
+				border-bottom: solid 0.5px var(--divider);
+
+				&:empty {
+					display: none;
+				}
+			}
+
+			&.unicode {
+				min-height: 384px;
+			}
+
+			&.custom {
+				min-height: 64px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue
new file mode 100644
index 0000000000..e58b5d2849
--- /dev/null
+++ b/packages/frontend/src/components/MkFeaturedPhotos.vue
@@ -0,0 +1,22 @@
+<template>
+<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os';
+
+const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
+
+os.api('meta', { detail: true }).then(gotMeta => {
+	meta.value = gotMeta;
+});
+</script>
+
+<style lang="scss" scoped>
+.xfbouadm {
+	background-position: center;
+	background-size: cover;
+}
+</style>
diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
new file mode 100644
index 0000000000..73875251f0
--- /dev/null
+++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
@@ -0,0 +1,175 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="400"
+	:height="450"
+	:with-ok-button="true"
+	:ok-button-disabled="false"
+	@ok="ok()"
+	@close="dialog.close()"
+	@closed="emit('closed')"
+>
+	<template #header>{{ i18n.ts.describeFile }}</template>
+	<div>
+		<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" style="height: 100px;"/>
+		<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
+			<template #label>{{ i18n.ts.caption }}</template>
+		</MkTextarea>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	file: Misskey.entities.DriveFile;
+	default: string;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'done', v: string): void;
+	(ev: 'closed'): void;
+}>();
+
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
+
+let caption = $ref(props.default);
+
+async function ok() {
+	emit('done', caption);
+	dialog.close();
+}
+</script>
+
+<style lang="scss" scoped>
+.container {
+	display: flex;
+	width: 100%;
+	height: 100%;
+	flex-direction: row;
+	overflow: scroll;
+	position: fixed;
+	left: 0;
+	top: 0;
+}
+@media (max-width: 850px) {
+	.container {
+		flex-direction: column;
+	}
+	.top-caption {
+		padding-bottom: 8px;
+	}
+}
+.fullwidth {
+	width: 100%;
+	margin: auto;
+}
+.mk-dialog {
+	position: relative;
+	padding: 32px;
+	min-width: 320px;
+	max-width: 480px;
+	box-sizing: border-box;
+	text-align: center;
+	background: var(--panel);
+	border-radius: var(--radius);
+	margin: auto;
+
+	> header {
+		margin: 0 0 8px 0;
+		position: relative;
+
+		> .title {
+			font-weight: bold;
+			font-size: 20px;
+		}
+
+		> .text-count {
+			opacity: 0.7;
+			position: absolute;
+			right: 0;
+		}
+	}
+
+	> .buttons {
+		margin-top: 16px;
+
+		> * {
+			margin: 0 8px;
+		}
+	}
+
+	> textarea {
+		display: block;
+		box-sizing: border-box;
+		padding: 0 24px;
+		margin: 0;
+		width: 100%;
+		font-size: 16px;
+		border: none;
+		border-radius: 0;
+		background: transparent;
+		color: var(--fg);
+		font-family: inherit;
+		max-width: 100%;
+		min-width: 100%;
+		min-height: 90px;
+
+		&:focus-visible {
+			outline: none;
+		}
+
+		&:disabled {
+			opacity: 0.5;
+		}
+	}
+}
+.hdrwpsaf {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+
+	> header,
+	> footer {
+		align-self: center;
+		display: inline-block;
+		padding: 6px 9px;
+		font-size: 90%;
+		background: rgba(0, 0, 0, 0.5);
+		border-radius: 6px;
+		color: #fff;
+	}
+
+	> header {
+		margin-bottom: 8px;
+		opacity: 0.9;
+	}
+
+	> img {
+		display: block;
+		flex: 1;
+		min-height: 0;
+		object-fit: contain;
+		width: 100%;
+		cursor: zoom-out;
+		image-orientation: from-image;
+	}
+
+	> footer {
+		margin-top: 8px;
+		opacity: 0.8;
+
+		> span + span {
+			margin-left: 0.5em;
+			padding-left: 0.5em;
+			border-left: solid 1px rgba(255, 255, 255, 0.5);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue
new file mode 100644
index 0000000000..4910506a95
--- /dev/null
+++ b/packages/frontend/src/components/MkFileListForAdmin.vue
@@ -0,0 +1,117 @@
+<template>
+<div>
+	<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
+		<MkA
+			v-for="file in items"
+			:key="file.id"
+			v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`"
+			:to="`/admin/file/${file.id}`"
+			class="file _button"
+		>
+			<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
+			<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+			<div v-if="viewMode === 'list'" class="body">
+				<div>
+					<small style="opacity: 0.7;">{{ file.name }}</small>
+				</div>
+				<div>
+					<MkAcct v-if="file.user" :user="file.user"/>
+					<div v-else>{{ i18n.ts.system }}</div>
+				</div>
+				<div>
+					<span style="margin-right: 1em;">{{ file.type }}</span>
+					<span>{{ bytes(file.size) }}</span>
+				</div>
+				<div>
+					<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+				</div>
+			</div>
+		</MkA>
+	</MkPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkPagination from '@/components/MkPagination.vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	pagination: any;
+	viewMode: 'grid' | 'list';
+}>();
+</script>
+
+<style lang="scss" scoped>
+@keyframes sensitive-blink {
+	0% { opacity: 1; }
+	50% { opacity: 0; }
+}
+
+.urempief {
+	margin-top: var(--margin);
+
+	&.list {
+		> .file {
+			display: flex;
+			width: 100%;
+			box-sizing: border-box;
+			text-align: left;
+			align-items: center;
+
+			&:hover {
+				color: var(--accent);
+			}
+
+			> .thumbnail {
+				width: 128px;
+				height: 128px;
+			}
+
+			> .body {
+				margin-left: 0.3em;
+				padding: 8px;
+				flex: 1;
+
+				@media (max-width: 500px) {
+					font-size: 14px;
+				}
+			}
+		}
+	}
+
+	&.grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+		grid-gap: 12px;
+		margin: var(--margin) 0;
+
+		> .file {
+			position: relative;
+			aspect-ratio: 1;
+		
+			> .thumbnail {
+				width: 100%;
+				height: 100%;
+			}
+
+			> .sensitive-label {
+				position: absolute;
+				z-index: 10;
+				top: 8px;
+				left: 8px;
+				padding: 2px 4px;
+				background: #ff0000bf;
+				color: #fff;
+				border-radius: 4px;
+				font-size: 85%;
+				animation: sensitive-blink 1s infinite;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
new file mode 100644
index 0000000000..9e83b07cd7
--- /dev/null
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -0,0 +1,159 @@
+<template>
+<div v-size="{ max: [500] }" class="ssazuxis">
+	<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
+		<div class="title"><slot name="header"></slot></div>
+		<div class="divider"></div>
+		<button class="_button">
+			<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
+			<template v-else><i class="ti ti-chevron-down"></i></template>
+		</button>
+	</header>
+	<transition
+		:name="$store.state.animation ? 'folder-toggle' : ''"
+		@enter="enter"
+		@after-enter="afterEnter"
+		@leave="leave"
+		@after-leave="afterLeave"
+	>
+		<div v-show="showBody">
+			<slot></slot>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import tinycolor from 'tinycolor2';
+
+const localStoragePrefix = 'ui:folder:';
+
+export default defineComponent({
+	props: {
+		expanded: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
+		persistKey: {
+			type: String,
+			required: false,
+			default: null,
+		},
+	},
+	data() {
+		return {
+			bg: null,
+			showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
+		};
+	},
+	watch: {
+		showBody() {
+			if (this.persistKey) {
+				localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
+			}
+		},
+	},
+	mounted() {
+		function getParentBg(el: Element | null): string {
+			if (el == null || el.tagName === 'BODY') return 'var(--bg)';
+			const bg = el.style.background || el.style.backgroundColor;
+			if (bg) {
+				return bg;
+			} else {
+				return getParentBg(el.parentElement);
+			}
+		}
+		const rawBg = getParentBg(this.$el);
+		const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+		bg.setAlpha(0.85);
+		this.bg = bg.toRgbString();
+	},
+	methods: {
+		toggleContent(show: boolean) {
+			this.showBody = show;
+		},
+
+		enter(el) {
+			const elementHeight = el.getBoundingClientRect().height;
+			el.style.height = 0;
+			el.offsetHeight; // reflow
+			el.style.height = elementHeight + 'px';
+		},
+		afterEnter(el) {
+			el.style.height = null;
+		},
+		leave(el) {
+			const elementHeight = el.getBoundingClientRect().height;
+			el.style.height = elementHeight + 'px';
+			el.offsetHeight; // reflow
+			el.style.height = 0;
+		},
+		afterLeave(el) {
+			el.style.height = null;
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.folder-toggle-enter-active, .folder-toggle-leave-active {
+	overflow-y: hidden;
+	transition: opacity 0.5s, height 0.5s !important;
+}
+.folder-toggle-enter-from {
+	opacity: 0;
+}
+.folder-toggle-leave-to {
+	opacity: 0;
+}
+
+.ssazuxis {
+	position: relative;
+
+	> header {
+		display: flex;
+		position: relative;
+		z-index: 10;
+		position: sticky;
+		top: var(--stickyTop, 0px);
+		padding: var(--x-padding);
+		-webkit-backdrop-filter: var(--blur, blur(8px));
+		backdrop-filter: var(--blur, blur(20px));
+
+		> .title {
+			display: grid;
+			place-content: center;
+			margin: 0;
+			padding: 12px 16px 12px 0;
+
+			> i {
+				margin-right: 6px;
+			}
+
+			&:empty {
+				display: none;
+			}
+		}
+
+		> .divider {
+			flex: 1;
+			margin: auto;
+			height: 1px;
+			background: var(--divider);
+		}
+
+		> button {
+			padding: 12px 0 12px 16px;
+		}
+	}
+
+	&.max-width_500px {
+		> header {
+			> .title {
+				padding: 8px 10px 8px 0;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
new file mode 100644
index 0000000000..ee256d9263
--- /dev/null
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -0,0 +1,187 @@
+<template>
+<button
+	class="kpoogebi _button"
+	:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
+	:disabled="wait"
+	@click="onClick"
+>
+	<template v-if="!wait">
+		<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
+			<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i>
+		</template>
+		<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
+			<!-- つまりリモートフォローの場合。 -->
+			<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
+		</template>
+		<template v-else-if="isFollowing">
+			<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
+		</template>
+		<template v-else-if="!isFollowing && user.isLocked">
+			<span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
+		</template>
+		<template v-else-if="!isFollowing && !user.isLocked">
+			<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
+		</template>
+	</template>
+	<template v-else>
+		<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
+	</template>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	user: Misskey.entities.UserDetailed,
+	full?: boolean,
+	large?: boolean,
+}>(), {
+	full: false,
+	large: false,
+});
+
+let isFollowing = $ref(props.user.isFollowing);
+let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
+let wait = $ref(false);
+const connection = stream.useChannel('main');
+
+if (props.user.isFollowing == null) {
+	os.api('users/show', {
+		userId: props.user.id,
+	})
+		.then(onFollowChange);
+}
+
+function onFollowChange(user: Misskey.entities.UserDetailed) {
+	if (user.id === props.user.id) {
+		isFollowing = user.isFollowing;
+		hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
+	}
+}
+
+async function onClick() {
+	wait = true;
+
+	try {
+		if (isFollowing) {
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
+			});
+
+			if (canceled) return;
+
+			await os.api('following/delete', {
+				userId: props.user.id,
+			});
+		} else {
+			if (hasPendingFollowRequestFromYou) {
+				await os.api('following/requests/cancel', {
+					userId: props.user.id,
+				});
+				hasPendingFollowRequestFromYou = false;
+			} else {
+				await os.api('following/create', {
+					userId: props.user.id,
+				});
+				hasPendingFollowRequestFromYou = true;
+			}
+		}
+	} catch (err) {
+		console.error(err);
+	} finally {
+		wait = false;
+	}
+}
+
+onMounted(() => {
+	connection.on('follow', onFollowChange);
+	connection.on('unfollow', onFollowChange);
+});
+
+onBeforeUnmount(() => {
+	connection.dispose();
+});
+</script>
+
+<style lang="scss" scoped>
+.kpoogebi {
+	position: relative;
+	display: inline-block;
+	font-weight: bold;
+	color: var(--accent);
+	background: transparent;
+	border: solid 1px var(--accent);
+	padding: 0;
+	height: 31px;
+	font-size: 16px;
+	border-radius: 32px;
+	background: #fff;
+
+	&.full {
+		padding: 0 8px 0 12px;
+		font-size: 14px;
+	}
+
+	&.large {
+		font-size: 16px;
+		height: 38px;
+		padding: 0 12px 0 16px;
+	}
+
+	&:not(.full) {
+		width: 31px;
+	}
+
+	&:focus-visible {
+		&:after {
+			content: "";
+			pointer-events: none;
+			position: absolute;
+			top: -5px;
+			right: -5px;
+			bottom: -5px;
+			left: -5px;
+			border: 2px solid var(--focus);
+			border-radius: 32px;
+		}
+	}
+
+	&:hover {
+		//background: mix($primary, #fff, 20);
+	}
+
+	&:active {
+		//background: mix($primary, #fff, 40);
+	}
+
+	&.active {
+		color: #fff;
+		background: var(--accent);
+
+		&:hover {
+			background: var(--accentLighten);
+			border-color: var(--accentLighten);
+		}
+
+		&:active {
+			background: var(--accentDarken);
+			border-color: var(--accentDarken);
+		}
+	}
+
+	&.wait {
+		cursor: wait !important;
+		opacity: 0.7;
+	}
+
+	> span {
+		margin-right: 6px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue
new file mode 100644
index 0000000000..1b55451c94
--- /dev/null
+++ b/packages/frontend/src/components/MkForgotPassword.vue
@@ -0,0 +1,80 @@
+<template>
+<XModalWindow ref="dialog"
+	:width="370"
+	:height="400"
+	@close="dialog.close()"
+	@closed="emit('closed')"
+>
+	<template #header>{{ i18n.ts.forgotPassword }}</template>
+
+	<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
+		<div class="main _formRoot">
+			<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
+				<template #label>{{ i18n.ts.username }}</template>
+				<template #prefix>@</template>
+			</MkInput>
+
+			<MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required>
+				<template #label>{{ i18n.ts.emailAddress }}</template>
+				<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
+			</MkInput>
+
+			<MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
+		</div>
+		<div class="sub">
+			<MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
+		</div>
+	</form>
+	<div v-else class="bafecedb">
+		{{ i18n.ts._forgotPassword.contactAdmin }}
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const emit = defineEmits<{
+	(ev: 'done'): void;
+	(ev: 'closed'): void;
+}>();
+
+let dialog: InstanceType<typeof XModalWindow> = $ref();
+
+let username = $ref('');
+let email = $ref('');
+let processing = $ref(false);
+
+async function onSubmit() {
+	processing = true;
+	await os.apiWithDialog('request-reset-password', {
+		username,
+		email,
+	});
+	emit('done');
+	dialog.close();
+}
+</script>
+
+<style lang="scss" scoped>
+.bafeceda {
+	> .main {
+		padding: 24px;
+	}
+
+	> .sub {
+		border-top: solid 0.5px var(--divider);
+		padding: 24px;
+	}
+}
+
+.bafecedb {
+	padding: 24px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
new file mode 100644
index 0000000000..b2bf76a8c7
--- /dev/null
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -0,0 +1,127 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="450"
+	:can-close="false"
+	:with-ok-button="true"
+	:ok-button-disabled="false"
+	@click="cancel()"
+	@ok="ok()"
+	@close="cancel()"
+	@closed="$emit('closed')"
+>
+	<template #header>
+		{{ title }}
+	</template>
+
+	<MkSpacer :margin-min="20" :margin-max="32">
+		<div class="xkpnjxcv _formRoot">
+			<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
+				<FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
+				</FormInput>
+				<FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
+				</FormInput>
+				<FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
+				</FormTextarea>
+				<FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]" class="_formBlock">
+					<span v-text="form[item].label || item"></span>
+					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
+				</FormSwitch>
+				<FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option>
+				</FormSelect>
+				<FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
+				</FormRadios>
+				<FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock">
+					<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+					<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
+				</FormRange>
+				<MkButton v-else-if="form[item].type === 'button'" class="_formBlock" @click="form[item].action($event, values)">
+					<span v-text="form[item].content || item"></span>
+				</MkButton>
+			</template>
+		</div>
+	</MkSpacer>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormInput from './form/input.vue';
+import FormTextarea from './form/textarea.vue';
+import FormSwitch from './form/switch.vue';
+import FormSelect from './form/select.vue';
+import FormRange from './form/range.vue';
+import MkButton from './MkButton.vue';
+import FormRadios from './form/radios.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+
+export default defineComponent({
+	components: {
+		XModalWindow,
+		FormInput,
+		FormTextarea,
+		FormSwitch,
+		FormSelect,
+		FormRange,
+		MkButton,
+		FormRadios,
+	},
+
+	props: {
+		title: {
+			type: String,
+			required: true,
+		},
+		form: {
+			type: Object,
+			required: true,
+		},
+	},
+
+	emits: ['done'],
+
+	data() {
+		return {
+			values: {},
+		};
+	},
+
+	created() {
+		for (const item in this.form) {
+			this.values[item] = this.form[item].default ?? null;
+		}
+	},
+
+	methods: {
+		ok() {
+			this.$emit('done', {
+				result: this.values,
+			});
+			this.$refs.dialog.close();
+		},
+
+		cancel() {
+			this.$emit('done', {
+				canceled: true,
+			});
+			this.$refs.dialog.close();
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.xkpnjxcv {
+
+}
+</style>
diff --git a/packages/frontend/src/components/MkFormula.vue b/packages/frontend/src/components/MkFormula.vue
new file mode 100644
index 0000000000..65a2fee930
--- /dev/null
+++ b/packages/frontend/src/components/MkFormula.vue
@@ -0,0 +1,24 @@
+<template>
+<XFormula :formula="formula" :block="block"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		XFormula: defineAsyncComponent(() => import('@/components/MkFormulaCore.vue')),
+	},
+	props: {
+		formula: {
+			type: String,
+			required: true,
+		},
+		block: {
+			type: Boolean,
+			required: true,
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/MkFormulaCore.vue b/packages/frontend/src/components/MkFormulaCore.vue
new file mode 100644
index 0000000000..6028db9e64
--- /dev/null
+++ b/packages/frontend/src/components/MkFormulaCore.vue
@@ -0,0 +1,34 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+<div v-if="block" v-html="compiledFormula"></div>
+<span v-else v-html="compiledFormula"></span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import katex from 'katex';
+
+export default defineComponent({
+	props: {
+		formula: {
+			type: String,
+			required: true,
+		},
+		block: {
+			type: Boolean,
+			required: true,
+		},
+	},
+	computed: {
+		compiledFormula(): any {
+			return katex.renderToString(this.formula, {
+				throwOnError: false,
+			} as any);
+		},
+	},
+});
+</script>
+
+<style>
+@import "../../node_modules/katex/dist/katex.min.css";
+</style>
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
new file mode 100644
index 0000000000..a133f6431b
--- /dev/null
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -0,0 +1,115 @@
+<template>
+<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
+	<div class="thumbnail">
+		<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
+	</div>
+	<article>
+		<header>
+			<MkAvatar :user="post.user" class="avatar"/>
+		</header>
+		<footer>
+			<span class="title">{{ post.title }}</span>
+		</footer>
+	</article>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { userName } from '@/filters/user';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+import * as os from '@/os';
+
+const props = defineProps<{
+	post: any;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.ttasepnz {
+	display: block;
+	position: relative;
+	height: 200px;
+
+	&:hover {
+		text-decoration: none;
+		color: var(--accent);
+
+		> .thumbnail {
+			transform: scale(1.1);
+		}
+
+		> article {
+			> footer {
+				&:before {
+					opacity: 1;
+				}
+			}
+		}
+	}
+
+	> .thumbnail {
+		width: 100%;
+		height: 100%;
+		position: absolute;
+		transition: all 0.5s ease;
+
+		> .img {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+		}
+	}
+
+	> article {
+		position: absolute;
+		z-index: 1;
+		width: 100%;
+		height: 100%;
+
+		> header {
+			position: absolute;
+			top: 0;
+			width: 100%;
+			padding: 12px;
+			box-sizing: border-box;
+			display: flex;
+
+			> .avatar {
+				margin-left: auto;
+				width: 32px;
+				height: 32px;
+			}
+		}
+
+		> footer {
+			position: absolute;
+			bottom: 0;
+			width: 100%;
+			padding: 16px;
+			box-sizing: border-box;
+			color: #fff;
+			text-shadow: 0 0 8px #000;
+			background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+
+			&:before {
+				content: "";
+				display: block;
+				position: absolute;
+				z-index: -1;
+				top: 0;
+				left: 0;
+				width: 100%;
+				height: 100%;
+				background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
+				opacity: 0;
+				transition: opacity 0.5s ease;
+			}
+
+			> .title {
+				font-weight: bold;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue
new file mode 100644
index 0000000000..d104cd4cd4
--- /dev/null
+++ b/packages/frontend/src/components/MkGoogle.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="mk-google">
+	<input v-model="query" type="search" :placeholder="q">
+	<button @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const props = defineProps<{
+	q: string;
+}>();
+
+const query = ref(props.q);
+
+const search = () => {
+	window.open(`https://www.google.com/search?q=${query.value}`, '_blank');
+};
+</script>
+
+<style lang="scss" scoped>
+.mk-google {
+	display: flex;
+	margin: 8px 0;
+
+	> input {
+		flex-shrink: 1;
+		padding: 10px;
+		width: 100%;
+		height: 40px;
+		font-size: 16px;
+		border: solid 1px var(--divider);
+		border-radius: 4px 0 0 4px;
+		-webkit-appearance: textfield;
+	}
+
+	> button {
+		flex-shrink: 0;
+		margin: 0;
+		padding: 0 16px;
+		border: solid 1px var(--divider);
+		border-left: none;
+		border-radius: 0 4px 4px 0;
+
+		&:active {
+			box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkImageViewer.vue b/packages/frontend/src/components/MkImageViewer.vue
new file mode 100644
index 0000000000..f074b1a2f2
--- /dev/null
+++ b/packages/frontend/src/components/MkImageViewer.vue
@@ -0,0 +1,77 @@
+<template>
+<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
+	<div class="xubzgfga">
+		<header>{{ image.name }}</header>
+		<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
+		<footer>
+			<span>{{ image.type }}</span>
+			<span>{{ bytes(image.size) }}</span>
+			<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+		</footer>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkModal from '@/components/MkModal.vue';
+
+const props = withDefaults(defineProps<{
+	image: misskey.entities.DriveFile;
+}>(), {
+});
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const modal = $ref<InstanceType<typeof MkModal>>();
+</script>
+
+<style lang="scss" scoped>
+.xubzgfga {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+
+	> header,
+	> footer {
+		align-self: center;
+		display: inline-block;
+		padding: 6px 9px;
+		font-size: 90%;
+		background: rgba(0, 0, 0, 0.5);
+		border-radius: 6px;
+		color: #fff;
+	}
+
+	> header {
+		margin-bottom: 8px;
+		opacity: 0.9;
+	}
+
+	> img {
+		display: block;
+		flex: 1;
+		min-height: 0;
+		object-fit: contain;
+		width: 100%;
+		cursor: zoom-out;
+		image-orientation: from-image;
+	}
+
+	> footer {
+		margin-top: 8px;
+		opacity: 0.8;
+
+		> span + span {
+			margin-left: 0.5em;
+			padding-left: 0.5em;
+			border-left: solid 1px rgba(255, 255, 255, 0.5);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
new file mode 100644
index 0000000000..80d7c201a4
--- /dev/null
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -0,0 +1,76 @@
+<template>
+<div class="xubzgfgb" :class="{ cover }" :title="title">
+	<canvas v-if="!loaded" ref="canvas" :width="size" :height="size" :title="title"/>
+	<img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import { decode } from 'blurhash';
+
+const props = withDefaults(defineProps<{
+	src?: string | null;
+	hash?: string;
+	alt?: string;
+	title?: string | null;
+	size?: number;
+	cover?: boolean;
+}>(), {
+	src: null,
+	alt: '',
+	title: null,
+	size: 64,
+	cover: true,
+});
+
+const canvas = $ref<HTMLCanvasElement>();
+let loaded = $ref(false);
+
+function draw() {
+	if (props.hash == null) return;
+	const pixels = decode(props.hash, props.size, props.size);
+	const ctx = canvas.getContext('2d');
+	const imageData = ctx!.createImageData(props.size, props.size);
+	imageData.data.set(pixels);
+	ctx!.putImageData(imageData, 0, 0);
+}
+
+function onLoad() {
+	loaded = true;
+}
+
+onMounted(() => {
+	draw();
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfgb {
+	position: relative;
+	width: 100%;
+	height: 100%;
+
+	> canvas,
+	> img {
+		display: block;
+		width: 100%;
+		height: 100%;
+	}
+
+	> canvas {
+		position: absolute;
+		object-fit: cover;
+	}
+
+	> img {
+		object-fit: contain;
+	}
+
+	&.cover {
+		> img {
+			object-fit: cover;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue
new file mode 100644
index 0000000000..7aaf2c5bcb
--- /dev/null
+++ b/packages/frontend/src/components/MkInfo.vue
@@ -0,0 +1,34 @@
+<template>
+<div class="fpezltsf" :class="{ warn }">
+	<i v-if="warn" class="ti ti-alert-triangle"></i>
+	<i v-else class="ti ti-info-circle"></i>
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	warn?: boolean;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.fpezltsf {
+	padding: 12px 14px;
+	font-size: 90%;
+	background: var(--infoBg);
+	color: var(--infoFg);
+	border-radius: var(--radius);
+
+	&.warn {
+		background: var(--infoWarnBg);
+		color: var(--infoWarnFg);
+	}
+
+	> i {
+		margin-right: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue
new file mode 100644
index 0000000000..4625de40af
--- /dev/null
+++ b/packages/frontend/src/components/MkInstanceCardMini.vue
@@ -0,0 +1,105 @@
+<template>
+<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]">
+	<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
+	<div class="body">
+		<span class="host">{{ instance.name ?? instance.host }}</span>
+		<span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span>
+	</div>
+	<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const props = defineProps<{
+	instance: misskey.entities.Instance;
+}>();
+
+let chartValues = $ref<number[] | null>(null);
+
+os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
+	// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
+	res.requests.received.splice(0, 1);
+	chartValues = res.requests.received;
+});
+
+function getInstanceIcon(instance): string {
+	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	$bodyTitleHieght: 18px;
+	$bodyInfoHieght: 16px;
+
+	display: flex;
+	align-items: center;
+	padding: 16px;
+	background: var(--panel);
+	border-radius: 8px;
+
+	> :global(.icon) {
+		display: block;
+		width: ($bodyTitleHieght + $bodyInfoHieght);
+		height: ($bodyTitleHieght + $bodyInfoHieght);
+		object-fit: cover;
+		border-radius: 4px;
+		margin-right: 10px;
+	}
+
+	> :global(.body) {
+		flex: 1;
+		overflow: hidden;
+		font-size: 0.9em;
+		color: var(--fg);
+		padding-right: 8px;
+
+		> :global(.host) {
+			display: block;
+			width: 100%;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			line-height: $bodyTitleHieght;
+		}
+
+		> :global(.sub) {
+			display: block;
+			width: 100%;
+			font-size: 80%;
+			opacity: 0.7;
+			line-height: $bodyInfoHieght;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+	}
+
+	> :global(.chart) {
+		height: 30px;
+	}
+
+	&:global(.yellow) {
+		--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;
+	}
+
+	&:global(.red) {
+		--c: rgb(255 0 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;
+	}
+
+	&:global(.gray) {
+		--c: var(--bg);
+		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/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
new file mode 100644
index 0000000000..41f6f9ffd5
--- /dev/null
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -0,0 +1,255 @@
+<template>
+<div :class="$style.root">
+	<MkFolder class="item">
+		<template #header>Chart</template>
+		<div :class="$style.chart">
+			<div class="selects">
+				<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+					<optgroup :label="i18n.ts.federation">
+						<option value="federation">{{ i18n.ts._charts.federation }}</option>
+						<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
+					</optgroup>
+					<optgroup :label="i18n.ts.users">
+						<option value="users">{{ i18n.ts._charts.usersIncDec }}</option>
+						<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option>
+						<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option>
+					</optgroup>
+					<optgroup :label="i18n.ts.notes">
+						<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
+						<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
+						<option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
+						<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
+					</optgroup>
+					<optgroup :label="i18n.ts.drive">
+						<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option>
+						<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option>
+					</optgroup>
+				</MkSelect>
+				<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
+					<option value="hour">{{ i18n.ts.perHour }}</option>
+					<option value="day">{{ i18n.ts.perDay }}</option>
+				</MkSelect>
+			</div>
+			<div class="chart _panel">
+				<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
+			</div>
+		</div>
+	</MkFolder>
+
+	<MkFolder class="item">
+		<template #header>Active users heatmap</template>
+		<div class="_panel" :class="$style.heatmap">
+			<MkActiveUsersHeatmap/>
+		</div>
+	</MkFolder>
+
+	<MkFolder class="item">
+		<template #header>Federation</template>
+		<div :class="$style.federation">
+			<div class="pies">
+				<div class="sub">
+					<div class="title">Sub</div>
+					<canvas ref="subDoughnutEl"></canvas>
+				</div>
+				<div class="pub">
+					<div class="title">Pub</div>
+					<canvas ref="pubDoughnutEl"></canvas>
+				</div>
+			</div>
+		</div>
+	</MkFolder>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	DoughnutController,
+} from 'chart.js';
+import MkSelect from '@/components/form/select.vue';
+import MkChart from '@/components/MkChart.vue';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
+import MkFolder from '@/components/MkFolder.vue';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	DoughnutController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+);
+
+const props = withDefaults(defineProps<{
+	chartLimit?: number;
+	detailed?: boolean;
+}>(), {
+	chartLimit: 90,
+});
+
+const chartSpan = $ref<'hour' | 'day'>('hour');
+const chartSrc = $ref('active-users');
+let subDoughnutEl = $ref<HTMLCanvasElement>();
+let pubDoughnutEl = $ref<HTMLCanvasElement>();
+
+const { handler: externalTooltipHandler1 } = useChartTooltip({
+	position: 'middle',
+});
+const { handler: externalTooltipHandler2 } = useChartTooltip({
+	position: 'middle',
+});
+
+function createDoughnut(chartEl, tooltip, data) {
+	const chartInstance = new Chart(chartEl, {
+		type: 'doughnut',
+		data: {
+			labels: data.map(x => x.name),
+			datasets: [{
+				backgroundColor: data.map(x => x.color),
+				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
+				borderWidth: 2,
+				hoverOffset: 0,
+				data: data.map(x => x.value),
+			}],
+		},
+		options: {
+			maintainAspectRatio: false,
+			layout: {
+				padding: {
+					left: 16,
+					right: 16,
+					top: 16,
+					bottom: 16,
+				},
+			},
+			onClick: (ev) => {
+				const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
+				if (hit && data[hit.index].onClick) {
+					data[hit.index].onClick();
+				}
+			},
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: tooltip,
+				},
+			},
+		},
+	});
+
+	return chartInstance;
+}
+
+onMounted(() => {
+	os.apiGet('federation/stats', { limit: 30 }).then(fedStats => {
+		createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followersCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }]));
+
+		createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followingCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }]));
+	});
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	&:global {
+		> .item {
+			margin-bottom: 16px;
+		}
+	}
+}
+
+.chart {
+	&:global {
+		> .selects {
+			display: flex;
+			margin-bottom: 12px;
+		}
+
+		> .chart {
+			padding: 16px;
+		}
+	}
+}
+
+.heatmap {
+	padding: 16px;
+	margin-bottom: 16px;
+}
+
+.federation {
+	&:global {
+		> .pies {
+			display: flex;
+			gap: 16px;
+
+			> .sub, > .pub {
+				flex: 1;
+				min-width: 0;
+				position: relative;
+				background: var(--panel);
+				border-radius: var(--radius);
+				padding: 24px;
+				max-height: 300px;
+
+				> .title {
+					position: absolute;
+					top: 24px;
+					left: 24px;
+				}
+			}
+
+			@media (max-width: 600px) {
+				flex-direction: column;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue
new file mode 100644
index 0000000000..646172fe8d
--- /dev/null
+++ b/packages/frontend/src/components/MkInstanceTicker.vue
@@ -0,0 +1,80 @@
+<template>
+<div :class="$style.root" :style="bg">
+	<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
+	<div :class="$style.name">{{ instance.name }}</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { instanceName } from '@/config';
+import { instance as Instance } from '@/instance';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const props = defineProps<{
+	instance?: {
+		faviconUrl?: string
+		name: string
+		themeColor?: string
+	}
+}>();
+
+// if no instance data is given, this is for the local instance
+const instance = props.instance ?? {
+	name: instanceName,
+	themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
+};
+
+const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico');
+
+const themeColor = instance.themeColor ?? '#777777';
+
+const bg = {
+	background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
+};
+</script>
+
+<style lang="scss" module>
+$height: 2ex;
+
+.root {
+	display: flex;
+	align-items: center;
+	height: $height;
+	border-radius: 4px 0 0 4px;
+	overflow: clip;
+	color: #fff;
+	text-shadow: /* .866 ≈ sin(60deg) */
+		1px 0 1px #000,
+		.866px .5px 1px #000,
+		.5px .866px 1px #000,
+		0 1px 1px #000,
+		-.5px .866px 1px #000,
+		-.866px .5px 1px #000,
+		-1px 0 1px #000,
+		-.866px -.5px 1px #000,
+		-.5px -.866px 1px #000,
+		0 -1px 1px #000,
+		.5px -.866px 1px #000,
+		.866px -.5px 1px #000;
+	mask-image: linear-gradient(90deg,
+		rgb(0,0,0),
+		rgb(0,0,0) calc(100% - 16px),
+		rgba(0,0,0,0) 100%
+	);
+}
+
+.icon {
+	height: $height;
+	flex-shrink: 0;
+}
+
+.name {
+	margin-left: 4px;
+	line-height: 1;
+	font-size: 0.9em;
+	font-weight: bold;
+	white-space: nowrap;
+	overflow: visible;
+}
+</style>
diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue
new file mode 100644
index 0000000000..ff69c79641
--- /dev/null
+++ b/packages/frontend/src/components/MkKeyValue.vue
@@ -0,0 +1,58 @@
+<template>
+<div class="alqyeyti" :class="{ oneline }">
+	<div class="key">
+		<slot name="key"></slot>
+	</div>
+	<div class="value">
+		<slot name="value"></slot>
+		<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	copy?: string | null;
+	oneline?: boolean;
+}>(), {
+	copy: null,
+	oneline: false,
+});
+
+const copy_ = () => {
+	copyToClipboard(props.copy);
+	os.success();
+};
+</script>
+
+<style lang="scss" scoped>
+.alqyeyti {
+	> .key {
+		font-size: 0.85em;
+		padding: 0 0 0.25em 0;
+		opacity: 0.75;
+	}
+
+	&.oneline {
+		display: flex;
+
+		> .key {
+			width: 30%;
+			font-size: 1em;
+			padding: 0 8px 0 0;
+		}
+
+		> .value {
+			width: 70%;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
new file mode 100644
index 0000000000..1ccc648c72
--- /dev/null
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -0,0 +1,138 @@
+<template>
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
+	<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
+		<div class="main">
+			<template v-for="item in items">
+				<button v-if="item.action" v-click-anime class="_button" @click="$event => { item.action($event); close(); }">
+					<i class="icon" :class="item.icon"></i>
+					<div class="text">{{ item.text }}</div>
+					<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+				</button>
+				<MkA v-else v-click-anime :to="item.to" @click.passive="close()">
+					<i class="icon" :class="item.icon"></i>
+					<div class="text">{{ item.text }}</div>
+					<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+				</MkA>
+			</template>
+		</div>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import { navbarItemDef } from '@/navbar';
+import { instanceName } from '@/config';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { deviceKind } from '@/scripts/device-kind';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+	src?: HTMLElement;
+	anchor?: { x: string; y: string; };
+}>(), {
+	anchor: () => ({ x: 'right', y: 'center' }),
+});
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' :
+	deviceKind === 'smartphone' ? 'drawer' :
+	'dialog';
+
+const modal = $ref<InstanceType<typeof MkModal>>();
+
+const menu = defaultStore.state.menu;
+
+const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
+	type: def.to ? 'link' : 'button',
+	text: i18n.ts[def.title],
+	icon: def.icon,
+	to: def.to,
+	action: def.action,
+	indicate: def.indicated,
+}));
+
+function close() {
+	modal.close();
+}
+</script>
+
+<style lang="scss" scoped>
+.szkkfdyq {
+	max-height: 100%;
+	width: min(460px, 100vw);
+	padding: 24px;
+	box-sizing: border-box;
+	overflow: auto;
+	overscroll-behavior: contain;
+	text-align: left;
+	border-radius: 16px;
+
+	&.asDrawer {
+		width: 100%;
+		padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
+		border-radius: 24px;
+		border-bottom-right-radius: 0;
+		border-bottom-left-radius: 0;
+		text-align: center;
+	}
+
+	> .main, > .sub {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+
+		> * {
+			position: relative;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			justify-content: center;
+			vertical-align: bottom;
+			height: 100px;
+			border-radius: 10px;
+
+			&:hover {
+				color: var(--accent);
+				background: var(--accentedBg);
+				text-decoration: none;
+			}
+
+			> .icon {
+				font-size: 24px;
+				height: 24px;
+			}
+
+			> .text {
+				margin-top: 12px;
+				font-size: 0.8em;
+				line-height: 1.5em;
+			}
+
+			> .indicator {
+				position: absolute;
+				top: 32px;
+				left: 32px;
+				color: var(--indicator);
+				font-size: 8px;
+				animation: blink 1s infinite;
+
+				@media (max-width: 500px) {
+					top: 16px;
+					left: 16px;
+				}
+			}
+		}
+	}
+
+	> .sub {
+		margin-top: 8px;
+		padding-top: 8px;
+		border-top: solid 0.5px var(--divider);
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
new file mode 100644
index 0000000000..6148ec6195
--- /dev/null
+++ b/packages/frontend/src/components/MkLink.vue
@@ -0,0 +1,47 @@
+<template>
+<component
+	:is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+	:title="url"
+>
+	<slot></slot>
+	<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { url as local } from '@/config';
+import { useTooltip } from '@/scripts/use-tooltip';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+	url: string;
+	rel?: null | string;
+}>(), {
+});
+
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+
+const el = $ref();
+
+useTooltip($$(el), (showing) => {
+	os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
+		showing,
+		url: props.url,
+		source: el,
+	}, {}, 'closed');
+});
+</script>
+
+<style lang="scss" scoped>
+.xlcxczvw {
+	word-break: break-all;
+
+	> .icon {
+		padding-left: 2px;
+		font-size: .9em;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue
new file mode 100644
index 0000000000..5ca04b0b48
--- /dev/null
+++ b/packages/frontend/src/components/MkMarquee.vue
@@ -0,0 +1,106 @@
+<script lang="ts">
+import { h, onMounted, onUnmounted, ref, watch } from 'vue';
+
+export default {
+	name: 'MarqueeText',
+	props: {
+		duration: {
+			type: Number,
+			default: 15,
+		},
+		repeat: {
+			type: Number,
+			default: 2,
+		},
+		paused: {
+			type: Boolean,
+			default: false,
+		},
+		reverse: {
+			type: Boolean,
+			default: false,
+		},
+	},
+	setup(props) {
+		const contentEl = ref();
+
+		function calc() {
+			const eachLength = contentEl.value.offsetWidth / props.repeat;
+			const factor = 3000;
+			const duration = props.duration / ((1 / eachLength) * factor);
+
+			contentEl.value.style.animationDuration = `${duration}s`;
+		}
+
+		watch(() => props.duration, calc);
+
+		onMounted(() => {
+			calc();
+		});
+
+		onUnmounted(() => {
+		});
+
+		return {
+			contentEl,
+		};
+	},
+	render({
+		$slots, $style, $props: {
+			duration, repeat, paused, reverse,
+		},
+	}) {
+		return h('div', { class: [$style.wrap] }, [
+			h('span', {
+				ref: 'contentEl',
+				class: [
+					paused
+						? $style.paused
+						: undefined,
+					$style.content,
+				],
+			}, Array(repeat).fill(
+				h('span', {
+					class: $style.text,
+					style: {
+						animationDirection: reverse
+							? 'reverse'
+							: undefined,
+					},
+				}, $slots.default()),
+			)),
+		]);
+	},
+};
+</script>
+
+<style lang="scss" module>
+.wrap {
+	overflow: clip;
+	animation-play-state: running;
+
+	&:hover {
+		animation-play-state: paused;
+	}
+}
+.content {
+	display: inline-block;
+	white-space: nowrap;
+	animation-play-state: inherit;
+}
+.text {
+	display: inline-block;
+	animation-name: marquee;
+	animation-timing-function: linear;
+	animation-iteration-count: infinite;
+	animation-duration: inherit;
+	animation-play-state: inherit;
+}
+.paused .text {
+	animation-play-state: paused;
+}
+@keyframes marquee {
+	0% { transform:translateX(0); }
+	100% { transform:translateX(-100%); }
+}
+</style>
diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue
new file mode 100644
index 0000000000..aa06c00fc6
--- /dev/null
+++ b/packages/frontend/src/components/MkMediaBanner.vue
@@ -0,0 +1,102 @@
+<template>
+<div class="mk-media-banner">
+	<div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false">
+		<span class="icon"><i class="ti ti-alert-triangle"></i></span>
+		<b>{{ $ts.sensitive }}</b>
+		<span>{{ $ts.clickToShow }}</span>
+	</div>
+	<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
+		<audio
+			ref="audioEl"
+			class="audio"
+			:src="media.url"
+			:title="media.name"
+			controls
+			preload="metadata"
+			@volumechange="volumechange"
+		/>
+	</div>
+	<a
+		v-else class="download"
+		:href="media.url"
+		:title="media.name"
+		:download="media.name"
+	>
+		<span class="icon"><i class="ti ti-download"></i></span>
+		<b>{{ media.name }}</b>
+	</a>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import { ColdDeviceStorage } from '@/store';
+
+const props = withDefaults(defineProps<{
+	media: misskey.entities.DriveFile;
+}>(), {
+});
+
+const audioEl = $ref<HTMLAudioElement | null>();
+let hide = $ref(true);
+
+function volumechange() {
+	if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
+}
+
+onMounted(() => {
+	if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-media-banner {
+	width: 100%;
+	border-radius: 4px;
+	margin-top: 4px;
+	overflow: hidden;
+
+	> .download,
+	> .sensitive {
+		display: flex;
+		align-items: center;
+		font-size: 12px;
+		padding: 8px 12px;
+		white-space: nowrap;
+
+		> * {
+			display: block;
+		}
+
+		> b {
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+
+		> *:not(:last-child) {
+			margin-right: .2em;
+		}
+
+		> .icon {
+			font-size: 1.6em;
+		}
+	}
+
+	> .download {
+		background: var(--noteAttachedFile);
+	}
+
+	> .sensitive {
+		background: #111;
+		color: #fff;
+	}
+
+	> .audio {
+		.audio {
+			display: block;
+			width: 100%;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
new file mode 100644
index 0000000000..56570eaa05
--- /dev/null
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -0,0 +1,130 @@
+<template>
+<div v-if="hide" class="qjewsnkg" @click="hide = false">
+	<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
+	<div class="text">
+		<div class="wrapper">
+			<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
+			<span style="display: block;">{{ $ts.clickToShow }}</span>
+		</div>
+	</div>
+</div>
+<div v-else class="gqnyydlz">
+	<a
+		:href="image.url"
+		:title="image.name"
+	>
+		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
+		<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
+	</a>
+	<button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { watch } from 'vue';
+import * as misskey from 'misskey-js';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+	image: misskey.entities.DriveFile;
+	raw?: boolean;
+}>();
+
+let hide = $ref(true);
+
+const url = (props.raw || defaultStore.state.loadRawImages)
+	? props.image.url
+	: defaultStore.state.disableShowingAnimatedImages
+		? getStaticImageUrl(props.image.thumbnailUrl)
+		: props.image.thumbnailUrl;
+
+// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
+watch(() => props.image, () => {
+	hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore');
+}, {
+	deep: true,
+	immediate: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.qjewsnkg {
+	position: relative;
+
+	> .bg {
+		filter: brightness(0.5);
+	}
+
+	> .text {
+		position: absolute;
+		left: 0;
+		top: 0;
+		width: 100%;
+		height: 100%;
+		z-index: 1;
+		display: flex;
+		justify-content: center;
+		align-items: center;
+
+		> .wrapper {
+			display: table-cell;
+			text-align: center;
+			font-size: 0.8em;
+			color: #fff;
+		}
+	}
+}
+
+.gqnyydlz {
+	position: relative;
+	//box-shadow: 0 0 0 1px var(--divider) inset;
+	background: var(--bg);
+
+	> .hide {
+		display: block;
+		position: absolute;
+		border-radius: 6px;
+		background-color: var(--accentedBg);
+		-webkit-backdrop-filter: var(--blur, blur(15px));
+		backdrop-filter: var(--blur, blur(15px));
+		color: var(--accent);
+		font-size: 0.8em;
+		padding: 6px 8px;
+		text-align: center;
+		top: 12px;
+		right: 12px;
+
+		> i {
+			display: block;
+		}
+	}
+
+	> a {
+		display: block;
+		cursor: zoom-in;
+		overflow: hidden;
+		width: 100%;
+		height: 100%;
+		background-position: center;
+		background-size: contain;
+		background-repeat: no-repeat;
+
+		> .gif {
+			background-color: var(--fg);
+			border-radius: 6px;
+			color: var(--accentLighten);
+			display: inline-block;
+			font-size: 14px;
+			font-weight: bold;
+			left: 12px;
+			opacity: .5;
+			padding: 0 6px;
+			text-align: center;
+			top: 12px;
+			pointer-events: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
new file mode 100644
index 0000000000..c6f8612182
--- /dev/null
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -0,0 +1,189 @@
+<template>
+<div class="hoawjimk">
+	<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
+	<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
+		<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length">
+			<template v-for="media in mediaList.filter(media => previewable(media))">
+				<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
+				<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
+			</template>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import * as misskey from 'misskey-js';
+import PhotoSwipeLightbox from 'photoswipe/lightbox';
+import PhotoSwipe from 'photoswipe';
+import 'photoswipe/style.css';
+import XBanner from '@/components/MkMediaBanner.vue';
+import XImage from '@/components/MkMediaImage.vue';
+import XVideo from '@/components/MkMediaVideo.vue';
+import * as os from '@/os';
+import { FILE_TYPE_BROWSERSAFE } from '@/const';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+	mediaList: misskey.entities.DriveFile[];
+	raw?: boolean;
+}>();
+
+const gallery = ref(null);
+const pswpZIndex = os.claimZIndex('middle');
+
+onMounted(() => {
+	const lightbox = new PhotoSwipeLightbox({
+		dataSource: props.mediaList
+			.filter(media => {
+				if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
+				return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
+			})
+			.map(media => {
+				const item = {
+					src: media.url,
+					w: media.properties.width,
+					h: media.properties.height,
+					alt: media.name,
+				};
+				if (media.properties.orientation != null && media.properties.orientation >= 5) {
+					[item.w, item.h] = [item.h, item.w];
+				}
+				return item;
+			}),
+		gallery: gallery.value,
+		children: '.image',
+		thumbSelector: '.image',
+		loop: false,
+		padding: window.innerWidth > 500 ? {
+			top: 32,
+			bottom: 32,
+			left: 32,
+			right: 32,
+		} : {
+			top: 0,
+			bottom: 0,
+			left: 0,
+			right: 0,
+		},
+		imageClickAction: 'close',
+		tapAction: 'toggle-controls',
+		pswpModule: PhotoSwipe,
+	});
+
+	lightbox.on('itemData', (ev) => {
+		const { itemData } = ev;
+
+		// element is children
+		const { element } = itemData;
+
+		const id = element.dataset.id;
+		const file = props.mediaList.find(media => media.id === id);
+
+		itemData.src = file.url;
+		itemData.w = Number(file.properties.width);
+		itemData.h = Number(file.properties.height);
+		if (file.properties.orientation != null && file.properties.orientation >= 5) {
+			[itemData.w, itemData.h] = [itemData.h, itemData.w];
+		}
+		itemData.msrc = file.thumbnailUrl;
+		itemData.thumbCropped = true;
+	});
+
+	lightbox.init();
+});
+
+const previewable = (file: misskey.entities.DriveFile): boolean => {
+	if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
+	// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
+	return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
+};
+</script>
+
+<style lang="scss" scoped>
+.hoawjimk {
+	> .gird-container {
+		position: relative;
+		width: 100%;
+		margin-top: 4px;
+
+		&:before {
+			content: '';
+			display: block;
+			padding-top: 56.25% // 16:9;
+		}
+
+		> div {
+			position: absolute;
+			top: 0;
+			right: 0;
+			bottom: 0;
+			left: 0;
+			display: grid;
+			grid-gap: 8px;
+
+			> * {
+				overflow: hidden;
+				border-radius: 6px;
+			}
+
+			&[data-count="1"] {
+				grid-template-rows: 1fr;
+			}
+
+			&[data-count="2"] {
+				grid-template-columns: 1fr 1fr;
+				grid-template-rows: 1fr;
+			}
+
+			&[data-count="3"] {
+				grid-template-columns: 1fr 0.5fr;
+				grid-template-rows: 1fr 1fr;
+
+				> *:nth-child(1) {
+					grid-row: 1 / 3;
+				}
+
+				> *:nth-child(3) {
+					grid-column: 2 / 3;
+					grid-row: 2 / 3;
+				}
+			}
+
+			&[data-count="4"] {
+				grid-template-columns: 1fr 1fr;
+				grid-template-rows: 1fr 1fr;
+			}
+
+			> *:nth-child(1) {
+				grid-column: 1 / 2;
+				grid-row: 1 / 2;
+			}
+
+			> *:nth-child(2) {
+				grid-column: 2 / 3;
+				grid-row: 1 / 2;
+			}
+
+			> *:nth-child(3) {
+				grid-column: 1 / 2;
+				grid-row: 2 / 3;
+			}
+
+			> *:nth-child(4) {
+				grid-column: 2 / 3;
+				grid-row: 2 / 3;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss">
+.pswp {
+	// なぜか機能しない
+  //z-index: v-bind(pswpZIndex);
+	z-index: 2000000;
+}
+</style>
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
new file mode 100644
index 0000000000..df0bf84116
--- /dev/null
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -0,0 +1,88 @@
+<template>
+<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false">
+	<div>
+		<b><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
+		<span>{{ $ts.clickToShow }}</span>
+	</div>
+</div>
+<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
+	<video
+		:poster="video.thumbnailUrl"
+		:title="video.comment"
+		:alt="video.comment"
+		preload="none"
+		controls
+		@contextmenu.stop
+	>
+		<source 
+			:src="video.url" 
+			:type="video.type"
+		>
+	</video>
+	<i class="ti ti-eye-off" @click="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+	video: misskey.entities.DriveFile;
+}>();
+
+const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore'));
+</script>
+
+<style lang="scss" scoped>
+.kkjnbbplepmiyuadieoenjgutgcmtsvu {
+	position: relative;
+
+	> i {
+		display: block;
+		position: absolute;
+		border-radius: 6px;
+		background-color: var(--fg);
+		color: var(--accentLighten);
+		font-size: 14px;
+		opacity: .5;
+		padding: 3px 6px;
+		text-align: center;
+		cursor: pointer;
+		top: 12px;
+		right: 12px;
+	}
+
+	> video {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+
+		font-size: 3.5em;
+		overflow: hidden;
+		background-position: center;
+		background-size: cover;
+		width: 100%;
+		height: 100%;
+	}
+}
+
+.icozogqfvdetwohsdglrbswgrejoxbdj {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	background: #111;
+	color: #fff;
+
+	> div {
+		display: table-cell;
+		text-align: center;
+		font-size: 12px;
+
+		> b {
+			display: block;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
new file mode 100644
index 0000000000..3091b435e4
--- /dev/null
+++ b/packages/frontend/src/components/MkMention.vue
@@ -0,0 +1,66 @@
+<template>
+<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }">
+	<img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
+	<span class="main">
+		<span class="username">@{{ username }}</span>
+		<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
+	</span>
+</MkA>
+<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
+	<span class="main">
+		<span class="username">@{{ username }}</span>
+		<span class="host">@{{ toUnicode(host) }}</span>
+	</span>
+</a>
+</template>
+
+<script lang="ts" setup>
+import { toUnicode } from 'punycode';
+import { } from 'vue';
+import tinycolor from 'tinycolor2';
+import { host as localHost } from '@/config';
+import { $i } from '@/account';
+
+const props = defineProps<{
+	username: string;
+	host: string;
+}>();
+
+const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
+
+const url = `/${canonical}`;
+
+const isMe = $i && (
+	`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
+);
+
+const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
+bg.setAlpha(0.1);
+const bgCss = bg.toRgbString();
+</script>
+
+<style lang="scss" scoped>
+.akbvjaqn {
+	display: inline-block;
+	padding: 4px 8px 4px 4px;
+	border-radius: 999px;
+	color: var(--mention);
+
+	&.isMe {
+		color: var(--mentionMe);
+	}
+
+	> .icon {
+		width: 1.5em;
+		height: 1.5em;
+		object-fit: cover;
+		margin: 0 0.2em 0 0;
+		vertical-align: bottom;
+		border-radius: 100%;
+	}
+
+	> .main > .host {
+		opacity: 0.5;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue
new file mode 100644
index 0000000000..3ada4afbdc
--- /dev/null
+++ b/packages/frontend/src/components/MkMenu.child.vue
@@ -0,0 +1,65 @@
+<template>
+<div ref="el" class="sfhdhdhr">
+	<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { on } from 'events';
+import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue';
+import MkMenu from './MkMenu.vue';
+import { MenuItem } from '@/types/menu';
+import * as os from '@/os';
+
+const props = defineProps<{
+	items: MenuItem[];
+	targetElement: HTMLElement;
+	rootElement: HTMLElement;
+	width?: number;
+	viaKeyboard?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'actioned'): void;
+}>();
+
+const el = ref<HTMLElement>();
+const align = 'left';
+
+function setPosition() {
+	const rootRect = props.rootElement.getBoundingClientRect();
+	const rect = props.targetElement.getBoundingClientRect();
+	const left = props.targetElement.offsetWidth;
+	const top = (rect.top - rootRect.top) - 8;
+	el.value.style.left = left + 'px';
+	el.value.style.top = top + 'px';
+}
+
+function onChildClosed(actioned?: boolean) {
+	if (actioned) {
+		emit('actioned');
+	} else {
+		emit('closed');
+	}
+}
+
+onMounted(() => {
+	setPosition();
+	nextTick(() => {
+		setPosition();
+	});
+});
+
+defineExpose({
+	checkHit: (ev: MouseEvent) => {
+		return (ev.target === el.value || el.value.contains(ev.target));
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.sfhdhdhr {
+	position: absolute;
+}
+</style>
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
new file mode 100644
index 0000000000..64d18b6b7c
--- /dev/null
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -0,0 +1,367 @@
+<template>
+<div>
+	<div
+		ref="itemsEl" v-hotkey="keymap"
+		class="rrevdjwt _popup _shadow"
+		:class="{ center: align === 'center', asDrawer }"
+		:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
+		@contextmenu.self="e => e.preventDefault()"
+	>
+		<template v-for="(item, i) in items2">
+			<div v-if="item === null" class="divider"></div>
+			<span v-else-if="item.type === 'label'" class="label item">
+				<span>{{ item.text }}</span>
+			</span>
+			<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
+				<span><MkEllipsis/></span>
+			</span>
+			<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
+				<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+				<span>{{ item.text }}</span>
+				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+			</MkA>
+			<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
+				<span>{{ item.text }}</span>
+				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+			</a>
+			<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+				<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
+				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+			</button>
+			<span v-else-if="item.type === 'switch'" :tabindex="i" class="item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+				<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
+			</span>
+			<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button item parent" :class="{ childShowing: childShowingItem === item }" @mouseenter="showChildren(item, $event)">
+				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
+				<span>{{ item.text }}</span>
+				<span class="caret"><i class="ti ti-caret-right ti-fw"></i></span>
+			</button>
+			<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+				<i v-if="item.icon" class="ti-fw" :class="item.icon"></i>
+				<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+				<span>{{ item.text }}</span>
+				<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
+			</button>
+		</template>
+		<span v-if="items2.length === 0" class="none item">
+			<span>{{ i18n.ts.none }}</span>
+		</span>
+	</div>
+	<div v-if="childMenu" class="child">
+		<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import FormSwitch from '@/components/form/switch.vue';
+import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
+
+const props = defineProps<{
+	items: MenuItem[];
+	viaKeyboard?: boolean;
+	asDrawer?: boolean;
+	align?: 'center' | string;
+	width?: number;
+	maxHeight?: number;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'close', actioned?: boolean): void;
+}>();
+
+let itemsEl = $ref<HTMLDivElement>();
+
+let items2: InnerMenuItem[] = $ref([]);
+
+let child = $ref<InstanceType<typeof XChild>>();
+
+let keymap = $computed(() => ({
+	'up|k|shift+tab': focusUp,
+	'down|j|tab': focusDown,
+	'esc': close,
+}));
+
+let childShowingItem = $ref<MenuItem | null>();
+
+watch(() => props.items, () => {
+	const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined);
+
+	for (let i = 0; i < items.length; i++) {
+		const item = items[i];
+
+		if (item && 'then' in item) { // if item is Promise
+			items[i] = { type: 'pending' };
+			item.then(actualItem => {
+				items2[i] = actualItem;
+			});
+		}
+	}
+
+	items2 = items as InnerMenuItem[];
+}, {
+	immediate: true,
+});
+
+let childMenu = $ref<MenuItem[] | null>();
+let childTarget = $ref<HTMLElement | null>();
+
+function closeChild() {
+	childMenu = null;
+	childShowingItem = null;
+}
+
+function childActioned() {
+	closeChild();
+	close(true);
+}
+
+function onGlobalMousedown(event: MouseEvent) {
+	if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return;
+	if (child && child.checkHit(event)) return;
+	closeChild();
+}
+
+let childCloseTimer: null | number = null;
+function onItemMouseEnter(item) {
+	childCloseTimer = window.setTimeout(() => {
+		closeChild();
+	}, 300);
+}
+function onItemMouseLeave(item) {
+	if (childCloseTimer) window.clearTimeout(childCloseTimer);
+}
+
+async function showChildren(item: MenuItem, ev: MouseEvent) {
+	if (props.asDrawer) {
+		os.popupMenu(item.children, ev.currentTarget ?? ev.target);
+		close();
+	} else {
+		childTarget = ev.currentTarget ?? ev.target;
+		childMenu = item.children;
+		childShowingItem = item;
+	}
+}
+
+function clicked(fn: MenuAction, ev: MouseEvent) {
+	fn(ev);
+	close(true);
+}
+
+function close(actioned = false) {
+	emit('close', actioned);
+}
+
+function focusUp() {
+	focusPrev(document.activeElement);
+}
+
+function focusDown() {
+	focusNext(document.activeElement);
+}
+
+onMounted(() => {
+	if (props.viaKeyboard) {
+		nextTick(() => {
+			focusNext(itemsEl.children[0], true, false);
+		});
+	}
+
+	// TODO: アクティブな要素までスクロール
+	//itemsEl.scrollTo();
+
+	document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
+});
+
+onBeforeUnmount(() => {
+	document.removeEventListener('mousedown', onGlobalMousedown);
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwt {
+	padding: 8px 0;
+	box-sizing: border-box;
+	min-width: 200px;
+	overflow: auto;
+	overscroll-behavior: contain;
+
+	&.center {
+		> .item {
+			text-align: center;
+		}
+	}
+
+	> .item {
+		display: block;
+		position: relative;
+		padding: 6px 16px;
+		width: 100%;
+		box-sizing: border-box;
+		white-space: nowrap;
+		font-size: 0.9em;
+		line-height: 20px;
+		text-align: left;
+		overflow: hidden;
+		text-overflow: ellipsis;
+
+		&:before {
+			content: "";
+			display: block;
+			position: absolute;
+			top: 0;
+			left: 0;
+			right: 0;
+			margin: auto;
+			width: calc(100% - 16px);
+			height: 100%;
+			border-radius: 6px;
+		}
+
+		> * {
+			position: relative;
+		}
+
+		&:not(:disabled):hover {
+			color: var(--accent);
+			text-decoration: none;
+
+			&:before {
+				background: var(--accentedBg);
+			}
+		}
+
+		&.danger {
+			color: #ff2a2a;
+
+			&:hover {
+				color: #fff;
+
+				&:before {
+					background: #ff4242;
+				}
+			}
+
+			&:active {
+				color: #fff;
+
+				&:before {
+					background: #d42e2e;
+				}
+			}
+		}
+
+		&.active {
+			color: var(--fgOnAccent);
+			opacity: 1;
+
+			&:before {
+				background: var(--accent);
+			}
+		}
+
+		&:not(:active):focus-visible {
+			box-shadow: 0 0 0 2px var(--focus) inset;
+		}
+
+		&.label {
+			pointer-events: none;
+			font-size: 0.7em;
+			padding-bottom: 4px;
+
+			> span {
+				opacity: 0.7;
+			}
+		}
+
+		&.pending {
+			pointer-events: none;
+			opacity: 0.7;
+		}
+
+		&.none {
+			pointer-events: none;
+			opacity: 0.7;
+		}
+
+		&.parent {
+			display: flex;
+			align-items: center;
+			cursor: default;
+
+			> .caret {
+				margin-left: auto;
+			}
+
+			&.childShowing {
+				color: var(--accent);
+				text-decoration: none;
+
+				&:before {
+					background: var(--accentedBg);
+				}
+			}
+		}
+
+		> i {
+			margin-right: 5px;
+			width: 20px;
+		}
+
+		> .avatar {
+			margin-right: 5px;
+			width: 20px;
+			height: 20px;
+		}
+
+		> .indicator {
+			position: absolute;
+			top: 5px;
+			left: 13px;
+			color: var(--indicator);
+			font-size: 12px;
+			animation: blink 1s infinite;
+		}
+	}
+
+	> .divider {
+		margin: 8px 0;
+		border-top: solid 0.5px var(--divider);
+	}
+
+	&.asDrawer {
+		padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 12px) 0;
+		width: 100%;
+		border-radius: 24px;
+		border-bottom-right-radius: 0;
+		border-bottom-left-radius: 0;
+
+		> .item {
+			font-size: 1em;
+			padding: 12px 24px;
+
+			&:before {
+				width: calc(100% - 24px);
+				border-radius: 12px;
+			}
+
+			> i {
+				margin-right: 14px;
+				width: 24px;
+			}
+		}
+
+		> .divider {
+			margin: 12px 0;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue
new file mode 100644
index 0000000000..c64ce163f9
--- /dev/null
+++ b/packages/frontend/src/components/MkMiniChart.vue
@@ -0,0 +1,73 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible">
+	<defs>
+		<linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
+			<stop offset="0%" :stop-color="color" stop-opacity="0"></stop>
+			<stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop>
+		</linearGradient>
+	</defs>
+	<polygon
+		:points="polygonPoints"
+		:style="`stroke: none; fill: url(#${ gradientId });`"
+	/>
+	<polyline
+		:points="polylinePoints"
+		fill="none"
+		:stroke="color"
+		stroke-width="2"
+	/>
+	<circle
+		:cx="headX"
+		:cy="headY"
+		r="3"
+		:fill="color"
+	/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import tinycolor from 'tinycolor2';
+import { useInterval } from '@/scripts/use-interval';
+
+const props = defineProps<{
+	src: number[];
+}>();
+
+const viewBoxX = 50;
+const viewBoxY = 50;
+const gradientId = uuid();
+let polylinePoints = $ref('');
+let polygonPoints = $ref('');
+let headX = $ref<number | null>(null);
+let headY = $ref<number | null>(null);
+let clock = $ref<number | null>(null);
+const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent'));
+const color = accent.toRgbString();
+
+function draw(): void {
+	const stats = props.src.slice().reverse();
+	const peak = Math.max.apply(null, stats) || 1;
+
+	const _polylinePoints = stats.map((n, i) => [
+		i * (viewBoxX / (stats.length - 1)),
+		(1 - (n / peak)) * viewBoxY,
+	]);
+
+	polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+	polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`;
+
+	headX = _polylinePoints[_polylinePoints.length - 1][0];
+	headY = _polylinePoints[_polylinePoints.length - 1][1];
+}
+
+watch(() => props.src, draw, { immediate: true });
+
+// Vueが何故かWatchを発動させない場合があるので
+useInterval(draw, 1000, {
+	immediate: false,
+	afterMounted: true,
+});
+</script>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
new file mode 100644
index 0000000000..2305a02794
--- /dev/null
+++ b/packages/frontend/src/components/MkModal.vue
@@ -0,0 +1,406 @@
+<template>
+<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
+	<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+		<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
+		<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
+			<slot :max-height="maxHeight" :type="type"></slot>
+		</div>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, watch, provide } from 'vue';
+import * as os from '@/os';
+import { isTouchUsing } from '@/scripts/touch';
+import { defaultStore } from '@/store';
+import { deviceKind } from '@/scripts/device-kind';
+
+function getFixedContainer(el: Element | null): Element | null {
+	if (el == null || el.tagName === 'BODY') return null;
+	const position = window.getComputedStyle(el).getPropertyValue('position');
+	if (position === 'fixed') {
+		return el;
+	} else {
+		return getFixedContainer(el.parentElement);
+	}
+}
+
+type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer';
+
+const props = withDefaults(defineProps<{
+	manualShowing?: boolean | null;
+	anchor?: { x: string; y: string; };
+	src?: HTMLElement;
+	preferType?: ModalTypes | 'auto';
+	zPriority?: 'low' | 'middle' | 'high';
+	noOverlap?: boolean;
+	transparentBg?: boolean;
+}>(), {
+	manualShowing: null,
+	src: null,
+	anchor: () => ({ x: 'center', y: 'bottom' }),
+	preferType: 'auto',
+	zPriority: 'low',
+	noOverlap: true,
+	transparentBg: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'opening'): void;
+	(ev: 'opened'): void;
+	(ev: 'click'): void;
+	(ev: 'esc'): void;
+	(ev: 'close'): void;
+	(ev: 'closed'): void;
+}>();
+
+provide('modal', true);
+
+let maxHeight = $ref<number>();
+let fixed = $ref(false);
+let transformOrigin = $ref('center');
+let showing = $ref(true);
+let content = $ref<HTMLElement>();
+const zIndex = os.claimZIndex(props.zPriority);
+const type = $computed(() => {
+	if (props.preferType === 'auto') {
+		if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
+			return 'drawer';
+		} else {
+			return props.src != null ? 'popup' : 'dialog';
+		}
+	} else {
+		return props.preferType!;
+	}
+});
+
+let contentClicking = false;
+
+const close = () => {
+	// eslint-disable-next-line vue/no-mutating-props
+	if (props.src) props.src.style.pointerEvents = 'auto';
+	showing = false;
+	emit('close');
+};
+
+const onBgClick = () => {
+	if (contentClicking) return;
+	emit('click');
+};
+
+if (type === 'drawer') {
+	maxHeight = window.innerHeight / 1.5;
+}
+
+const keymap = {
+	'esc': () => emit('esc'),
+};
+
+const MARGIN = 16;
+
+const align = () => {
+	if (props.src == null) return;
+	if (type === 'drawer') return;
+	if (type === 'dialog') return;
+
+	if (content == null) return;
+
+	const srcRect = props.src.getBoundingClientRect();
+
+	const width = content!.offsetWidth;
+	const height = content!.offsetHeight;
+
+	let left;
+	let top;
+
+	const x = srcRect.left + (fixed ? 0 : window.pageXOffset);
+	const y = srcRect.top + (fixed ? 0 : window.pageYOffset);
+
+	if (props.anchor.x === 'center') {
+		left = x + (props.src.offsetWidth / 2) - (width / 2);
+	} else if (props.anchor.x === 'left') {
+		// TODO
+	} else if (props.anchor.x === 'right') {
+		left = x + props.src.offsetWidth;
+	}
+
+	if (props.anchor.y === 'center') {
+		top = (y - (height / 2));
+	} else if (props.anchor.y === 'top') {
+		// TODO
+	} else if (props.anchor.y === 'bottom') {
+		top = y + props.src.offsetHeight;
+	}
+
+	if (fixed) {
+		// 画面から横にはみ出る場合
+		if (left + width > window.innerWidth) {
+			left = window.innerWidth - width;
+		}
+
+		const underSpace = (window.innerHeight - MARGIN) - top;
+		const upperSpace = (srcRect.top - MARGIN);
+
+		// 画面から縦にはみ出る場合
+		if (top + height > (window.innerHeight - MARGIN)) {
+			if (props.noOverlap && props.anchor.x === 'center') {
+				if (underSpace >= (upperSpace / 3)) {
+					maxHeight = underSpace;
+				} else {
+					maxHeight = upperSpace;
+					top = (upperSpace + MARGIN) - height;
+				}
+			} else {
+				top = (window.innerHeight - MARGIN) - height;
+			}
+		} else {
+			maxHeight = underSpace;
+		}
+	} else {
+		// 画面から横にはみ出る場合
+		if (left + width - window.pageXOffset > window.innerWidth) {
+			left = window.innerWidth - width + window.pageXOffset - 1;
+		}
+
+		const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset);
+		const upperSpace = (srcRect.top - MARGIN);
+
+		// 画面から縦にはみ出る場合
+		if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
+			if (props.noOverlap && props.anchor.x === 'center') {
+				if (underSpace >= (upperSpace / 3)) {
+					maxHeight = underSpace;
+				} else {
+					maxHeight = upperSpace;
+					top = window.pageYOffset + ((upperSpace + MARGIN) - height);
+				}
+			} else {
+				top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1;
+			}
+		} else {
+			maxHeight = underSpace;
+		}
+	}
+
+	if (top < 0) {
+		top = MARGIN;
+	}
+
+	if (left < 0) {
+		left = 0;
+	}
+
+	let transformOriginX = 'center';
+	let transformOriginY = 'center';
+
+	if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) {
+		transformOriginY = 'top';
+	} else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) {
+		transformOriginY = 'bottom';
+	}
+
+	if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) {
+		transformOriginX = 'left';
+	} else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) {
+		transformOriginX = 'right';
+	}
+
+	transformOrigin = `${transformOriginX} ${transformOriginY}`;
+
+	content.style.left = left + 'px';
+	content.style.top = top + 'px';
+};
+
+const onOpened = () => {
+	emit('opened');
+
+	// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+	const el = content!.children[0];
+	el.addEventListener('mousedown', ev => {
+		contentClicking = true;
+		window.addEventListener('mouseup', ev => {
+			// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+			window.setTimeout(() => {
+				contentClicking = false;
+			}, 100);
+		}, { passive: true, once: true });
+	}, { passive: true });
+};
+
+onMounted(() => {
+	watch(() => props.src, async () => {
+		if (props.src) {
+			// eslint-disable-next-line vue/no-mutating-props
+			props.src.style.pointerEvents = 'none';
+		}
+		fixed = (type === 'drawer') || (getFixedContainer(props.src) != null);
+
+		await nextTick();
+		
+		align();
+	}, { immediate: true });
+
+	nextTick(() => {
+		new ResizeObserver((entries, observer) => {
+			align();
+		}).observe(content!);
+	});
+});
+
+defineExpose({
+	close,
+});
+</script>
+
+<style lang="scss" scoped>
+.modal-enter-active, .modal-leave-active {
+	> .bg {
+		transition: opacity 0.2s !important;
+	}
+
+	> .content {
+		transform-origin: var(--transformOrigin);
+		transition: opacity 0.2s, transform 0.2s !important;
+	}
+}
+.modal-enter-from, .modal-leave-to {
+	> .bg {
+		opacity: 0;
+	}
+
+	> .content {
+		pointer-events: none;
+		opacity: 0;
+		transform-origin: var(--transformOrigin);
+		transform: scale(0.9);
+	}
+}
+
+.modal-popup-enter-active, .modal-popup-leave-active {
+	> .bg {
+		transition: opacity 0.2s !important;
+	}
+
+	> .content {
+		transform-origin: var(--transformOrigin);
+		transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
+	}
+}
+.modal-popup-enter-from, .modal-popup-leave-to {
+	> .bg {
+		opacity: 0;
+	}
+
+	> .content {
+		pointer-events: none;
+		opacity: 0;
+		transform-origin: var(--transformOrigin);
+		transform: scale(0.9);
+	}
+}
+
+.modal-drawer-enter-active {
+	> .bg {
+		transition: opacity 0.2s !important;
+	}
+
+	> .content {
+		transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
+	}
+}
+.modal-drawer-leave-active {
+	> .bg {
+		transition: opacity 0.2s !important;
+	}
+
+	> .content {
+		transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
+	}
+}
+.modal-drawer-enter-from, .modal-drawer-leave-to {
+	> .bg {
+		opacity: 0;
+	}
+
+	> .content {
+		pointer-events: none;
+		transform: translateY(100%);
+	}
+}
+
+.qzhlnise {
+	> .bg {
+		&.transparent {
+			background: transparent;
+			-webkit-backdrop-filter: none;
+			backdrop-filter: none;
+		}
+	}
+
+	&.dialog {
+		> .content {
+			position: fixed;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+			margin: auto;
+			padding: 32px;
+			// TODO: mask-imageはiOSだとやたら重い。なんとかしたい
+			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+			overflow: auto;
+			display: flex;
+
+			@media (max-width: 500px) {
+				padding: 16px;
+				-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+				mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+			}
+
+			> ::v-deep(*) {
+				margin: auto;
+			}
+
+			&.top {
+				> ::v-deep(*) {
+					margin-top: 0;
+				}
+			}
+		}
+	}
+
+	&.popup {
+		> .content {
+			position: absolute;
+
+			&.fixed {
+				position: fixed;
+			}
+		}
+	}
+
+	&.drawer {
+		position: fixed;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		overflow: clip;
+
+		> .content {
+			position: fixed;
+			bottom: 0;
+			left: 0;
+			right: 0;
+			margin: auto;
+
+			> ::v-deep(*) {
+				margin: auto;
+			}
+		}
+	}
+
+}
+</style>
diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue
new file mode 100644
index 0000000000..ced8a7a714
--- /dev/null
+++ b/packages/frontend/src/components/MkModalPageWindow.vue
@@ -0,0 +1,181 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+	<div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
+		<div class="header" @contextmenu="onContextmenu">
+			<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
+			<span v-else style="display: inline-block; width: 20px"></span>
+			<span v-if="pageMetadata?.value" class="title">
+				<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
+				<span>{{ pageMetadata?.value.title }}</span>
+			</span>
+			<button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button>
+		</div>
+		<div class="body">
+			<MkStickyContainer>
+				<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
+				<RouterView :router="router"/>
+			</MkStickyContainer>
+		</div>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { ComputedRef, provide } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import { popout as _popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { Router } from '@/nirax';
+
+const props = defineProps<{
+	initialPath: string;
+}>();
+
+defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'click'): void;
+}>();
+
+const router = new Router(routes, props.initialPath);
+
+router.addListener('push', ctx => {
+	
+});
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let rootEl = $ref();
+let modal = $ref<InstanceType<typeof MkModal>>();
+let path = $ref(props.initialPath);
+let width = $ref(860);
+let height = $ref(660);
+const history = [];
+
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const pageUrl = $computed(() => url + path);
+const contextmenu = $computed(() => {
+	return [{
+		type: 'label',
+		text: path,
+	}, {
+		icon: 'ti ti-player-eject',
+		text: i18n.ts.showInPage,
+		action: expand,
+	}, {
+		icon: 'ti ti-window-maximize',
+		text: i18n.ts.popout,
+		action: popout,
+	}, null, {
+		icon: 'ti ti-external-link',
+		text: i18n.ts.openInNewTab,
+		action: () => {
+			window.open(pageUrl, '_blank');
+			modal.close();
+		},
+	}, {
+		icon: 'ti ti-link',
+		text: i18n.ts.copyLink,
+		action: () => {
+			copyToClipboard(pageUrl);
+		},
+	}];
+});
+
+function navigate(path, record = true) {
+	if (record) history.push(router.getCurrentPath());
+	router.push(path);
+}
+
+function back() {
+	navigate(history.pop(), false);
+}
+
+function expand() {
+	mainRouter.push(path);
+	modal.close();
+}
+
+function popout() {
+	_popout(path, rootEl);
+	modal.close();
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(contextmenu, ev);
+}
+</script>
+
+<style lang="scss" scoped>
+.hrmcaedk {
+	overflow: hidden;
+	display: flex;
+	flex-direction: column;
+	contain: content;
+	border-radius: var(--radius);
+
+	--root-margin: 24px;
+
+	@media (max-width: 500px) {
+		--root-margin: 16px;
+	}
+
+	> .header {
+		$height: 52px;
+		$height-narrow: 42px;
+		display: flex;
+		flex-shrink: 0;
+		height: $height;
+		line-height: $height;
+		font-weight: bold;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		background: var(--windowHeader);
+		-webkit-backdrop-filter: var(--blur, blur(15px));
+		backdrop-filter: var(--blur, blur(15px));
+
+		> button {
+			height: $height;
+			width: $height;
+
+			&:hover {
+				color: var(--fgHighlighted);
+			}
+		}
+
+		@media (max-width: 500px) {
+			height: $height-narrow;
+			line-height: $height-narrow;
+			padding-left: 16px;
+
+			> button {
+				height: $height-narrow;
+				width: $height-narrow;
+			}
+		}
+
+		> .title {
+			flex: 1;
+
+			> .icon {
+				margin-right: 0.5em;
+			}
+		}
+	}
+
+	> .body {
+		overflow: auto;
+		background: var(--bg);
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
new file mode 100644
index 0000000000..d977ca6e9c
--- /dev/null
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -0,0 +1,146 @@
+<template>
+<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
+	<div ref="rootEl" class="ebkgoccj _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
+		<div ref="headerEl" class="header">
+			<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
+			<span class="title">
+				<slot name="header"></slot>
+			</span>
+			<button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
+			<button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
+		</div>
+		<div class="body">
+			<slot :width="bodyWidth" :height="bodyHeight"></slot>
+		</div>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
+import MkModal from './MkModal.vue';
+
+const props = withDefaults(defineProps<{
+	withOkButton: boolean;
+	okButtonDisabled: boolean;
+	width: number;
+	height: number | null;
+	scroll: boolean;
+}>(), {
+	withOkButton: false,
+	okButtonDisabled: false,
+	width: 400,
+	height: null,
+	scroll: true,
+});
+
+const emit = defineEmits<{
+	(event: 'click'): void;
+	(event: 'close'): void;
+	(event: 'closed'): void;
+	(event: 'ok'): void;
+}>();
+
+let modal = $ref<InstanceType<typeof MkModal>>();
+let rootEl = $ref<HTMLElement>();
+let headerEl = $ref<HTMLElement>();
+let bodyWidth = $ref(0);
+let bodyHeight = $ref(0);
+
+const close = () => {
+	modal.close();
+};
+
+const onBgClick = () => {
+	emit('click');
+};
+
+const onKeydown = (evt) => {
+	if (evt.which === 27) { // Esc
+		evt.preventDefault();
+		evt.stopPropagation();
+		close();
+	}
+};
+
+const ro = new ResizeObserver((entries, observer) => {
+	bodyWidth = rootEl.offsetWidth;
+	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
+});
+
+onMounted(() => {
+	bodyWidth = rootEl.offsetWidth;
+	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
+	ro.observe(rootEl);
+});
+
+onUnmounted(() => {
+	ro.disconnect();
+});
+
+defineExpose({
+	close,
+});
+</script>
+
+<style lang="scss" scoped>
+.ebkgoccj {
+	overflow: hidden;
+	display: flex;
+	flex-direction: column;
+	contain: content;
+	border-radius: var(--radius);
+
+	--root-margin: 24px;
+
+	@media (max-width: 500px) {
+		--root-margin: 16px;
+	}
+
+	> .header {
+		$height: 46px;
+		$height-narrow: 42px;
+		display: flex;
+		flex-shrink: 0;
+		background: var(--windowHeader);
+		-webkit-backdrop-filter: var(--blur, blur(15px));
+		backdrop-filter: var(--blur, blur(15px));
+
+		> button {
+			height: $height;
+			width: $height;
+
+			@media (max-width: 500px) {
+				height: $height-narrow;
+				width: $height-narrow;
+			}
+		}
+
+		> .title {
+			flex: 1;
+			line-height: $height;
+			padding-left: 32px;
+			font-weight: bold;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			pointer-events: none;
+
+			@media (max-width: 500px) {
+				line-height: $height-narrow;
+				padding-left: 16px;
+			}
+		}
+
+		> button + .title {
+			padding-left: 0;
+		}
+	}
+
+	> .body {
+		flex: 1;
+		overflow: auto;
+		background: var(--panel);
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
new file mode 100644
index 0000000000..a4100e1f2c
--- /dev/null
+++ b/packages/frontend/src/components/MkNote.vue
@@ -0,0 +1,658 @@
+<template>
+<div
+	v-if="!muted"
+	v-show="!isDeleted"
+	ref="el"
+	v-hotkey="keymap"
+	v-size="{ max: [500, 450, 350, 300] }"
+	class="tkcbzcuz"
+	:tabindex="!isDeleted ? '-1' : null"
+	:class="{ renote: isRenote }"
+>
+	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
+	<div v-if="pinned" class="info"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
+	<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>
+	<div v-if="appearNote._featuredId_" class="info"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>
+	<div v-if="isRenote" class="renote">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<i class="ti ti-repeat"></i>
+		<I18n :src="i18n.ts.renotedBy" tag="span">
+			<template #user>
+				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
+					<MkUserName :user="note.user"/>
+				</MkA>
+			</template>
+		</I18n>
+		<div class="info">
+			<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
+				<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
+				<MkTime :time="note.createdAt"/>
+			</button>
+			<MkVisibility :note="note"/>
+		</div>
+	</div>
+	<article class="article" @contextmenu.stop="onContextmenu">
+		<MkAvatar class="avatar" :user="appearNote.user"/>
+		<div class="main">
+			<XNoteHeader class="header" :note="appearNote" :mini="true"/>
+			<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+			<div class="body">
+				<p v-if="appearNote.cw != null" class="cw">
+					<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+					<XCwButton v-model="showContent" :note="appearNote"/>
+				</p>
+				<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
+					<div class="text">
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+						<a v-if="appearNote.renote != null" class="rp">RN:</a>
+						<div v-if="translating || translation" class="translation">
+							<MkLoading v-if="translating" mini/>
+							<div v-else class="translated">
+								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
+								<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+							</div>
+						</div>
+					</div>
+					<div v-if="appearNote.files.length > 0" class="files">
+						<XMediaList :media-list="appearNote.files"/>
+					</div>
+					<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
+					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
+					<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false">
+						<span>{{ i18n.ts.showMore }}</span>
+					</button>
+					<button v-else-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true">
+						<span>{{ i18n.ts.showLess }}</span>
+					</button>
+				</div>
+				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
+			</div>
+			<footer class="footer">
+				<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
+				<button class="button _button" @click="reply()">
+					<i class="ti ti-arrow-back-up"></i>
+					<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
+				</button>
+				<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
+				<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
+					<i class="ti ti-plus"></i>
+				</button>
+				<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
+					<i class="ti ti-minus"></i>
+				</button>
+				<button ref="menuButton" class="button _button" @click="menu()">
+					<i class="ti ti-dots"></i>
+				</button>
+			</footer>
+		</div>
+	</article>
+</div>
+<div v-else class="muted" @click="muted = false">
+	<I18n :src="i18n.ts.userSaysSomething" tag="small">
+		<template #name>
+			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
+				<MkUserName :user="appearNote.user"/>
+			</MkA>
+		</template>
+	</I18n>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue';
+import * as mfm from 'mfm-js';
+import * as misskey from 'misskey-js';
+import MkNoteSub from '@/components/MkNoteSub.vue';
+import XNoteHeader from '@/components/MkNoteHeader.vue';
+import XNoteSimple from '@/components/MkNoteSimple.vue';
+import XReactionsViewer from '@/components/MkReactionsViewer.vue';
+import XMediaList from '@/components/MkMediaList.vue';
+import XCwButton from '@/components/MkCwButton.vue';
+import XPoll from '@/components/MkPoll.vue';
+import XRenoteButton from '@/components/MkRenoteButton.vue';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
+import MkVisibility from '@/components/MkVisibility.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { defaultStore, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
+import { deepClone } from '@/scripts/clone';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+let note = $ref(deepClone(props.note));
+
+// plugin
+if (noteViewInterruptors.length > 0) {
+	onMounted(async () => {
+		let result = deepClone(note);
+		for (const interruptor of noteViewInterruptors) {
+			result = await interruptor.handler(result);
+		}
+		note = result;
+	});
+}
+
+const isRenote = (
+	note.renote != null &&
+	note.text == null &&
+	note.fileIds.length === 0 &&
+	note.poll == null
+);
+
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
+const isMyRenote = $i && ($i.id === note.userId);
+const showContent = ref(false);
+const isLong = (appearNote.cw == null && appearNote.text != null && (
+	(appearNote.text.split('\n').length > 9) ||
+	(appearNote.text.length > 500)
+));
+const collapsed = ref(appearNote.cw == null && isLong);
+const isDeleted = ref(false);
+const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+
+const keymap = {
+	'r': () => reply(true),
+	'e|a|plus': () => react(true),
+	'q': () => renoteButton.value.renote(true),
+	'up|k|shift+tab': focusBefore,
+	'down|j|tab': focusAfter,
+	'esc': blur,
+	'm|o': () => menu(true),
+	's': () => showContent.value !== showContent.value,
+};
+
+useNoteCapture({
+	rootEl: el,
+	note: $$(appearNote),
+	isDeletedRef: isDeleted,
+});
+
+function reply(viaKeyboard = false): void {
+	pleaseLogin();
+	os.post({
+		reply: appearNote,
+		animation: !viaKeyboard,
+	}, () => {
+		focus();
+	});
+}
+
+function react(viaKeyboard = false): void {
+	pleaseLogin();
+	blur();
+	reactionPicker.show(reactButton.value, reaction => {
+		os.api('notes/reactions/create', {
+			noteId: appearNote.id,
+			reaction: reaction,
+		});
+	}, () => {
+		focus();
+	});
+}
+
+function undoReact(note): void {
+	const oldReaction = note.myReaction;
+	if (!oldReaction) return;
+	os.api('notes/reactions/delete', {
+		noteId: note.id,
+	});
+}
+
+const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
+
+function onContextmenu(ev: MouseEvent): void {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target)) return;
+	if (window.getSelection().toString() !== '') return;
+
+	if (defaultStore.state.useReactionPickerForContextMenu) {
+		ev.preventDefault();
+		react();
+	} else {
+		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus);
+	}
+}
+
+function menu(viaKeyboard = false): void {
+	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
+		viaKeyboard,
+	}).then(focus);
+}
+
+function showRenoteMenu(viaKeyboard = false): void {
+	if (!isMyRenote) return;
+	os.popupMenu([{
+		text: i18n.ts.unrenote,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: () => {
+			os.api('notes/delete', {
+				noteId: note.id,
+			});
+			isDeleted.value = true;
+		},
+	}], renoteTime.value, {
+		viaKeyboard: viaKeyboard,
+	});
+}
+
+function focus() {
+	el.value.focus();
+}
+
+function blur() {
+	el.value.blur();
+}
+
+function focusBefore() {
+	focusPrev(el.value);
+}
+
+function focusAfter() {
+	focusNext(el.value);
+}
+
+function readPromo() {
+	os.api('promo/read', {
+		noteId: appearNote.id,
+	});
+	isDeleted.value = true;
+}
+</script>
+
+<style lang="scss" scoped>
+.tkcbzcuz {
+	position: relative;
+	transition: box-shadow 0.1s ease;
+	font-size: 1.05em;
+	overflow: clip;
+	contain: content;
+
+	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか？)
+	//content-visibility: auto;
+  //contain-intrinsic-size: 0 128px;
+
+	&:focus-visible {
+		outline: none;
+
+		&:after {
+			content: "";
+			pointer-events: none;
+			display: block;
+			position: absolute;
+			z-index: 10;
+			top: 0;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			margin: auto;
+			width: calc(100% - 8px);
+			height: calc(100% - 8px);
+			border: dashed 1px var(--focus);
+			border-radius: var(--radius);
+			box-sizing: border-box;
+		}
+	}
+
+	&:hover > .article > .main > .footer > .button {
+		opacity: 1;
+	}
+
+	> .info {
+		display: flex;
+		align-items: center;
+		padding: 16px 32px 8px 32px;
+		line-height: 24px;
+		font-size: 90%;
+		white-space: pre;
+		color: #d28a3f;
+
+		> i {
+			margin-right: 4px;
+		}
+
+		> .hide {
+			margin-left: auto;
+			color: inherit;
+		}
+	}
+
+	> .info + .article {
+		padding-top: 8px;
+	}
+
+	> .reply-to {
+		opacity: 0.7;
+		padding-bottom: 0;
+	}
+
+	> .renote {
+		display: flex;
+		align-items: center;
+		padding: 16px 32px 8px 32px;
+		line-height: 28px;
+		white-space: pre;
+		color: var(--renote);
+
+		> .avatar {
+			flex-shrink: 0;
+			display: inline-block;
+			width: 28px;
+			height: 28px;
+			margin: 0 8px 0 0;
+			border-radius: 6px;
+		}
+
+		> i {
+			margin-right: 4px;
+		}
+
+		> span {
+			overflow: hidden;
+			flex-shrink: 1;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+
+			> .name {
+				font-weight: bold;
+			}
+		}
+
+		> .info {
+			margin-left: auto;
+			font-size: 0.9em;
+
+			> .time {
+				flex-shrink: 0;
+				color: inherit;
+
+				> .dropdownIcon {
+					margin-right: 4px;
+				}
+			}
+		}
+	}
+
+	> .renote + .article {
+		padding-top: 8px;
+	}
+
+	> .article {
+		display: flex;
+		padding: 28px 32px 18px;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: block;
+			margin: 0 14px 8px 0;
+			width: 58px;
+			height: 58px;
+			position: sticky;
+			top: calc(22px + var(--stickyTop, 0px));
+			left: 0;
+		}
+
+		> .main {
+			flex: 1;
+			min-width: 0;
+
+			> .body {
+				container-type: inline-size;
+
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					&.isLong {
+						> .showLess {
+							width: 100%;
+							margin-top: 1em;
+							position: sticky;
+							bottom: 1em;
+
+							> span {
+								display: inline-block;
+								background: var(--popup);
+								padding: 6px 10px;
+								font-size: 0.8em;
+								border-radius: 999px;
+								box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+							}
+						}
+					}
+
+					&.collapsed {
+						position: relative;
+						max-height: 9em;
+						overflow: hidden;
+
+						> .fade {
+							display: block;
+							position: absolute;
+							bottom: 0;
+							left: 0;
+							width: 100%;
+							height: 64px;
+							background: linear-gradient(0deg, var(--panel), var(--X15));
+
+							> span {
+								display: inline-block;
+								background: var(--panel);
+								padding: 6px 10px;
+								font-size: 0.8em;
+								border-radius: 999px;
+								box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+							}
+
+							&:hover {
+								> span {
+									background: var(--panelHighlight);
+								}
+							}
+						}
+					}
+
+					> .text {
+						overflow-wrap: break-word;
+
+						> .reply {
+							color: var(--accent);
+							margin-right: 0.5em;
+						}
+
+						> .rp {
+							margin-left: 4px;
+							font-style: oblique;
+							color: var(--renote);
+						}
+
+						> .translation {
+							border: solid 0.5px var(--divider);
+							border-radius: var(--radius);
+							padding: 12px;
+							margin-top: 8px;
+						}
+					}
+
+					> .url-preview {
+						margin-top: 8px;
+					}
+
+					> .poll {
+						font-size: 80%;
+					}
+
+					> .renote {
+						padding: 8px 0;
+
+						> * {
+							padding: 16px;
+							border: dashed 1px var(--renote);
+							border-radius: 8px;
+						}
+					}
+				}
+
+				> .channel {
+					opacity: 0.7;
+					font-size: 80%;
+				}
+			}
+
+			> .footer {
+				> .button {
+					margin: 0;
+					padding: 8px;
+					opacity: 0.7;
+
+					&:not(:last-child) {
+						margin-right: 28px;
+					}
+
+					&:hover {
+						color: var(--fgHighlighted);
+					}
+
+					> .count {
+						display: inline;
+						margin: 0 0 0 8px;
+						opacity: 0.7;
+					}
+
+					&.reacted {
+						color: var(--accent);
+					}
+				}
+			}
+		}
+	}
+
+	> .reply {
+		border-top: solid 0.5px var(--divider);
+	}
+}
+
+@container (max-width: 500px) {
+	.tkcbzcuz {
+		font-size: 0.9em;
+
+		> .article {
+			> .avatar {
+				width: 50px;
+				height: 50px;
+			}
+		}
+	}
+}
+
+@container (max-width: 450px) {
+	.tkcbzcuz {
+		> .renote {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .info {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .article {
+			padding: 14px 16px 9px;
+
+			> .avatar {
+				margin: 0 10px 8px 0;
+				width: 46px;
+				height: 46px;
+				top: calc(14px + var(--stickyTop, 0px));
+			}
+		}
+	}
+}
+
+@container (max-width: 350px) {
+	.tkcbzcuz {
+		> .article {
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 18px;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 300px) {
+	.tkcbzcuz {
+		> .article {
+			> .avatar {
+				width: 44px;
+				height: 44px;
+			}
+
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 12px;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+.muted {
+	padding: 8px;
+	text-align: center;
+	opacity: 0.7;
+}
+</style>
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
new file mode 100644
index 0000000000..7ce8e039d9
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -0,0 +1,677 @@
+<template>
+<div
+	v-if="!muted"
+	v-show="!isDeleted"
+	ref="el"
+	v-hotkey="keymap"
+	v-size="{ max: [500, 450, 350, 300] }"
+	class="lxwezrsl _block"
+	:tabindex="!isDeleted ? '-1' : null"
+	:class="{ renote: isRenote }"
+>
+	<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
+	<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
+	<div v-if="isRenote" class="renote">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<i class="ti ti-repeat"></i>
+		<I18n :src="i18n.ts.renotedBy" tag="span">
+			<template #user>
+				<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
+					<MkUserName :user="note.user"/>
+				</MkA>
+			</template>
+		</I18n>
+		<div class="info">
+			<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
+				<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
+				<MkTime :time="note.createdAt"/>
+			</button>
+			<MkVisibility :note="note"/>
+		</div>
+	</div>
+	<article class="article" @contextmenu.stop="onContextmenu">
+		<header class="header">
+			<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
+			<div class="body">
+				<div class="top">
+					<MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)">
+						<MkUserName :nowrap="false" :user="appearNote.user"/>
+					</MkA>
+					<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
+					<div class="info">
+						<MkVisibility :note="appearNote"/>
+					</div>
+				</div>
+				<div class="username"><MkAcct :user="appearNote.user"/></div>
+				<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+			</div>
+		</header>
+		<div class="main">
+			<div class="body">
+				<p v-if="appearNote.cw != null" class="cw">
+					<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+					<XCwButton v-model="showContent" :note="appearNote"/>
+				</p>
+				<div v-show="appearNote.cw == null || showContent" class="content">
+					<div class="text">
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+						<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+						<a v-if="appearNote.renote != null" class="rp">RN:</a>
+						<div v-if="translating || translation" class="translation">
+							<MkLoading v-if="translating" mini/>
+							<div v-else class="translated">
+								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b>
+								<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+							</div>
+						</div>
+					</div>
+					<div v-if="appearNote.files.length > 0" class="files">
+						<XMediaList :media-list="appearNote.files"/>
+					</div>
+					<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
+					<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>
+					<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
+				</div>
+				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
+			</div>
+			<footer class="footer">
+				<div class="info">
+					<MkA class="created-at" :to="notePage(appearNote)">
+						<MkTime :time="appearNote.createdAt" mode="detail"/>
+					</MkA>
+				</div>
+				<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
+				<button class="button _button" @click="reply()">
+					<i class="ti ti-arrow-back-up"></i>
+					<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
+				</button>
+				<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
+				<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
+					<i class="ti ti-plus"></i>
+				</button>
+				<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
+					<i class="ti ti-minus"></i>
+				</button>
+				<button ref="menuButton" class="button _button" @click="menu()">
+					<i class="ti ti-dots"></i>
+				</button>
+			</footer>
+		</div>
+	</article>
+	<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
+</div>
+<div v-else class="_panel muted" @click="muted = false">
+	<I18n :src="i18n.ts.userSaysSomething" tag="small">
+		<template #name>
+			<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
+				<MkUserName :user="appearNote.user"/>
+			</MkA>
+		</template>
+	</I18n>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
+import * as mfm from 'mfm-js';
+import * as misskey from 'misskey-js';
+import MkNoteSub from '@/components/MkNoteSub.vue';
+import XNoteSimple from '@/components/MkNoteSimple.vue';
+import XReactionsViewer from '@/components/MkReactionsViewer.vue';
+import XMediaList from '@/components/MkMediaList.vue';
+import XCwButton from '@/components/MkCwButton.vue';
+import XPoll from '@/components/MkPoll.vue';
+import XRenoteButton from '@/components/MkRenoteButton.vue';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
+import MkVisibility from '@/components/MkVisibility.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import { notePage } from '@/filters/note';
+import * as os from '@/os';
+import { defaultStore, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
+import { deepClone } from '@/scripts/clone';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+let note = $ref(deepClone(props.note));
+
+// plugin
+if (noteViewInterruptors.length > 0) {
+	onMounted(async () => {
+		let result = deepClone(note);
+		for (const interruptor of noteViewInterruptors) {
+			result = await interruptor.handler(result);
+		}
+		note = result;
+	});
+}
+
+const isRenote = (
+	note.renote != null &&
+	note.text == null &&
+	note.fileIds.length === 0 &&
+	note.poll == null
+);
+
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
+const isMyRenote = $i && ($i.id === note.userId);
+const showContent = ref(false);
+const isDeleted = ref(false);
+const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+const conversation = ref<misskey.entities.Note[]>([]);
+const replies = ref<misskey.entities.Note[]>([]);
+
+const keymap = {
+	'r': () => reply(true),
+	'e|a|plus': () => react(true),
+	'q': () => renoteButton.value.renote(true),
+	'esc': blur,
+	'm|o': () => menu(true),
+	's': () => showContent.value !== showContent.value,
+};
+
+useNoteCapture({
+	rootEl: el,
+	note: $$(appearNote),
+	isDeletedRef: isDeleted,
+});
+
+function reply(viaKeyboard = false): void {
+	pleaseLogin();
+	os.post({
+		reply: appearNote,
+		animation: !viaKeyboard,
+	}, () => {
+		focus();
+	});
+}
+
+function react(viaKeyboard = false): void {
+	pleaseLogin();
+	blur();
+	reactionPicker.show(reactButton.value, reaction => {
+		os.api('notes/reactions/create', {
+			noteId: appearNote.id,
+			reaction: reaction,
+		});
+	}, () => {
+		focus();
+	});
+}
+
+function undoReact(note): void {
+	const oldReaction = note.myReaction;
+	if (!oldReaction) return;
+	os.api('notes/reactions/delete', {
+		noteId: note.id,
+	});
+}
+
+function onContextmenu(ev: MouseEvent): void {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target)) return;
+	if (window.getSelection().toString() !== '') return;
+
+	if (defaultStore.state.useReactionPickerForContextMenu) {
+		ev.preventDefault();
+		react();
+	} else {
+		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus);
+	}
+}
+
+function menu(viaKeyboard = false): void {
+	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, {
+		viaKeyboard,
+	}).then(focus);
+}
+
+function showRenoteMenu(viaKeyboard = false): void {
+	if (!isMyRenote) return;
+	os.popupMenu([{
+		text: i18n.ts.unrenote,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: () => {
+			os.api('notes/delete', {
+				noteId: note.id,
+			});
+			isDeleted.value = true;
+		},
+	}], renoteTime.value, {
+		viaKeyboard: viaKeyboard,
+	});
+}
+
+function focus() {
+	el.value.focus();
+}
+
+function blur() {
+	el.value.blur();
+}
+
+os.api('notes/children', {
+	noteId: appearNote.id,
+	limit: 30,
+}).then(res => {
+	replies.value = res;
+});
+
+if (appearNote.replyId) {
+	os.api('notes/conversation', {
+		noteId: appearNote.replyId,
+	}).then(res => {
+		conversation.value = res.reverse();
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.lxwezrsl {
+	position: relative;
+	transition: box-shadow 0.1s ease;
+	overflow: hidden;
+	contain: content;
+
+	&:focus-visible {
+		outline: none;
+
+		&:after {
+			content: "";
+			pointer-events: none;
+			display: block;
+			position: absolute;
+			z-index: 10;
+			top: 0;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			margin: auto;
+			width: calc(100% - 8px);
+			height: calc(100% - 8px);
+			border: dashed 1px var(--focus);
+			border-radius: var(--radius);
+			box-sizing: border-box;
+		}
+	}
+
+	&:hover > .article > .main > .footer > .button {
+		opacity: 1;
+	}
+
+	> .reply-to {
+		opacity: 0.7;
+		padding-bottom: 0;
+	}
+
+	> .reply-to-more {
+		opacity: 0.7;
+	}
+
+	> .renote {
+		display: flex;
+		align-items: center;
+		padding: 16px 32px 8px 32px;
+		line-height: 28px;
+		white-space: pre;
+		color: var(--renote);
+
+		> .avatar {
+			flex-shrink: 0;
+			display: inline-block;
+			width: 28px;
+			height: 28px;
+			margin: 0 8px 0 0;
+			border-radius: 6px;
+		}
+
+		> i {
+			margin-right: 4px;
+		}
+
+		> span {
+			overflow: hidden;
+			flex-shrink: 1;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+
+			> .name {
+				font-weight: bold;
+			}
+		}
+
+		> .info {
+			margin-left: auto;
+			font-size: 0.9em;
+
+			> .time {
+				flex-shrink: 0;
+				color: inherit;
+
+				> .dropdownIcon {
+					margin-right: 4px;
+				}
+			}
+		}
+	}
+
+	> .renote + .article {
+		padding-top: 8px;
+	}
+
+	> .article {
+		padding: 32px;
+		font-size: 1.2em;
+
+		> .header {
+			display: flex;
+			position: relative;
+			margin-bottom: 16px;
+			align-items: center;
+
+			> .avatar {
+				display: block;
+				flex-shrink: 0;
+				width: 58px;
+				height: 58px;
+			}
+
+			> .body {
+				flex: 1;
+				display: flex;
+				flex-direction: column;
+				justify-content: center;
+				padding-left: 16px;
+				font-size: 0.95em;
+
+				> .top {
+					> .name {
+						font-weight: bold;
+						line-height: 1.3;
+					}
+
+					> .is-bot {
+						display: inline-block;
+						margin: 0 0.5em;
+						padding: 4px 6px;
+						font-size: 80%;
+						line-height: 1;
+						border: solid 0.5px var(--divider);
+						border-radius: 4px;
+					}
+
+					> .info {
+						float: right;
+					}
+				}
+
+				> .username {
+					margin-bottom: 2px;
+					line-height: 1.3;
+					word-wrap: anywhere;
+				}
+			}
+		}
+
+		> .main {
+			> .body {
+				container-type: inline-size;
+
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					> .text {
+						overflow-wrap: break-word;
+
+						> .reply {
+							color: var(--accent);
+							margin-right: 0.5em;
+						}
+
+						> .rp {
+							margin-left: 4px;
+							font-style: oblique;
+							color: var(--renote);
+						}
+
+						> .translation {
+							border: solid 0.5px var(--divider);
+							border-radius: var(--radius);
+							padding: 12px;
+							margin-top: 8px;
+						}
+					}
+
+					> .url-preview {
+						margin-top: 8px;
+					}
+
+					> .poll {
+						font-size: 80%;
+					}
+
+					> .renote {
+						padding: 8px 0;
+
+						> * {
+							padding: 16px;
+							border: dashed 1px var(--renote);
+							border-radius: 8px;
+						}
+					}
+				}
+
+				> .channel {
+					opacity: 0.7;
+					font-size: 80%;
+				}
+			}
+
+			> .footer {
+				> .info {
+					margin: 16px 0;
+					opacity: 0.7;
+					font-size: 0.9em;
+				}
+
+				> .button {
+					margin: 0;
+					padding: 8px;
+					opacity: 0.7;
+
+					&:not(:last-child) {
+						margin-right: 28px;
+					}
+
+					&:hover {
+						color: var(--fgHighlighted);
+					}
+
+					> .count {
+						display: inline;
+						margin: 0 0 0 8px;
+						opacity: 0.7;
+					}
+
+					&.reacted {
+						color: var(--accent);
+					}
+				}
+			}
+		}
+	}
+
+	> .reply {
+		border-top: solid 0.5px var(--divider);
+	}
+
+	&.max-width_500px {
+		font-size: 0.9em;
+	}
+
+	&.max-width_450px {
+		> .renote {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .article {
+			padding: 16px;
+
+			> .header {
+				> .avatar {
+					width: 50px;
+					height: 50px;
+				}
+			}
+		}
+	}
+
+	&.max-width_350px {
+		> .article {
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 18px;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	&.max-width_300px {
+		font-size: 0.825em;
+
+		> .article {
+			> .header {
+				> .avatar {
+					width: 50px;
+					height: 50px;
+				}
+			}
+
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 12px;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 500px) {
+	.lxwezrsl {
+		font-size: 0.9em;
+	}
+}
+
+@container (max-width: 450px) {
+	.lxwezrsl {
+		> .renote {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .article {
+			padding: 16px;
+
+			> .header {
+				> .avatar {
+					width: 50px;
+					height: 50px;
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 350px) {
+	.lxwezrsl {
+		> .article {
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 18px;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 300px) {
+	.lxwezrsl {
+		font-size: 0.825em;
+
+		> .article {
+			> .header {
+				> .avatar {
+					width: 50px;
+					height: 50px;
+				}
+			}
+
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 12px;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+.muted {
+	padding: 8px;
+	text-align: center;
+	opacity: 0.7;
+}
+</style>
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
new file mode 100644
index 0000000000..333c3ddbd9
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -0,0 +1,75 @@
+<template>
+<header class="kkwtjztg">
+	<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
+		<MkUserName :user="note.user"/>
+	</MkA>
+	<div v-if="note.user.isBot" class="is-bot">bot</div>
+	<div class="username"><MkAcct :user="note.user"/></div>
+	<div class="info">
+		<MkA class="created-at" :to="notePage(note)">
+			<MkTime :time="note.createdAt"/>
+		</MkA>
+		<MkVisibility :note="note"/>
+	</div>
+</header>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import MkVisibility from '@/components/MkVisibility.vue';
+import { notePage } from '@/filters/note';
+import { userPage } from '@/filters/user';
+
+defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.kkwtjztg {
+	display: flex;
+	align-items: baseline;
+	white-space: nowrap;
+
+	> .name {
+		flex-shrink: 1;
+		display: block;
+		margin: 0 .5em 0 0;
+		padding: 0;
+		overflow: hidden;
+		font-size: 1em;
+		font-weight: bold;
+		text-decoration: none;
+		text-overflow: ellipsis;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	> .is-bot {
+		flex-shrink: 0;
+		align-self: center;
+		margin: 0 .5em 0 0;
+		padding: 1px 6px;
+		font-size: 80%;
+		border: solid 0.5px var(--divider);
+		border-radius: 3px;
+	}
+
+	> .username {
+		flex-shrink: 9999999;
+		margin: 0 .5em 0 0;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	> .info {
+		flex-shrink: 0;
+		margin-left: auto;
+		font-size: 0.9em;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue
new file mode 100644
index 0000000000..0c81059091
--- /dev/null
+++ b/packages/frontend/src/components/MkNotePreview.vue
@@ -0,0 +1,112 @@
+<template>
+<div v-size="{ min: [350, 500] }" class="fefdfafb">
+	<MkAvatar class="avatar" :user="$i"/>
+	<div class="main">
+		<div class="header">
+			<MkUserName :user="$i"/>
+		</div>
+		<div class="body">
+			<div class="content">
+				<Mfm :text="text.trim()" :author="$i" :i="$i"/>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	text: string;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.fefdfafb {
+	display: flex;
+	margin: 0;
+	padding: 0;
+	overflow: clip;
+	font-size: 0.95em;
+
+	&.min-width_350px {
+		> .avatar {
+			margin: 0 10px 0 0;
+			width: 44px;
+			height: 44px;
+		}
+	}
+
+	&.min-width_500px {
+		> .avatar {
+			margin: 0 12px 0 0;
+			width: 48px;
+			height: 48px;
+		}
+	}
+
+	> .avatar {
+		flex-shrink: 0;
+		display: block;
+		margin: 0 10px 0 0;
+		width: 40px;
+		height: 40px;
+		border-radius: 8px;
+		pointer-events: none;
+	}
+
+	> .main {
+		flex: 1;
+		min-width: 0;
+
+		> .header {
+			margin-bottom: 2px;
+			font-weight: bold;
+		}
+
+		> .body {
+
+			> .cw {
+				cursor: default;
+				display: block;
+				margin: 0;
+				padding: 0;
+				overflow-wrap: break-word;
+
+				> .text {
+					margin-right: 8px;
+				}
+			}
+
+			> .content {
+				> .text {
+					cursor: default;
+					margin: 0;
+					padding: 0;
+				}
+			}
+		}
+	}
+}
+
+@container (min-width: 350px) {
+	.fefdfafb {
+		> .avatar {
+			margin: 0 10px 0 0;
+			width: 44px;
+			height: 44px;
+		}
+	}
+}
+
+@container (min-width: 500px) {
+	.fefdfafb {
+		> .avatar {
+			margin: 0 12px 0 0;
+			width: 48px;
+			height: 48px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
new file mode 100644
index 0000000000..96d29831d2
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -0,0 +1,119 @@
+<template>
+<div v-size="{ min: [350, 500] }" class="yohlumlk">
+	<MkAvatar class="avatar" :user="note.user"/>
+	<div class="main">
+		<XNoteHeader class="header" :note="note" :mini="true"/>
+		<div class="body">
+			<p v-if="note.cw != null" class="cw">
+				<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+				<XCwButton v-model="showContent" :note="note"/>
+			</p>
+			<div v-show="note.cw == null || showContent" class="content">
+				<MkSubNoteContent class="text" :note="note"/>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import XNoteHeader from '@/components/MkNoteHeader.vue';
+import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
+import XCwButton from '@/components/MkCwButton.vue';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	pinned?: boolean;
+}>();
+
+const showContent = $ref(false);
+</script>
+
+<style lang="scss" scoped>
+.yohlumlk {
+	display: flex;
+	margin: 0;
+	padding: 0;
+	overflow: clip;
+	font-size: 0.95em;
+
+	&.min-width_350px {
+		> .avatar {
+			margin: 0 10px 0 0;
+			width: 44px;
+			height: 44px;
+		}
+	}
+
+	&.min-width_500px {
+		> .avatar {
+			margin: 0 12px 0 0;
+			width: 48px;
+			height: 48px;
+		}
+	}
+
+	> .avatar {
+		flex-shrink: 0;
+		display: block;
+		margin: 0 10px 0 0;
+		width: 40px;
+		height: 40px;
+		border-radius: 8px;
+	}
+
+	> .main {
+		flex: 1;
+		min-width: 0;
+
+		> .header {
+			margin-bottom: 2px;
+		}
+
+		> .body {
+
+			> .cw {
+				cursor: default;
+				display: block;
+				margin: 0;
+				padding: 0;
+				overflow-wrap: break-word;
+
+				> .text {
+					margin-right: 8px;
+				}
+			}
+
+			> .content {
+				> .text {
+					cursor: default;
+					margin: 0;
+					padding: 0;
+				}
+			}
+		}
+	}
+}
+
+@container (min-width: 350px) {
+	.yohlumlk {
+		> .avatar {
+			margin: 0 10px 0 0;
+			width: 44px;
+			height: 44px;
+		}
+	}
+}
+
+@container (min-width: 500px) {
+	.yohlumlk {
+		> .avatar {
+			margin: 0 12px 0 0;
+			width: 48px;
+			height: 48px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
new file mode 100644
index 0000000000..d03ce7c434
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -0,0 +1,140 @@
+<template>
+<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
+	<div class="main">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<div class="body">
+			<XNoteHeader class="header" :note="note" :mini="true"/>
+			<div class="body">
+				<p v-if="note.cw != null" class="cw">
+					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+					<XCwButton v-model="showContent" :note="note"/>
+				</p>
+				<div v-show="note.cw == null || showContent" class="content">
+					<MkSubNoteContent class="text" :note="note"/>
+				</div>
+			</div>
+		</div>
+	</div>
+	<template v-if="depth < 5">
+		<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
+	</template>
+	<div v-else class="more">
+		<MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import XNoteHeader from '@/components/MkNoteHeader.vue';
+import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
+import XCwButton from '@/components/MkCwButton.vue';
+import { notePage } from '@/filters/note';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	note: misskey.entities.Note;
+	detail?: boolean;
+
+	// how many notes are in between this one and the note being viewed in detail
+	depth?: number;
+}>(), {
+	depth: 1,
+});
+
+let showContent = $ref(false);
+let replies: misskey.entities.Note[] = $ref([]);
+
+if (props.detail) {
+	os.api('notes/children', {
+		noteId: props.note.id,
+		limit: 5,
+	}).then(res => {
+		replies = res;
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+	padding: 16px 32px;
+	font-size: 0.9em;
+
+	&.max-width_450px {
+		padding: 14px 16px;
+	}
+
+	&.children {
+		padding: 10px 0 0 16px;
+		font-size: 1em;
+
+		&.max-width_450px {
+			padding: 10px 0 0 8px;
+		}
+	}
+
+	> .main {
+		display: flex;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: block;
+			margin: 0 8px 0 0;
+			width: 38px;
+			height: 38px;
+			border-radius: 8px;
+		}
+
+		> .body {
+			flex: 1;
+			min-width: 0;
+
+			> .header {
+				margin-bottom: 2px;
+			}
+
+			> .body {
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					> .text {
+						margin: 0;
+						padding: 0;
+					}
+				}
+			}
+		}
+	}
+
+	> .reply, > .more {
+		border-left: solid 0.5px var(--divider);
+		margin-top: 10px;
+	}
+
+	> .more {
+		padding: 10px 0 0 16px;
+	}
+}
+
+@container (max-width: 450px) {
+	.wrpstxzv {
+		padding: 14px 16px;
+
+		&.children {
+			padding: 10px 0 0 8px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
new file mode 100644
index 0000000000..5abcdc2298
--- /dev/null
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -0,0 +1,58 @@
+<template>
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ i18n.ts.noNotes }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: notes }">
+		<div class="giivymft" :class="{ noGap }">
+			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
+				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
+			</XList>
+		</div>
+	</template>
+</MkPagination>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import XNote from '@/components/MkNote.vue';
+import XList from '@/components/MkDateSeparatedList.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+}>();
+
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
+
+defineExpose({
+	pagingComponent,
+});
+</script>
+
+<style lang="scss" scoped>
+.giivymft {
+	&.noGap {
+		> .notes {
+			background: var(--panel);
+		}
+	}
+
+	&:not(.noGap) {
+		> .notes {
+			background: var(--bg);
+
+			.qtqtichx {
+				background: var(--panel);
+				border-radius: var(--radius);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
new file mode 100644
index 0000000000..8b8d3f452d
--- /dev/null
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -0,0 +1,323 @@
+<template>
+<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type">
+	<div class="head">
+		<MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/>
+		<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
+		<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
+		<div class="sub-icon" :class="notification.type">
+			<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
+			<i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i>
+			<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i>
+			<i v-else-if="notification.type === 'groupInvited'" class="ti ti-certificate-2"></i>
+			<i v-else-if="notification.type === 'renote'" class="ti ti-repeat"></i>
+			<i v-else-if="notification.type === 'reply'" class="ti ti-arrow-back-up"></i>
+			<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
+			<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
+			<i v-else-if="notification.type === 'pollVote'" class="ti ti-chart-arrows"></i>
+			<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
+			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
+			<XReactionIcon
+				v-else-if="notification.type === 'reaction'"
+				ref="reactionRef"
+				:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
+				:custom-emojis="notification.note.emojis"
+				:no-style="true"
+			/>
+		</div>
+	</div>
+	<div class="tail">
+		<header>
+			<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
+			<MkA v-else-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
+			<span v-else>{{ notification.header }}</span>
+			<MkTime v-if="withTime" :time="notification.createdAt" class="time"/>
+		</header>
+		<MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<i class="ti ti-quote"></i>
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+			<i class="ti ti-quote"></i>
+		</MkA>
+		<MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
+			<i class="ti ti-quote"></i>
+			<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/>
+			<i class="ti ti-quote"></i>
+		</MkA>
+		<MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+		</MkA>
+		<MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+		</MkA>
+		<MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+		</MkA>
+		<MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<i class="ti ti-quote"></i>
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+			<i class="ti ti-quote"></i>
+		</MkA>
+		<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+			<i class="ti ti-quote"></i>
+			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+			<i class="ti ti-quote"></i>
+		</MkA>
+		<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
+		<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
+		<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
+		<span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ i18n.ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button></div></span>
+		<span v-if="notification.type === 'app'" class="text">
+			<Mfm :text="notification.body" :nowrap="!full"/>
+		</span>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import XReactionIcon from '@/components/MkReactionIcon.vue';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import XReactionTooltip from '@/components/MkReactionTooltip.vue';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { notePage } from '@/filters/note';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { useTooltip } from '@/scripts/use-tooltip';
+
+const props = withDefaults(defineProps<{
+	notification: misskey.entities.Notification;
+	withTime?: boolean;
+	full?: boolean;
+}>(), {
+	withTime: false,
+	full: false,
+});
+
+const elRef = ref<HTMLElement>(null);
+const reactionRef = ref(null);
+
+let readObserver: IntersectionObserver | undefined;
+let connection;
+
+onMounted(() => {
+	if (!props.notification.isRead) {
+		readObserver = new IntersectionObserver((entries, observer) => {
+			if (!entries.some(entry => entry.isIntersecting)) return;
+			stream.send('readNotification', {
+				id: props.notification.id,
+			});
+			observer.disconnect();
+		});
+
+		readObserver.observe(elRef.value);
+
+		connection = stream.useChannel('main');
+		connection.on('readAllNotifications', () => readObserver.disconnect());
+
+		watch(props.notification.isRead, () => {
+			readObserver.disconnect();
+		});
+	}
+});
+
+onUnmounted(() => {
+	if (readObserver) readObserver.disconnect();
+	if (connection) connection.dispose();
+});
+
+const followRequestDone = ref(false);
+const groupInviteDone = ref(false);
+
+const acceptFollowRequest = () => {
+	followRequestDone.value = true;
+	os.api('following/requests/accept', { userId: props.notification.user.id });
+};
+
+const rejectFollowRequest = () => {
+	followRequestDone.value = true;
+	os.api('following/requests/reject', { userId: props.notification.user.id });
+};
+
+const acceptGroupInvitation = () => {
+	groupInviteDone.value = true;
+	os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id });
+};
+
+const rejectGroupInvitation = () => {
+	groupInviteDone.value = true;
+	os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
+};
+
+useTooltip(reactionRef, (showing) => {
+	os.popup(XReactionTooltip, {
+		showing,
+		reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
+		emojis: props.notification.note.emojis,
+		targetElement: reactionRef.value.$el,
+	}, {}, 'closed');
+});
+</script>
+
+<style lang="scss" scoped>
+.qglefbjs {
+	position: relative;
+	box-sizing: border-box;
+	padding: 24px 32px;
+	font-size: 0.9em;
+	overflow-wrap: break-word;
+	display: flex;
+	contain: content;
+
+	&.max-width_600px {
+		padding: 16px;
+		font-size: 0.9em;
+	}
+
+	&.max-width_500px {
+		padding: 12px;
+		font-size: 0.85em;
+	}
+
+	> .head {
+		position: sticky;
+		top: 0;
+		flex-shrink: 0;
+		width: 42px;
+		height: 42px;
+		margin-right: 8px;
+
+		> .icon {
+			display: block;
+			width: 100%;
+			height: 100%;
+			border-radius: 6px;
+		}
+
+		> .sub-icon {
+			position: absolute;
+			z-index: 1;
+			bottom: -2px;
+			right: -2px;
+			width: 20px;
+			height: 20px;
+			box-sizing: border-box;
+			border-radius: 100%;
+			background: var(--panel);
+			box-shadow: 0 0 0 3px var(--panel);
+			font-size: 12px;
+			text-align: center;
+
+			&:empty {
+				display: none;
+			}
+
+			> * {
+				color: #fff;
+				width: 100%;
+				height: 100%;
+			}
+
+			&.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited {
+				padding: 3px;
+				background: #36aed2;
+				pointer-events: none;
+			}
+
+			&.renote {
+				padding: 3px;
+				background: #36d298;
+				pointer-events: none;
+			}
+
+			&.quote {
+				padding: 3px;
+				background: #36d298;
+				pointer-events: none;
+			}
+
+			&.reply {
+				padding: 3px;
+				background: #007aff;
+				pointer-events: none;
+			}
+
+			&.mention {
+				padding: 3px;
+				background: #88a6b7;
+				pointer-events: none;
+			}
+
+			&.pollVote {
+				padding: 3px;
+				background: #88a6b7;
+				pointer-events: none;
+			}
+
+			&.pollEnded {
+				padding: 3px;
+				background: #88a6b7;
+				pointer-events: none;
+			}
+		}
+	}
+
+	> .tail {
+		flex: 1;
+		min-width: 0;
+
+		> header {
+			display: flex;
+			align-items: baseline;
+			white-space: nowrap;
+
+			> .name {
+				text-overflow: ellipsis;
+				white-space: nowrap;
+				min-width: 0;
+				overflow: hidden;
+			}
+
+			> .time {
+				margin-left: auto;
+				font-size: 0.9em;
+			}
+		}
+
+		> .text {
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+
+			> i {
+				vertical-align: super;
+				font-size: 50%;
+				opacity: 0.5;
+			}
+
+			> i:first-child {
+				margin-right: 4px;
+			}
+
+			> i:last-child {
+				margin-left: 4px;
+			}
+		}
+	}
+}
+
+@container (max-width: 600px) {
+	.qglefbjs {
+		padding: 16px;
+		font-size: 0.9em;
+	}
+}
+
+@container (max-width: 500px) {
+	.qglefbjs {
+		padding: 12px;
+		font-size: 0.85em;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue
new file mode 100644
index 0000000000..75bea2976c
--- /dev/null
+++ b/packages/frontend/src/components/MkNotificationSettingWindow.vue
@@ -0,0 +1,87 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="400"
+	:height="450"
+	:with-ok-button="true"
+	:ok-button-disabled="false"
+	@ok="ok()"
+	@close="dialog.close()"
+	@closed="emit('closed')"
+>
+	<template #header>{{ i18n.ts.notificationSetting }}</template>
+	<div class="_monolithic_">
+		<div v-if="showGlobalToggle" class="_section">
+			<MkSwitch v-model="useGlobalSetting">
+				{{ i18n.ts.useGlobalSetting }}
+				<template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template>
+			</MkSwitch>
+		</div>
+		<div v-if="!useGlobalSetting" class="_section">
+			<MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo>
+			<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
+			<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
+			<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
+		</div>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import MkSwitch from './form/switch.vue';
+import MkInfo from './MkInfo.vue';
+import MkButton from './MkButton.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n';
+
+const emit = defineEmits<{
+	(ev: 'done', v: { includingTypes: string[] | null }): void,
+	(ev: 'closed'): void,
+}>();
+
+const props = withDefaults(defineProps<{
+	includingTypes?: typeof notificationTypes[number][] | null;
+	showGlobalToggle?: boolean;
+}>(), {
+	includingTypes: () => [],
+	showGlobalToggle: true,
+});
+
+let includingTypes = $computed(() => props.includingTypes || []);
+
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
+
+let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({});
+let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle);
+
+for (const ntype of notificationTypes) {
+	typesMap[ntype] = includingTypes.includes(ntype);
+}
+
+function ok() {
+	if (useGlobalSetting) {
+		emit('done', { includingTypes: null });
+	} else {
+		emit('done', {
+			includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][])
+				.filter(type => typesMap[type]),
+		});
+	}
+
+	dialog.close();
+}
+
+function disableAll() {
+	for (const type in typesMap) {
+		typesMap[type as typeof notificationTypes[number]] = false;
+	}
+}
+
+function enableAll() {
+	for (const type in typesMap) {
+		typesMap[type as typeof notificationTypes[number]] = true;
+	}
+}
+</script>
diff --git a/packages/frontend/src/components/MkNotificationToast.vue b/packages/frontend/src/components/MkNotificationToast.vue
new file mode 100644
index 0000000000..07640792c0
--- /dev/null
+++ b/packages/frontend/src/components/MkNotificationToast.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="mk-notification-toast" :style="{ zIndex }">
+	<transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')">
+		<XNotification v-if="showing" :notification="notification" class="notification _acrylic"/>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XNotification from '@/components/MkNotification.vue';
+import * as os from '@/os';
+
+defineProps<{
+	notification: any; // TODO
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const zIndex = os.claimZIndex('high');
+let showing = $ref(true);
+
+onMounted(() => {
+	window.setTimeout(() => {
+		showing = false;
+	}, 6000);
+});
+</script>
+
+<style lang="scss" scoped>
+.notification-toast-enter-active, .notification-toast-leave-active {
+	transition: opacity 0.3s, transform 0.3s !important;
+}
+.notification-toast-enter-from, .notification-toast-leave-to {
+	opacity: 0;
+	transform: translateX(-250px);
+}
+
+.mk-notification-toast {
+	position: fixed;
+	left: 0;
+	width: 250px;
+	top: 32px;
+	padding: 0 32px;
+	pointer-events: none;
+	container-type: inline-size;
+
+	@media (max-width: 700px) {
+		top: initial;
+		bottom: 112px;
+		padding: 0 16px;
+	}
+
+	@media (max-width: 500px) {
+		bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
+		padding: 0 8px;
+	}
+
+	> .notification {
+		height: 100%;
+		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+		border-radius: 8px;
+		overflow: hidden;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
new file mode 100644
index 0000000000..0e1cc06743
--- /dev/null
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -0,0 +1,104 @@
+<template>
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ i18n.ts.noNotifications }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: notifications }">
+		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
+			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
+			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
+		</XList>
+	</template>
+</MkPagination>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import XNotification from '@/components/MkNotification.vue';
+import XList from '@/components/MkDateSeparatedList.vue';
+import XNote from '@/components/MkNote.vue';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	includeTypes?: typeof notificationTypes[number][];
+	unreadOnly?: boolean;
+}>();
+
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
+
+const pagination: Paging = {
+	endpoint: 'i/notifications' as const,
+	limit: 10,
+	params: computed(() => ({
+		includeTypes: props.includeTypes ?? undefined,
+		excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
+		unreadOnly: props.unreadOnly,
+	})),
+};
+
+const onNotification = (notification) => {
+	const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
+	if (isMuted || document.visibilityState === 'visible') {
+		stream.send('readNotification', {
+			id: notification.id,
+		});
+	}
+
+	if (!isMuted) {
+		pagingComponent.value.prepend({
+			...notification,
+			isRead: document.visibilityState === 'visible',
+		});
+	}
+};
+
+let connection;
+
+onMounted(() => {
+	connection = stream.useChannel('main');
+	connection.on('notification', onNotification);
+	connection.on('readAllNotifications', () => {
+		if (pagingComponent.value) {
+			for (const item of pagingComponent.value.queue) {
+				item.isRead = true;
+			}
+			for (const item of pagingComponent.value.items) {
+				item.isRead = true;
+			}
+		}
+	});
+	connection.on('readNotifications', notificationIds => {
+		if (pagingComponent.value) {
+			for (let i = 0; i < pagingComponent.value.queue.length; i++) {
+				if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
+					pagingComponent.value.queue[i].isRead = true;
+				}
+			}
+			for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
+				if (notificationIds.includes(pagingComponent.value.items[i].id)) {
+					pagingComponent.value.items[i].isRead = true;
+				}
+			}
+		}
+	});
+});
+
+onUnmounted(() => {
+	if (connection) connection.dispose();
+});
+</script>
+
+<style lang="scss" scoped>
+.elsfgstc {
+	background: var(--panel);
+}
+</style>
diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue
new file mode 100644
index 0000000000..e7d4a5472a
--- /dev/null
+++ b/packages/frontend/src/components/MkNumberDiff.vue
@@ -0,0 +1,47 @@
+<template>
+<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
+	<slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot>
+</span>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+	props: {
+		value: {
+			type: Number,
+			required: true,
+		},
+	},
+
+	setup(props) {
+		const isPlus = computed(() => props.value > 0);
+		const isMinus = computed(() => props.value < 0);
+		const isZero = computed(() => props.value === 0);
+		return {
+			isPlus,
+			isMinus,
+			isZero,
+			number,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ceaaebcd {
+	&.isPlus {
+		color: var(--success);
+	}
+
+	&.isMinus {
+		color: var(--error);
+	}
+
+	&.isZero {
+		opacity: 0.5;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue
new file mode 100644
index 0000000000..0c7230d783
--- /dev/null
+++ b/packages/frontend/src/components/MkObjectView.value.vue
@@ -0,0 +1,160 @@
+<template>
+<div class="igpposuu _monospace">
+	<div v-if="value === null" class="null">null</div>
+	<div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div>
+	<div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div>
+	<div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div>
+	<div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div>
+	<div v-else-if="isArray(value)" class="array">
+		<div v-for="i in value.length" class="element">
+			{{ i }}: <XValue :value="value[i - 1]" collapsed/>
+		</div>
+	</div>
+	<div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div>
+	<div v-else-if="isObject(value)" class="object">
+		<div v-for="k in Object.keys(value)" class="kv">
+			<button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button>
+			<div class="k">{{ k }}:</div>
+			<div v-if="collapsed[k]" class="v">
+				<button class="_button" @click="collapsed[k] = !collapsed[k]">
+					<template v-if="typeof value[k] === 'string'">"..."</template>
+					<template v-else-if="isArray(value[k])">[...]</template>
+					<template v-else-if="isObject(value[k])">{...}</template>
+				</button>
+			</div>
+			<div v-else class="v"><XValue :value="value[k]"/></div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, reactive, ref } from 'vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+	name: 'XValue',
+
+	props: {
+		value: {
+			required: true,
+		},
+	},
+
+	setup(props) {
+		const collapsed = reactive({});
+
+		if (isObject(props.value)) {
+			for (const key in props.value) {
+				collapsed[key] = collapsable(props.value[key]);
+			}
+		}
+
+		function isObject(v): boolean {
+			return typeof v === 'object' && !Array.isArray(v) && v !== null;
+		}
+
+		function isArray(v): boolean {
+			return Array.isArray(v);
+		}
+
+		function isEmpty(v): boolean {
+			return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0);
+		}
+
+		function collapsable(v): boolean {
+			return (isObject(v) || isArray(v)) && !isEmpty(v);
+		}
+
+		return {
+			number,
+			collapsed,
+			isObject,
+			isArray,
+			isEmpty,
+			collapsable,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.igpposuu {
+	display: inline;
+
+	> .null {
+		display: inline;
+		opacity: 0.7;
+	}
+
+	> .boolean {
+		display: inline;
+		color: var(--codeBoolean);
+
+		&.true {
+			font-weight: bold;
+		}
+
+		&.false {
+			opacity: 0.7;
+		}
+	}
+
+	> .string {
+		display: inline;
+		color: var(--codeString);
+	}
+
+	> .number {
+		display: inline;
+		color: var(--codeNumber);
+	}
+
+	> .array.empty {
+		display: inline;
+		opacity: 0.7;
+	}
+
+	> .array:not(.empty) {
+		display: inline;
+
+		> .element {
+			display: block;
+			padding-left: 16px;
+		}
+	}
+
+	> .object.empty {
+		display: inline;
+		opacity: 0.7;
+	}
+
+	> .object:not(.empty) {
+		display: inline;
+
+		> .kv {
+			display: block;
+			padding-left: 16px;
+
+			> .toggle {
+				width: 16px;
+				color: var(--accent);
+				visibility: hidden;
+
+				&.visible {
+					visibility: visible;
+				}
+			}
+
+			> .k {
+				display: inline;
+				margin-right: 8px;
+			}
+
+			> .v {
+				display: inline;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue
new file mode 100644
index 0000000000..55578a37f6
--- /dev/null
+++ b/packages/frontend/src/components/MkObjectView.vue
@@ -0,0 +1,20 @@
+<template>
+<div class="zhyxdalp">
+	<XValue :value="value" :collapsed="false"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XValue from './MkObjectView.value.vue';
+
+const props = defineProps<{
+	value: Record<string, unknown>;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.zhyxdalp {
+
+}
+</style>
diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue
new file mode 100644
index 0000000000..009582e540
--- /dev/null
+++ b/packages/frontend/src/components/MkPagePreview.vue
@@ -0,0 +1,162 @@
+<template>
+<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1">
+	<div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
+	<article>
+		<header>
+			<h1 :title="page.title">{{ page.title }}</h1>
+		</header>
+		<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
+		<footer>
+			<img class="icon" :src="page.user.avatarUrl"/>
+			<p>{{ userName(page.user) }}</p>
+		</footer>
+	</article>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { userName } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+	props: {
+		page: {
+			type: Object,
+			required: true,
+		},
+	},
+	methods: {
+		userName,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.vhpxefrj {
+	display: block;
+
+	&:hover {
+		text-decoration: none;
+		color: var(--accent);
+	}
+
+	> .thumbnail {
+		width: 100%;
+		height: 200px;
+		background-position: center;
+		background-size: cover;
+		display: flex;
+		justify-content: center;
+		align-items: center;
+
+		> button {
+			font-size: 3.5em;
+			opacity: 0.7;
+
+			&:hover {
+				font-size: 4em;
+				opacity: 0.9;
+			}
+		}
+
+		& + article {
+			left: 100px;
+			width: calc(100% - 100px);
+		}
+	}
+
+	> article {
+		padding: 16px;
+
+		> header {
+			margin-bottom: 8px;
+
+			> h1 {
+				margin: 0;
+				font-size: 1em;
+				color: var(--urlPreviewTitle);
+			}
+		}
+
+		> p {
+			margin: 0;
+			color: var(--urlPreviewText);
+			font-size: 0.8em;
+		}
+
+		> footer {
+			margin-top: 8px;
+			height: 16px;
+
+			> img {
+				display: inline-block;
+				width: 16px;
+				height: 16px;
+				margin-right: 4px;
+				vertical-align: top;
+			}
+
+			> p {
+				display: inline-block;
+				margin: 0;
+				color: var(--urlPreviewInfo);
+				font-size: 0.8em;
+				line-height: 16px;
+				vertical-align: top;
+			}
+		}
+	}
+
+	@media (max-width: 700px) {
+		> .thumbnail {
+			position: relative;
+			width: 100%;
+			height: 100px;
+
+			& + article {
+				left: 0;
+				width: 100%;
+			}
+		}
+	}
+
+	@media (max-width: 550px) {
+		font-size: 12px;
+
+		> .thumbnail {
+			height: 80px;
+		}
+
+		> article {
+			padding: 12px;
+		}
+	}
+
+	@media (max-width: 500px) {
+		font-size: 10px;
+
+		> .thumbnail {
+			height: 70px;
+		}
+
+		> article {
+			padding: 8px;
+
+			> header {
+				margin-bottom: 4px;
+			}
+
+			> footer {
+				margin-top: 4px;
+
+				> img {
+					width: 12px;
+					height: 12px;
+				}
+			}
+		}
+	}
+}
+
+</style>
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
new file mode 100644
index 0000000000..29d45558a7
--- /dev/null
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -0,0 +1,140 @@
+<template>
+<XWindow
+	ref="windowEl"
+	:initial-width="500"
+	:initial-height="500"
+	:can-resize="true"
+	:close-button="true"
+	:buttons-left="buttonsLeft"
+	:buttons-right="buttonsRight"
+	:contextmenu="contextmenu"
+	@closed="$emit('closed')"
+>
+	<template #header>
+		<template v-if="pageMetadata?.value">
+			<i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
+			<span>{{ pageMetadata.value.title }}</span>
+		</template>
+	</template>
+
+	<div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }">
+		<RouterView :router="router"/>
+	</div>
+</XWindow>
+</template>
+
+<script lang="ts" setup>
+import { ComputedRef, inject, provide } from 'vue';
+import RouterView from '@/components/global/RouterView.vue';
+import XWindow from '@/components/MkWindow.vue';
+import { popout as _popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { Router } from '@/nirax';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	initialPath: string;
+}>();
+
+defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const router = new Router(routes, props.initialPath);
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let windowEl = $ref<InstanceType<typeof XWindow>>();
+const history = $ref<{ path: string; key: any; }[]>([{
+	path: router.getCurrentPath(),
+	key: router.getCurrentKey(),
+}]);
+const buttonsLeft = $computed(() => {
+	const buttons = [];
+
+	if (history.length > 1) {
+		buttons.push({
+			icon: 'ti ti-arrow-left',
+			onClick: back,
+		});
+	}
+
+	return buttons;
+});
+const buttonsRight = $computed(() => {
+	const buttons = [{
+		icon: 'ti ti-player-eject',
+		title: i18n.ts.showInPage,
+		onClick: expand,
+	}];
+
+	return buttons;
+});
+
+router.addListener('push', ctx => {
+	history.push({ path: ctx.path, key: ctx.key });
+});
+
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const contextmenu = $computed(() => ([{
+	icon: 'ti ti-player-eject',
+	text: i18n.ts.showInPage,
+	action: expand,
+}, {
+	icon: 'ti ti-window-maximize',
+	text: i18n.ts.popout,
+	action: popout,
+}, {
+	icon: 'ti ti-external-link',
+	text: i18n.ts.openInNewTab,
+	action: () => {
+		window.open(url + router.getCurrentPath(), '_blank');
+		windowEl.close();
+	},
+}, {
+	icon: 'ti ti-link',
+	text: i18n.ts.copyLink,
+	action: () => {
+		copyToClipboard(url + router.getCurrentPath());
+	},
+}]));
+
+function back() {
+	history.pop();
+	router.replace(history[history.length - 1].path, history[history.length - 1].key);
+}
+
+function close() {
+	windowEl.close();
+}
+
+function expand() {
+	mainRouter.push(router.getCurrentPath(), 'forcePage');
+	windowEl.close();
+}
+
+function popout() {
+	_popout(router.getCurrentPath(), windowEl.$el);
+	windowEl.close();
+}
+
+defineExpose({
+	close,
+});
+</script>
+
+<style lang="scss" scoped>
+.yrolvcoq {
+	min-height: 100%;
+	background: var(--bg);
+}
+</style>
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
new file mode 100644
index 0000000000..291409171a
--- /dev/null
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -0,0 +1,317 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+	<MkLoading v-if="fetching"/>
+
+	<MkError v-else-if="error" @retry="init()"/>
+
+	<div v-else-if="empty" key="_empty_" class="empty">
+		<slot name="empty">
+			<div class="_fullinfo">
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+				<div>{{ i18n.ts.nothing }}</div>
+			</div>
+		</slot>
+	</div>
+
+	<div v-else ref="rootEl">
+		<div v-show="pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
+			<MkButton v-if="!moreFetching" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
+				{{ i18n.ts.loadMore }}
+			</MkButton>
+			<MkLoading v-else class="loading"/>
+		</div>
+		<slot :items="items"></slot>
+		<div v-show="!pagination.reversed && more" key="_more_" class="cxiknjgy _gap">
+			<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
+				{{ i18n.ts.loadMore }}
+			</MkButton>
+			<MkLoading v-else class="loading"/>
+		</div>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import * as os from '@/os';
+import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+
+const SECOND_FETCH_LIMIT = 30;
+
+export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
+	endpoint: E;
+	limit: number;
+	params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>;
+
+	/**
+	 * 検索APIのような、ページング不可なエンドポイントを利用する場合
+	 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
+	 */
+	noPaging?: boolean;
+
+	/**
+	 * items 配列の中身を逆順にする(新しい方が最後)
+	 */
+	reversed?: boolean;
+
+	offsetMode?: boolean;
+};
+
+const props = withDefaults(defineProps<{
+	pagination: Paging;
+	disableAutoLoad?: boolean;
+	displayLimit?: number;
+}>(), {
+	displayLimit: 30,
+});
+
+const emit = defineEmits<{
+	(ev: 'queue', count: number): void;
+}>();
+
+type Item = { id: string; [another: string]: unknown; };
+
+const rootEl = ref<HTMLElement>();
+const items = ref<Item[]>([]);
+const queue = ref<Item[]>([]);
+const offset = ref(0);
+const fetching = ref(true);
+const moreFetching = ref(false);
+const more = ref(false);
+const backed = ref(false); // 遡り中か否か
+const isBackTop = ref(false);
+const empty = computed(() => items.value.length === 0);
+const error = ref(false);
+
+const init = async (): Promise<void> => {
+	queue.value = [];
+	fetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			if (props.pagination.reversed) {
+				if (i === res.length - 2) item._shouldInsertAd_ = true;
+			} else {
+				if (i === 3) item._shouldInsertAd_ = true;
+			}
+		}
+		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse() : res;
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse() : res;
+			more.value = false;
+		}
+		offset.value = res.length;
+		error.value = false;
+		fetching.value = false;
+	}, err => {
+		error.value = true;
+		fetching.value = false;
+	});
+};
+
+const reload = (): void => {
+	items.value = [];
+	init();
+};
+
+const fetchMore = async (): Promise<void> => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	moreFetching.value = true;
+	backed.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT + 1,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : props.pagination.reversed ? {
+			sinceId: items.value[0].id,
+		} : {
+			untilId: items.value[items.value.length - 1].id,
+		}),
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			if (props.pagination.reversed) {
+				if (i === res.length - 9) item._shouldInsertAd_ = true;
+			} else {
+				if (i === 10) item._shouldInsertAd_ = true;
+			}
+		}
+		if (res.length > SECOND_FETCH_LIMIT) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = false;
+		}
+		offset.value += res.length;
+		moreFetching.value = false;
+	}, err => {
+		moreFetching.value = false;
+	});
+};
+
+const fetchMoreAhead = async (): Promise<void> => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	moreFetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT + 1,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : props.pagination.reversed ? {
+			untilId: items.value[0].id,
+		} : {
+			sinceId: items.value[items.value.length - 1].id,
+		}),
+	}).then(res => {
+		if (res.length > SECOND_FETCH_LIMIT) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = false;
+		}
+		offset.value += res.length;
+		moreFetching.value = false;
+	}, err => {
+		moreFetching.value = false;
+	});
+};
+
+const prepend = (item: Item): void => {
+	if (props.pagination.reversed) {
+		if (rootEl.value) {
+			const container = getScrollContainer(rootEl.value);
+			if (container == null) {
+				// TODO?
+			} else {
+				const pos = getScrollPosition(rootEl.value);
+				const viewHeight = container.clientHeight;
+				const height = container.scrollHeight;
+				const isBottom = (pos + viewHeight > height - 32);
+				if (isBottom) {
+					// オーバーフローしたら古いアイテムは捨てる
+					if (items.value.length >= props.displayLimit) {
+						// このやり方だとVue 3.2以降アニメーションが動かなくなる
+						//items.value = items.value.slice(-props.displayLimit);
+						while (items.value.length >= props.displayLimit) {
+							items.value.shift();
+						}
+						more.value = true;
+					}
+				}
+			}
+		}
+		items.value.push(item);
+		// TODO
+	} else {
+		// 初回表示時はunshiftだけでOK
+		if (!rootEl.value) {
+			items.value.unshift(item);
+			return;
+		}
+
+		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
+
+		if (isTop) {
+			// Prepend the item
+			items.value.unshift(item);
+
+			// オーバーフローしたら古いアイテムは捨てる
+			if (items.value.length >= props.displayLimit) {
+				// このやり方だとVue 3.2以降アニメーションが動かなくなる
+				//this.items = items.value.slice(0, props.displayLimit);
+				while (items.value.length >= props.displayLimit) {
+					items.value.pop();
+				}
+				more.value = true;
+			}
+		} else {
+			queue.value.push(item);
+			onScrollTop(rootEl.value, () => {
+				for (const item of queue.value) {
+					prepend(item);
+				}
+				queue.value = [];
+			});
+		}
+	}
+};
+
+const append = (item: Item): void => {
+	items.value.push(item);
+};
+
+const removeItem = (finder: (item: Item) => boolean) => {
+	const i = items.value.findIndex(finder);
+	items.value.splice(i, 1);
+};
+
+const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => {
+	const i = items.value.findIndex(item => item.id === id);
+	items.value[i] = replacer(items.value[i]);
+};
+
+if (props.pagination.params && isRef(props.pagination.params)) {
+	watch(props.pagination.params, init, { deep: true });
+}
+
+watch(queue, (a, b) => {
+	if (a.length === 0 && b.length === 0) return;
+	emit('queue', queue.value.length);
+}, { deep: true });
+
+init();
+
+onActivated(() => {
+	isBackTop.value = false;
+});
+
+onDeactivated(() => {
+	isBackTop.value = window.scrollY === 0;
+});
+
+defineExpose({
+	items,
+	queue,
+	backed,
+	reload,
+	prepend,
+	append,
+	removeItem,
+	updateItem,
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.cxiknjgy {
+	> .button {
+		margin-left: auto;
+		margin-right: auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
new file mode 100644
index 0000000000..a1b927e42a
--- /dev/null
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="tivcixzd" :class="{ done: closed || isVoted }">
+	<ul>
+		<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
+			<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
+			<span>
+				<template v-if="choice.isVoted"><i class="ti ti-check"></i></template>
+				<Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
+				<span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
+			</span>
+		</li>
+	</ul>
+	<p v-if="!readOnly">
+		<span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
+		<span> · </span>
+		<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
+		<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
+		<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
+		<span v-if="remaining > 0"> · {{ timer }}</span>
+	</p>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onUnmounted, ref, toRef } from 'vue';
+import * as misskey from 'misskey-js';
+import { sum } from '@/scripts/array';
+import { pleaseLogin } from '@/scripts/please-login';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { useInterval } from '@/scripts/use-interval';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	readOnly?: boolean;
+}>();
+
+const remaining = ref(-1);
+
+const total = computed(() => sum(props.note.poll.choices.map(x => x.votes)));
+const closed = computed(() => remaining.value === 0);
+const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted));
+const timer = computed(() => i18n.t(
+	remaining.value >= 86400 ? '_poll.remainingDays' :
+	remaining.value >= 3600 ? '_poll.remainingHours' :
+	remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
+		s: Math.floor(remaining.value % 60),
+		m: Math.floor(remaining.value / 60) % 60,
+		h: Math.floor(remaining.value / 3600) % 24,
+		d: Math.floor(remaining.value / 86400),
+	}));
+
+const showResult = ref(props.readOnly || isVoted.value);
+
+// 期限付きアンケート
+if (props.note.poll.expiresAt) {
+	const tick = () => {
+		remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000);
+		if (remaining.value === 0) {
+			showResult.value = true;
+		}
+	};
+
+	useInterval(tick, 3000, {
+		immediate: true,
+		afterMounted: false,
+	});
+}
+
+const vote = async (id) => {
+	pleaseLogin();
+
+	if (props.readOnly || closed.value || isVoted.value) return;
+
+	const { canceled } = await os.confirm({
+		type: 'question',
+		text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }),
+	});
+	if (canceled) return;
+
+	await os.api('notes/polls/vote', {
+		noteId: props.note.id,
+		choice: id,
+	});
+	if (!showResult.value) showResult.value = !props.note.poll.multiple;
+};
+</script>
+
+<style lang="scss" scoped>
+.tivcixzd {
+	> ul {
+		display: block;
+		margin: 0;
+		padding: 0;
+		list-style: none;
+
+		> li {
+			display: block;
+			position: relative;
+			margin: 4px 0;
+			padding: 4px;
+			//border: solid 0.5px var(--divider);
+			background: var(--accentedBg);
+			border-radius: 4px;
+			overflow: hidden;
+			cursor: pointer;
+
+			> .backdrop {
+				position: absolute;
+				top: 0;
+				left: 0;
+				height: 100%;
+				background: var(--accent);
+				background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
+				transition: width 1s ease;
+			}
+
+			> span {
+				position: relative;
+				display: inline-block;
+				padding: 3px 5px;
+				background: var(--panel);
+				border-radius: 3px;
+
+				> i {
+					margin-right: 4px;
+					color: var(--accent);
+				}
+
+				> .votes {
+					margin-left: 4px;
+					opacity: 0.7;
+				}
+			}
+		}
+	}
+
+	> p {
+		color: var(--fg);
+
+		a {
+			color: inherit;
+		}
+	}
+
+	&.done {
+		> ul > li {
+			cursor: default;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue
new file mode 100644
index 0000000000..556abc5fd0
--- /dev/null
+++ b/packages/frontend/src/components/MkPollEditor.vue
@@ -0,0 +1,219 @@
+<template>
+<div class="zmdxowus">
+	<p v-if="choices.length < 2" class="caution">
+		<i class="ti ti-alert-triangle"></i>{{ i18n.ts._poll.noOnlyOneChoice }}
+	</p>
+	<ul>
+		<li v-for="(choice, i) in choices" :key="i">
+			<MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
+			</MkInput>
+			<button class="_button" @click="remove(i)">
+				<i class="ti ti-x"></i>
+			</button>
+		</li>
+	</ul>
+	<MkButton v-if="choices.length < 10" class="add" @click="add">{{ i18n.ts.add }}</MkButton>
+	<MkButton v-else class="add" disabled>{{ i18n.ts._poll.noMore }}</MkButton>
+	<MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch>
+	<section>
+		<div>
+			<MkSelect v-model="expiration" small>
+				<template #label>{{ i18n.ts._poll.expiration }}</template>
+				<option value="infinite">{{ i18n.ts._poll.infinite }}</option>
+				<option value="at">{{ i18n.ts._poll.at }}</option>
+				<option value="after">{{ i18n.ts._poll.after }}</option>
+			</MkSelect>
+			<section v-if="expiration === 'at'">
+				<MkInput v-model="atDate" small type="date" class="input">
+					<template #label>{{ i18n.ts._poll.deadlineDate }}</template>
+				</MkInput>
+				<MkInput v-model="atTime" small type="time" class="input">
+					<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
+				</MkInput>
+			</section>
+			<section v-else-if="expiration === 'after'">
+				<MkInput v-model="after" small type="number" class="input">
+					<template #label>{{ i18n.ts._poll.duration }}</template>
+				</MkInput>
+				<MkSelect v-model="unit" small>
+					<option value="second">{{ i18n.ts._time.second }}</option>
+					<option value="minute">{{ i18n.ts._time.minute }}</option>
+					<option value="hour">{{ i18n.ts._time.hour }}</option>
+					<option value="day">{{ i18n.ts._time.day }}</option>
+				</MkSelect>
+			</section>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import MkInput from './form/input.vue';
+import MkSelect from './form/select.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './MkButton.vue';
+import { formatDateTimeString } from '@/scripts/format-time-string';
+import { addTime } from '@/scripts/time';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: {
+		expiresAt: string;
+		expiredAfter: number;
+		choices: string[];
+		multiple: boolean;
+	};
+}>();
+const emit = defineEmits<{
+	(ev: 'update:modelValue', v: {
+		expiresAt: string;
+		expiredAfter: number;
+		choices: string[];
+		multiple: boolean;
+	}): void;
+}>();
+
+const choices = ref(props.modelValue.choices);
+const multiple = ref(props.modelValue.multiple);
+const expiration = ref('infinite');
+const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
+const atTime = ref('00:00');
+const after = ref(0);
+const unit = ref('second');
+
+if (props.modelValue.expiresAt) {
+	expiration.value = 'at';
+	atDate.value = atTime.value = props.modelValue.expiresAt;
+} else if (typeof props.modelValue.expiredAfter === 'number') {
+	expiration.value = 'after';
+	after.value = props.modelValue.expiredAfter / 1000;
+} else {
+	expiration.value = 'infinite';
+}
+
+function onInput(i, value) {
+	choices.value[i] = value;
+}
+
+function add() {
+	choices.value.push('');
+	// TODO
+	// nextTick(() => {
+	//   (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
+	// });
+}
+
+function remove(i) {
+	choices.value = choices.value.filter((_, _i) => _i !== i);
+}
+
+function get() {
+	const calcAt = () => {
+		return new Date(`${atDate.value} ${atTime.value}`).getTime();
+	};
+
+	const calcAfter = () => {
+		let base = parseInt(after.value);
+		switch (unit.value) {
+			case 'day': base *= 24;
+				// fallthrough
+			case 'hour': base *= 60;
+				// fallthrough
+			case 'minute': base *= 60;
+				// fallthrough
+			case 'second': return base *= 1000;
+			default: return null;
+		}
+	};
+
+	return {
+		choices: choices.value,
+		multiple: multiple.value,
+		...(
+			expiration.value === 'at' ? { expiresAt: calcAt() } :
+			expiration.value === 'after' ? { expiredAfter: calcAfter() } : {}
+		),
+	};
+}
+
+watch([choices, multiple, expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), {
+	deep: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.zmdxowus {
+	padding: 8px 16px;
+
+	> .caution {
+		margin: 0 0 8px 0;
+		font-size: 0.8em;
+		color: #f00;
+
+		> i {
+			margin-right: 4px;
+		}
+	}
+
+	> ul {
+		display: block;
+		margin: 0;
+		padding: 0;
+		list-style: none;
+
+		> li {
+			display: flex;
+			margin: 8px 0;
+			padding: 0;
+			width: 100%;
+
+			> .input {
+				flex: 1;
+			}
+
+			> button {
+				width: 32px;
+				padding: 4px 0;
+			}
+		}
+	}
+
+	> .add {
+		margin: 8px 0;
+		z-index: 1;
+	}
+
+	> section {
+		margin: 16px 0 0 0;
+
+		> div {
+			margin: 0 8px;
+			display: flex;
+			flex-direction: row;
+			flex-wrap: wrap;
+			gap: 12px;
+
+			&:last-child {
+				flex: 1 0 auto;
+
+				> div {
+					flex-grow: 1;
+				}
+
+				> section {
+					// MAGIC: Prevent div above from growing unless wrapped to its own line
+					flex-grow: 9999;
+					align-items: end;
+					display: flex;
+					gap: 4px;
+
+					> .input {
+						flex: 1 1 auto;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue
new file mode 100644
index 0000000000..f04c7f5618
--- /dev/null
+++ b/packages/frontend/src/components/MkPopupMenu.vue
@@ -0,0 +1,36 @@
+<template>
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')">
+	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkModal from './MkModal.vue';
+import MkMenu from './MkMenu.vue';
+import { MenuItem } from '@/types/menu';
+
+defineProps<{
+	items: MenuItem[];
+	align?: 'center' | string;
+	width?: number;
+	viaKeyboard?: boolean;
+	src?: any;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+let modal = $ref<InstanceType<typeof MkModal>>();
+</script>
+
+<style lang="scss" scoped>
+.sfhdhdhq {
+	&.drawer {
+		border-radius: 24px;
+		border-bottom-right-radius: 0;
+		border-bottom-left-radius: 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
new file mode 100644
index 0000000000..f79e5a32cd
--- /dev/null
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -0,0 +1,1050 @@
+<template>
+<div
+	v-size="{ max: [310, 500] }" class="gafaadew"
+	:class="{ modal, _popup: modal }"
+	@dragover.stop="onDragover"
+	@dragenter="onDragenter"
+	@dragleave="onDragleave"
+	@drop.stop="onDrop"
+>
+	<header>
+		<button v-if="!fixed" class="cancel _button" @click="cancel"><i class="ti ti-x"></i></button>
+		<button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu">
+			<MkAvatar :user="postAccount ?? $i" class="avatar"/>
+		</button>
+		<div class="right">
+			<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
+			<span v-if="localOnly" class="local-only"><i class="ti ti-world-off"></i></span>
+			<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
+				<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
+				<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
+				<span v-if="visibility === 'followers'"><i class="ti ti-lock-open"></i></span>
+				<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
+			</button>
+			<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
+			<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button>
+		</div>
+	</header>
+	<div class="form" :class="{ fixed }">
+		<XNoteSimple v-if="reply" class="preview" :note="reply"/>
+		<XNoteSimple v-if="renote" class="preview" :note="renote"/>
+		<div v-if="quoteId" class="with-quote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
+		<div v-if="visibility === 'specified'" class="to-specified">
+			<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
+			<div class="visibleUsers">
+				<span v-for="u in visibleUsers" :key="u.id">
+					<MkAcct :user="u"/>
+					<button class="_button" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
+				</span>
+				<button class="_buttonPrimary" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
+			</div>
+		</div>
+		<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
+		<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
+		<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+		<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
+		<XPostFormAttaches v-model="files" class="attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
+		<XPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
+		<XNotePreview v-if="showPreview" class="preview" :text="text"/>
+		<footer>
+			<button v-tooltip="i18n.ts.attachFile" class="_button" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
+			<button v-tooltip="i18n.ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
+			<button v-tooltip="i18n.ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
+			<button v-tooltip="i18n.ts.mention" class="_button" @click="insertMention"><i class="ti ti-at"></i></button>
+			<button v-tooltip="i18n.ts.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
+			<button v-tooltip="i18n.ts.emoji" class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
+			<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" @click="showActions"><i class="ti ti-plug"></i></button>
+		</footer>
+		<datalist id="hashtags">
+			<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
+		</datalist>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
+import * as mfm from 'mfm-js';
+import * as misskey from 'misskey-js';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import { length } from 'stringz';
+import { toASCII } from 'punycode/';
+import * as Acct from 'misskey-js/built/acct';
+import { throttle } from 'throttle-debounce';
+import XNoteSimple from '@/components/MkNoteSimple.vue';
+import XNotePreview from '@/components/MkNotePreview.vue';
+import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
+import XPollEditor from '@/components/MkPollEditor.vue';
+import { host, url } from '@/config';
+import { erase, unique } from '@/scripts/array';
+import { extractMentions } from '@/scripts/extract-mentions';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { Autocomplete } from '@/scripts/autocomplete';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { selectFiles } from '@/scripts/select-file';
+import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
+import MkInfo from '@/components/MkInfo.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
+import { uploadFile } from '@/scripts/upload';
+import { deepClone } from '@/scripts/clone';
+
+const modal = inject('modal');
+
+const props = withDefaults(defineProps<{
+	reply?: misskey.entities.Note;
+	renote?: misskey.entities.Note;
+	channel?: any; // TODO
+	mention?: misskey.entities.User;
+	specified?: misskey.entities.User;
+	initialText?: string;
+	initialVisibility?: typeof misskey.noteVisibilities;
+	initialFiles?: misskey.entities.DriveFile[];
+	initialLocalOnly?: boolean;
+	initialVisibleUsers?: misskey.entities.User[];
+	initialNote?: misskey.entities.Note;
+	instant?: boolean;
+	fixed?: boolean;
+	autofocus?: boolean;
+}>(), {
+	initialVisibleUsers: () => [],
+	autofocus: true,
+});
+
+const emit = defineEmits<{
+	(ev: 'posted'): void;
+	(ev: 'cancel'): void;
+	(ev: 'esc'): void;
+}>();
+
+const textareaEl = $ref<HTMLTextAreaElement | null>(null);
+const cwInputEl = $ref<HTMLInputElement | null>(null);
+const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
+const visibilityButton = $ref<HTMLElement | null>(null);
+
+let posting = $ref(false);
+let text = $ref(props.initialText ?? '');
+let files = $ref(props.initialFiles ?? []);
+let poll = $ref<{
+	choices: string[];
+	multiple: boolean;
+	expiresAt: string | null;
+	expiredAfter: string | null;
+} | null>(null);
+let useCw = $ref(false);
+let showPreview = $ref(false);
+let cw = $ref<string | null>(null);
+let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
+let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]);
+let visibleUsers = $ref([]);
+if (props.initialVisibleUsers) {
+	props.initialVisibleUsers.forEach(pushVisibleUser);
+}
+let autocomplete = $ref(null);
+let draghover = $ref(false);
+let quoteId = $ref(null);
+let hasNotSpecifiedMentions = $ref(false);
+let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]'));
+let imeText = $ref('');
+
+const typing = throttle(3000, () => {
+	if (props.channel) {
+		stream.send('typingOnChannel', { channel: props.channel.id });
+	}
+});
+
+const draftKey = $computed((): string => {
+	let key = props.channel ? `channel:${props.channel.id}` : '';
+
+	if (props.renote) {
+		key += `renote:${props.renote.id}`;
+	} else if (props.reply) {
+		key += `reply:${props.reply.id}`;
+	} else {
+		key += 'note';
+	}
+
+	return key;
+});
+
+const placeholder = $computed((): string => {
+	if (props.renote) {
+		return i18n.ts._postForm.quotePlaceholder;
+	} else if (props.reply) {
+		return i18n.ts._postForm.replyPlaceholder;
+	} else if (props.channel) {
+		return i18n.ts._postForm.channelPlaceholder;
+	} else {
+		const xs = [
+			i18n.ts._postForm._placeholders.a,
+			i18n.ts._postForm._placeholders.b,
+			i18n.ts._postForm._placeholders.c,
+			i18n.ts._postForm._placeholders.d,
+			i18n.ts._postForm._placeholders.e,
+			i18n.ts._postForm._placeholders.f,
+		];
+		return xs[Math.floor(Math.random() * xs.length)];
+	}
+});
+
+const submitText = $computed((): string => {
+	return props.renote
+		? i18n.ts.quote
+		: props.reply
+			? i18n.ts.reply
+			: i18n.ts.note;
+});
+
+const textLength = $computed((): number => {
+	return length((text + imeText).trim());
+});
+
+const maxTextLength = $computed((): number => {
+	return instance ? instance.maxNoteTextLength : 1000;
+});
+
+const canPost = $computed((): boolean => {
+	return !posting &&
+		(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
+		(textLength <= maxTextLength) &&
+		(!poll || poll.choices.length >= 2);
+});
+
+const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
+const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
+
+watch($$(text), () => {
+	checkMissingMention();
+});
+
+watch($$(visibleUsers), () => {
+	checkMissingMention();
+}, {
+	deep: true,
+});
+
+if (props.mention) {
+	text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
+	text += ' ';
+}
+
+if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
+	text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
+}
+
+if (props.reply && props.reply.text != null) {
+	const ast = mfm.parse(props.reply.text);
+	const otherHost = props.reply.user.host;
+
+	for (const x of extractMentions(ast)) {
+		const mention = x.host ?
+			`@${x.username}@${toASCII(x.host)}` :
+			(otherHost == null || otherHost === host) ?
+				`@${x.username}` :
+				`@${x.username}@${toASCII(otherHost)}`;
+
+		// 自分は除外
+		if ($i.username === x.username && (x.host == null || x.host === host)) continue;
+
+		// 重複は除外
+		if (text.includes(`${mention} `)) continue;
+
+		text += `${mention} `;
+	}
+}
+
+if (props.channel) {
+	visibility = 'public';
+	localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+}
+
+// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
+	if (props.reply.visibility === 'home' && visibility === 'followers') {
+		visibility = 'followers';
+	} else if (['home', 'followers'].includes(props.reply.visibility) && visibility === 'specified') {
+		visibility = 'specified';
+	} else {
+		visibility = props.reply.visibility;
+	}
+
+	if (visibility === 'specified') {
+		if (props.reply.visibleUserIds) {
+			os.api('users/show', {
+				userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId),
+			}).then(users => {
+				users.forEach(pushVisibleUser);
+			});
+		}
+
+		if (props.reply.userId !== $i.id) {
+			os.api('users/show', { userId: props.reply.userId }).then(user => {
+				pushVisibleUser(user);
+			});
+		}
+	}
+}
+
+if (props.specified) {
+	visibility = 'specified';
+	pushVisibleUser(props.specified);
+}
+
+// keep cw when reply
+if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
+	useCw = true;
+	cw = props.reply.cw;
+}
+
+function watchForDraft() {
+	watch($$(text), () => saveDraft());
+	watch($$(useCw), () => saveDraft());
+	watch($$(cw), () => saveDraft());
+	watch($$(poll), () => saveDraft());
+	watch($$(files), () => saveDraft(), { deep: true });
+	watch($$(visibility), () => saveDraft());
+	watch($$(localOnly), () => saveDraft());
+}
+
+function checkMissingMention() {
+	if (visibility === 'specified') {
+		const ast = mfm.parse(text);
+
+		for (const x of extractMentions(ast)) {
+			if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
+				hasNotSpecifiedMentions = true;
+				return;
+			}
+		}
+		hasNotSpecifiedMentions = false;
+	}
+}
+
+function addMissingMention() {
+	const ast = mfm.parse(text);
+
+	for (const x of extractMentions(ast)) {
+		if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
+			os.api('users/show', { username: x.username, host: x.host }).then(user => {
+				visibleUsers.push(user);
+			});
+		}
+	}
+}
+
+function togglePoll() {
+	if (poll) {
+		poll = null;
+	} else {
+		poll = {
+			choices: ['', ''],
+			multiple: false,
+			expiresAt: null,
+			expiredAfter: null,
+		};
+	}
+}
+
+function addTag(tag: string) {
+	insertTextAtCursor(textareaEl, ` #${tag} `);
+}
+
+function focus() {
+	if (textareaEl) {
+		textareaEl.focus();
+		textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length);
+	}
+}
+
+function chooseFileFrom(ev) {
+	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
+		for (const file of files_) {
+			files.push(file);
+		}
+	});
+}
+
+function detachFile(id) {
+	files = files.filter(x => x.id !== id);
+}
+
+function updateFileSensitive(file, sensitive) {
+	files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+}
+
+function updateFileName(file, name) {
+	files[files.findIndex(x => x.id === file.id)].name = name;
+}
+
+function upload(file: File, name?: string) {
+	uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
+		files.push(res);
+	});
+}
+
+function setVisibility() {
+	if (props.channel) {
+		// TODO: information dialog
+		return;
+	}
+
+	os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
+		currentVisibility: visibility,
+		currentLocalOnly: localOnly,
+		src: visibilityButton,
+	}, {
+		changeVisibility: v => {
+			visibility = v;
+			if (defaultStore.state.rememberNoteVisibility) {
+				defaultStore.set('visibility', visibility);
+			}
+		},
+		changeLocalOnly: v => {
+			localOnly = v;
+			if (defaultStore.state.rememberNoteVisibility) {
+				defaultStore.set('localOnly', localOnly);
+			}
+		},
+	}, 'closed');
+}
+
+function pushVisibleUser(user) {
+	if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) {
+		visibleUsers.push(user);
+	}
+}
+
+function addVisibleUser() {
+	os.selectUser().then(user => {
+		pushVisibleUser(user);
+	});
+}
+
+function removeVisibleUser(user) {
+	visibleUsers = erase(user, visibleUsers);
+}
+
+function clear() {
+	text = '';
+	files = [];
+	poll = null;
+	quoteId = null;
+}
+
+function onKeydown(ev: KeyboardEvent) {
+	if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post();
+	if (ev.which === 27) emit('esc');
+	typing();
+}
+
+function onCompositionUpdate(ev: CompositionEvent) {
+	imeText = ev.data;
+	typing();
+}
+
+function onCompositionEnd(ev: CompositionEvent) {
+	imeText = '';
+}
+
+async function onPaste(ev: ClipboardEvent) {
+	for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({ item, i }))) {
+		if (item.kind === 'file') {
+			const file = item.getAsFile();
+			const lio = file.name.lastIndexOf('.');
+			const ext = lio >= 0 ? file.name.slice(lio) : '';
+			const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+			upload(file, formatted);
+		}
+	}
+
+	const paste = ev.clipboardData.getData('text');
+
+	if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) {
+		ev.preventDefault();
+
+		os.confirm({
+			type: 'info',
+			text: i18n.ts.quoteQuestion,
+		}).then(({ canceled }) => {
+			if (canceled) {
+				insertTextAtCursor(textareaEl, paste);
+				return;
+			}
+
+			quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+		});
+	}
+}
+
+function onDragover(ev) {
+	if (!ev.dataTransfer.items[0]) return;
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	if (isFile || isDriveFile) {
+		ev.preventDefault();
+		draghover = true;
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	}
+}
+
+function onDragenter(ev) {
+	draghover = true;
+}
+
+function onDragleave(ev) {
+	draghover = false;
+}
+
+function onDrop(ev): void {
+	draghover = false;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length > 0) {
+		ev.preventDefault();
+		for (const x of Array.from(ev.dataTransfer.files)) upload(x);
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		files.push(file);
+		ev.preventDefault();
+	}
+	//#endregion
+}
+
+function saveDraft() {
+	const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+	draftData[draftKey] = {
+		updatedAt: new Date(),
+		data: {
+			text: text,
+			useCw: useCw,
+			cw: cw,
+			visibility: visibility,
+			localOnly: localOnly,
+			files: files,
+			poll: poll,
+		},
+	};
+
+	localStorage.setItem('drafts', JSON.stringify(draftData));
+}
+
+function deleteDraft() {
+	const draftData = JSON.parse(localStorage.getItem('drafts') ?? '{}');
+
+	delete draftData[draftKey];
+
+	localStorage.setItem('drafts', JSON.stringify(draftData));
+}
+
+async function post() {
+	let postData = {
+		text: text === '' ? undefined : text,
+		fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
+		replyId: props.reply ? props.reply.id : undefined,
+		renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
+		channelId: props.channel ? props.channel.id : undefined,
+		poll: poll,
+		cw: useCw ? cw ?? '' : undefined,
+		localOnly: localOnly,
+		visibility: visibility,
+		visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
+	};
+
+	if (withHashtags && hashtags && hashtags.trim() !== '') {
+		const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
+		postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_;
+	}
+
+	// plugin
+	if (notePostInterruptors.length > 0) {
+		for (const interruptor of notePostInterruptors) {
+			postData = await interruptor.handler(deepClone(postData));
+		}
+	}
+
+	let token = undefined;
+
+	if (postAccount) {
+		const storedAccounts = await getAccounts();
+		token = storedAccounts.find(x => x.id === postAccount.id)?.token;
+	}
+
+	posting = true;
+	os.api('notes/create', postData, token).then(() => {
+		clear();
+		nextTick(() => {
+			deleteDraft();
+			emit('posted');
+			if (postData.text && postData.text !== '') {
+				const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history))));
+			}
+			posting = false;
+			postAccount = null;
+		});
+	}).catch(err => {
+		posting = false;
+		os.alert({
+			type: 'error',
+			text: err.message + '\n' + (err as any).id,
+		});
+	});
+}
+
+function cancel() {
+	emit('cancel');
+}
+
+function insertMention() {
+	os.selectUser().then(user => {
+		insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' ');
+	});
+}
+
+async function insertEmoji(ev: MouseEvent) {
+	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl);
+}
+
+function showActions(ev) {
+	os.popupMenu(postFormActions.map(action => ({
+		text: action.title,
+		action: () => {
+			action.handler({
+				text: text,
+			}, (key, value) => {
+				if (key === 'text') { text = value; }
+			});
+		},
+	})), ev.currentTarget ?? ev.target);
+}
+
+let postAccount = $ref<misskey.entities.UserDetailed | null>(null);
+
+function openAccountMenu(ev: MouseEvent) {
+	openAccountMenu_({
+		withExtraOperation: false,
+		includeCurrentAccount: true,
+		active: postAccount != null ? postAccount.id : $i.id,
+		onChoose: (account) => {
+			if (account.id === $i.id) {
+				postAccount = null;
+			} else {
+				postAccount = account;
+			}
+		},
+	}, ev);
+}
+
+onMounted(() => {
+	if (props.autofocus) {
+		focus();
+
+		nextTick(() => {
+			focus();
+		});
+	}
+
+	// TODO: detach when unmount
+	new Autocomplete(textareaEl, $$(text));
+	new Autocomplete(cwInputEl, $$(cw));
+	new Autocomplete(hashtagsInputEl, $$(hashtags));
+
+	nextTick(() => {
+		// 書きかけの投稿を復元
+		if (!props.instant && !props.mention && !props.specified) {
+			const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
+			if (draft) {
+				text = draft.data.text;
+				useCw = draft.data.useCw;
+				cw = draft.data.cw;
+				visibility = draft.data.visibility;
+				localOnly = draft.data.localOnly;
+				files = (draft.data.files || []).filter(draftFile => draftFile);
+				if (draft.data.poll) {
+					poll = draft.data.poll;
+				}
+			}
+		}
+
+		// 削除して編集
+		if (props.initialNote) {
+			const init = props.initialNote;
+			text = init.text ? init.text : '';
+			files = init.files;
+			cw = init.cw;
+			useCw = init.cw != null;
+			if (init.poll) {
+				poll = {
+					choices: init.poll.choices.map(x => x.text),
+					multiple: init.poll.multiple,
+					expiresAt: init.poll.expiresAt,
+					expiredAfter: init.poll.expiredAfter,
+				};
+			}
+			visibility = init.visibility;
+			localOnly = init.localOnly;
+			quoteId = init.renote ? init.renote.id : null;
+		}
+
+		nextTick(() => watchForDraft());
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+.gafaadew {
+	position: relative;
+
+	&.modal {
+		width: 100%;
+		max-width: 520px;
+	}
+
+	> header {
+		z-index: 1000;
+		height: 66px;
+
+		> .cancel {
+			padding: 0;
+			font-size: 1em;
+			width: 64px;
+			line-height: 66px;
+		}
+
+		> .account {
+			height: 100%;
+			aspect-ratio: 1/1;
+			display: inline-flex;
+			vertical-align: bottom;
+
+			> .avatar {
+				width: 28px;
+				height: 28px;
+				margin: auto;
+			}
+		}
+
+		> .right {
+			position: absolute;
+			top: 0;
+			right: 0;
+
+			> .text-count {
+				opacity: 0.7;
+				line-height: 66px;
+			}
+
+			> .visibility {
+				height: 34px;
+				width: 34px;
+				margin: 0 0 0 8px;
+
+				& + .localOnly {
+					margin-left: 0 !important;
+				}
+			}
+			
+			> .local-only {
+				margin: 0 0 0 12px;
+				opacity: 0.7;
+			}
+
+			> .preview {
+				display: inline-block;
+				padding: 0;
+				margin: 0 8px 0 0;
+				font-size: 16px;
+				width: 34px;
+				height: 34px;
+				border-radius: 6px;
+
+				&:hover {
+					background: var(--X5);
+				}
+
+				&.active {
+					color: var(--accent);
+				}
+			}
+
+			> .submit {
+				margin: 16px 16px 16px 0;
+				padding: 0 12px;
+				line-height: 34px;
+				font-weight: bold;
+				vertical-align: bottom;
+				border-radius: 4px;
+				font-size: 0.9em;
+
+				&:disabled {
+					opacity: 0.7;
+				}
+
+				> i {
+					margin-left: 6px;
+				}
+			}
+		}
+	}
+
+	> .form {
+		> .preview {
+			padding: 16px;
+		}
+
+		> .with-quote {
+			margin: 0 0 8px 0;
+			color: var(--accent);
+
+			> button {
+				padding: 4px 8px;
+				color: var(--accentAlpha04);
+
+				&:hover {
+					color: var(--accentAlpha06);
+				}
+
+				&:active {
+					color: var(--accentDarken30);
+				}
+			}
+		}
+
+		> .to-specified {
+			padding: 6px 24px;
+			margin-bottom: 8px;
+			overflow: auto;
+			white-space: nowrap;
+
+			> .visibleUsers {
+				display: inline;
+				top: -1px;
+				font-size: 14px;
+
+				> button {
+					padding: 4px;
+					border-radius: 8px;
+				}
+
+				> span {
+					margin-right: 14px;
+					padding: 8px 0 8px 8px;
+					border-radius: 8px;
+					background: var(--X4);
+
+					> button {
+						padding: 4px 8px;
+					}
+				}
+			}
+		}
+
+		> .hasNotSpecifiedMentions {
+			margin: 0 20px 16px 20px;
+		}
+
+		> .cw,
+		> .hashtags,
+		> .text {
+			display: block;
+			box-sizing: border-box;
+			padding: 0 24px;
+			margin: 0;
+			width: 100%;
+			font-size: 16px;
+			border: none;
+			border-radius: 0;
+			background: transparent;
+			color: var(--fg);
+			font-family: inherit;
+
+			&:focus {
+				outline: none;
+			}
+
+			&:disabled {
+				opacity: 0.5;
+			}
+		}
+
+		> .cw {
+			z-index: 1;
+			padding-bottom: 8px;
+			border-bottom: solid 0.5px var(--divider);
+		}
+
+		> .hashtags {
+			z-index: 1;
+			padding-top: 8px;
+			padding-bottom: 8px;
+			border-top: solid 0.5px var(--divider);
+		}
+
+		> .text {
+			max-width: 100%;
+			min-width: 100%;
+			min-height: 90px;
+
+			&.withCw {
+				padding-top: 8px;
+			}
+		}
+
+		> footer {
+			padding: 0 16px 16px 16px;
+
+			> button {
+				display: inline-block;
+				padding: 0;
+				margin: 0;
+				font-size: 1em;
+				width: 46px;
+				height: 46px;
+				border-radius: 6px;
+
+				&:hover {
+					background: var(--X5);
+				}
+
+				&.active {
+					color: var(--accent);
+				}
+			}
+		}
+	}
+
+	&.max-width_500px {
+		> header {
+			height: 50px;
+
+			> .cancel {
+				width: 50px;
+				line-height: 50px;
+			}
+
+			> .right {
+				> .text-count {
+					line-height: 50px;
+				}
+
+				> .submit {
+					margin: 8px;
+				}
+			}
+		}
+
+		> .form {
+			> .to-specified {
+				padding: 6px 16px;
+			}
+
+			> .cw,
+			> .hashtags,
+			> .text {
+				padding: 0 16px;
+			}
+
+			> .text {
+				min-height: 80px;
+			}
+
+			> footer {
+				padding: 0 8px 8px 8px;
+			}
+		}
+	}
+
+	&.max-width_310px {
+		> .form {
+			> footer {
+				> button {
+					font-size: 14px;
+					width: 44px;
+				height: 44px;
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 500px) {
+	.gafaadew {
+		> header {
+			height: 50px;
+
+			> .cancel {
+				width: 50px;
+				line-height: 50px;
+			}
+
+			> .right {
+				> .text-count {
+					line-height: 50px;
+				}
+
+				> .submit {
+					margin: 8px;
+				}
+			}
+		}
+
+		> .form {
+			> .to-specified {
+				padding: 6px 16px;
+			}
+
+			> .cw,
+			> .hashtags,
+			> .text {
+				padding: 0 16px;
+			}
+
+			> .text {
+				min-height: 80px;
+			}
+
+			> footer {
+				padding: 0 8px 8px 8px;
+			}
+		}
+	}
+}
+
+@container (max-width: 310px) {
+	.gafaadew {
+		> .form {
+			> footer {
+				> button {
+					font-size: 14px;
+					width: 44px;
+					height: 44px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
new file mode 100644
index 0000000000..766cc9a06c
--- /dev/null
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -0,0 +1,168 @@
+<template>
+<div v-show="props.modelValue.length != 0" class="skeikyzd">
+	<Sortable :model-value="props.modelValue" class="files" item-key="id" :animation="150" :delay="100" :delay-on-touch-only="true" @update:model-value="v => emit('update:modelValue', v)">
+		<template #item="{element}">
+			<div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
+				<MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
+				<div v-if="element.isSensitive" class="sensitive">
+					<i class="ti ti-alert-triangle icon"></i>
+				</div>
+			</div>
+		</template>
+	</Sortable>
+	<p class="remain">{{ 16 - props.modelValue.length }}/16</p>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, watch } from 'vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+import { i18n } from '@/i18n';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+const props = defineProps<{
+	modelValue: any[];
+	detachMediaFn: () => void;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any[]): void;
+	(ev: 'detach'): void;
+	(ev: 'changeSensitive'): void;
+	(ev: 'changeName'): void;
+}>();
+
+let menuShowing = false;
+
+function detachMedia(id) {
+	if (props.detachMediaFn) {
+		props.detachMediaFn(id);
+	} else {
+		emit('detach', id);
+	}
+}
+
+function toggleSensitive(file) {
+	os.api('drive/files/update', {
+		fileId: file.id,
+		isSensitive: !file.isSensitive,
+	}).then(() => {
+		emit('changeSensitive', file, !file.isSensitive);
+	});
+}
+async function rename(file) {
+	const { canceled, result } = await os.inputText({
+		title: i18n.ts.enterFileName,
+		default: file.name,
+		allowEmpty: false,
+	});
+	if (canceled) return;
+	os.api('drive/files/update', {
+		fileId: file.id,
+		name: result,
+	}).then(() => {
+		emit('changeName', file, result);
+		file.name = result;
+	});
+}
+
+async function describe(file) {
+	os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
+		default: file.comment !== null ? file.comment : '',
+		file: file,
+	}, {
+		done: caption => {
+			let comment = caption.length === 0 ? null : caption;
+			os.api('drive/files/update', {
+				fileId: file.id,
+				comment: comment,
+			}).then(() => {
+				file.comment = comment;
+			});
+		},
+	}, 'closed');
+}
+
+function showFileMenu(file, ev: MouseEvent) {
+	if (menuShowing) return;
+	os.popupMenu([{
+		text: i18n.ts.renameFile,
+		icon: 'ti ti-forms',
+		action: () => { rename(file); },
+	}, {
+		text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
+		icon: file.isSensitive ? 'ti ti-eye-off' : 'ti ti-eye',
+		action: () => { toggleSensitive(file); },
+	}, {
+		text: i18n.ts.describeFile,
+		icon: 'ti ti-text-caption',
+		action: () => { describe(file); },
+	}, {
+		text: i18n.ts.attachCancel,
+		icon: 'ti ti-circle-x',
+		action: () => { detachMedia(file.id); },
+	}], ev.currentTarget ?? ev.target).then(() => menuShowing = false);
+	menuShowing = true;
+}
+</script>
+
+<style lang="scss" scoped>
+.skeikyzd {
+	padding: 8px 16px;
+	position: relative;
+
+	> .files {
+		display: flex;
+		flex-wrap: wrap;
+
+		> .file {
+			position: relative;
+			width: 64px;
+			height: 64px;
+			margin-right: 4px;
+			border-radius: 4px;
+			overflow: hidden;
+			cursor: move;
+
+			&:hover > .remove {
+				display: block;
+			}
+
+			> .thumbnail {
+				width: 100%;
+				height: 100%;
+				z-index: 1;
+				color: var(--fg);
+			}
+
+			> .sensitive {
+				display: flex;
+				position: absolute;
+				width: 64px;
+				height: 64px;
+				top: 0;
+				left: 0;
+				z-index: 2;
+				background: rgba(17, 17, 17, .7);
+				color: #fff;
+
+				> .icon {
+					margin: auto;
+				}
+			}
+		}
+	}
+
+	> .remain {
+		display: block;
+		position: absolute;
+		top: 8px;
+		right: 8px;
+		margin: 0;
+		padding: 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
new file mode 100644
index 0000000000..6dabb1db14
--- /dev/null
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -0,0 +1,19 @@
+<template>
+<MkModal ref="modal" :prefer-type="'dialog:top'" @click="$refs.modal.close()" @closed="$emit('closed')">
+	<MkPostForm v-bind="$attrs" @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()"/>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import MkPostForm from '@/components/MkPostForm.vue';
+
+export default defineComponent({
+	components: {
+		MkModal,
+		MkPostForm,
+	},
+	emits: ['closed'],
+});
+</script>
diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
new file mode 100644
index 0000000000..b356fd3b89
--- /dev/null
+++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
@@ -0,0 +1,167 @@
+<template>
+<MkButton
+	v-if="supported && !pushRegistrationInServer"
+	type="button"
+	primary
+	:gradate="gradate"
+	:rounded="rounded"
+	:inline="inline"
+	:autofocus="autofocus"
+	:wait="wait"
+	:full="full"
+	@click="subscribe"
+>
+	{{ i18n.ts.subscribePushNotification }}
+</MkButton>
+<MkButton
+	v-else-if="!showOnlyToRegister && ($i ? pushRegistrationInServer : pushSubscription)"
+	type="button"
+	:primary="false"
+	:gradate="gradate"
+	:rounded="rounded"
+	:inline="inline"
+	:autofocus="autofocus"
+	:wait="wait"
+	:full="full"
+	@click="unsubscribe"
+>
+	{{ i18n.ts.unsubscribePushNotification }}
+</MkButton>
+<MkButton v-else-if="$i && pushRegistrationInServer" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full">
+	{{ i18n.ts.pushNotificationAlreadySubscribed }}
+</MkButton>
+<MkButton v-else-if="!supported" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full">
+	{{ i18n.ts.pushNotificationNotSupported }}
+</MkButton>
+</template>
+
+<script setup lang="ts">
+import { $i, getAccounts } from '@/account';
+import MkButton from '@/components/MkButton.vue';
+import { instance } from '@/instance';
+import { api, apiWithDialog, promiseDialog } from '@/os';
+import { i18n } from '@/i18n';
+
+defineProps<{
+	primary?: boolean;
+	gradate?: boolean;
+	rounded?: boolean;
+	inline?: boolean;
+	link?: boolean;
+	to?: string;
+	autofocus?: boolean;
+	wait?: boolean;
+	danger?: boolean;
+	full?: boolean;
+	showOnlyToRegister?: boolean;
+}>();
+
+// ServiceWorker registration
+let registration = $ref<ServiceWorkerRegistration | undefined>();
+// If this browser supports push notification
+let supported = $ref(false);
+// If this browser has already subscribed to push notification
+let pushSubscription = $ref<PushSubscription | null>(null);
+let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>();
+
+function subscribe() {
+	if (!registration || !supported || !instance.swPublickey) return;
+
+	// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
+	return promiseDialog(registration.pushManager.subscribe({
+		userVisibleOnly: true,
+		applicationServerKey: urlBase64ToUint8Array(instance.swPublickey),
+	})
+	.then(async subscription => {
+		pushSubscription = subscription;
+
+		// Register
+		pushRegistrationInServer = await api('sw/register', {
+			endpoint: subscription.endpoint,
+			auth: encode(subscription.getKey('auth')),
+			publickey: encode(subscription.getKey('p256dh')),
+		});
+	}, async err => { // When subscribe failed
+		// 通知が許可されていなかったとき
+		if (err?.name === 'NotAllowedError') {
+			console.info('User denied the notification permission request.');
+			return;
+		}
+
+		// 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
+		// 既に存在していることが原因でエラーになった可能性があるので、
+		// そのサブスクリプションを解除しておく
+		// （これは実行されなさそうだけど、おまじない的に古い実装から残してある）
+		await unsubscribe();
+	}), null, null);
+}
+
+async function unsubscribe() {
+	if (!pushSubscription) return;
+
+	const endpoint = pushSubscription.endpoint;
+	const accounts = await getAccounts();
+
+	pushRegistrationInServer = undefined;
+
+	if ($i && accounts.length >= 2) {
+		apiWithDialog('sw/unregister', {
+			i: $i.token,
+			endpoint,
+		});
+	} else {
+		pushSubscription.unsubscribe();
+		apiWithDialog('sw/unregister', {
+			endpoint,
+		});
+		pushSubscription = null;
+	}
+}
+
+function encode(buffer: ArrayBuffer | null) {
+	return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
+}
+
+/**
+ * Convert the URL safe base64 string to a Uint8Array
+ * @param base64String base64 string
+ */
+ function urlBase64ToUint8Array(base64String: string): Uint8Array {
+	const padding = '='.repeat((4 - base64String.length % 4) % 4);
+	const base64 = (base64String + padding)
+		.replace(/-/g, '+')
+		.replace(/_/g, '/');
+
+	const rawData = window.atob(base64);
+	const outputArray = new Uint8Array(rawData.length);
+
+	for (let i = 0; i < rawData.length; ++i) {
+		outputArray[i] = rawData.charCodeAt(i);
+	}
+	return outputArray;
+}
+
+navigator.serviceWorker.ready.then(async swr => {
+	registration = swr;
+
+	pushSubscription = await registration.pushManager.getSubscription();
+
+	if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) {
+		supported = true;
+
+		if (pushSubscription) {
+			const res = await api('sw/show-registration', {
+				endpoint: pushSubscription.endpoint,
+			});
+
+			if (res) {
+				pushRegistrationInServer = res;
+			}
+		}
+	}
+});
+
+defineExpose({
+	pushRegistrationInServer: $$(pushRegistrationInServer),
+});
+</script>
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
new file mode 100644
index 0000000000..5638c9a816
--- /dev/null
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -0,0 +1,13 @@
+<template>
+<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	reaction: string;
+	customEmojis?: any[]; // TODO
+	noStyle?: boolean;
+}>();
+</script>
diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue
new file mode 100644
index 0000000000..310d5954fc
--- /dev/null
+++ b/packages/frontend/src/components/MkReactionTooltip.vue
@@ -0,0 +1,43 @@
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
+	<div class="beeadbfb">
+		<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+		<div class="name">{{ reaction.replace('@.', '') }}</div>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from './MkTooltip.vue';
+import XReactionIcon from '@/components/MkReactionIcon.vue';
+
+defineProps<{
+	showing: boolean;
+	reaction: string;
+	emojis: any[]; // TODO
+	targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.beeadbfb {
+	text-align: center;
+
+	> .icon {
+		display: block;
+		width: 60px;
+		font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
+		margin: 0 auto;
+		object-fit: contain;
+	}
+
+	> .name {
+		font-size: 0.9em;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue
new file mode 100644
index 0000000000..29902a5075
--- /dev/null
+++ b/packages/frontend/src/components/MkReactionsViewer.details.vue
@@ -0,0 +1,96 @@
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
+	<div class="bqxuuuey">
+		<div class="reaction">
+			<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+			<div class="name">{{ getReactionName(reaction) }}</div>
+		</div>
+		<div class="users">
+			<div v-for="u in users" :key="u.id" class="user">
+				<MkAvatar class="avatar" :user="u"/>
+				<MkUserName class="name" :user="u" :nowrap="true"/>
+			</div>
+			<div v-if="users.length > 10" class="omitted">+{{ count - 10 }}</div>
+		</div>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from './MkTooltip.vue';
+import XReactionIcon from '@/components/MkReactionIcon.vue';
+import { getEmojiName } from '@/scripts/emojilist';
+
+defineProps<{
+	showing: boolean;
+	reaction: string;
+	users: any[]; // TODO
+	count: number;
+	emojis: any[]; // TODO
+	targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+function getReactionName(reaction: string): string {
+	const trimLocal = reaction.replace('@.', '');
+	if (trimLocal.startsWith(':')) {
+		return trimLocal;
+	}
+	return getEmojiName(reaction) ?? reaction;
+}
+</script>
+
+<style lang="scss" scoped>
+.bqxuuuey {
+	display: flex;
+
+	> .reaction {
+		max-width: 100px;
+		text-align: center;
+
+		> .icon {
+			display: block;
+			width: 60px;
+			font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
+			object-fit: contain;
+			margin: 0 auto;
+		}
+
+		> .name {
+			font-size: 1em;
+		}
+	}
+
+	> .users {
+		flex: 1;
+		min-width: 0;
+		font-size: 0.95em;
+		border-left: solid 0.5px var(--divider);
+		padding-left: 10px;
+		margin-left: 10px;
+		margin-right: 14px;
+		text-align: left;
+
+		> .user {
+			line-height: 24px;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+
+			&:not(:last-child) {
+				margin-bottom: 3px;
+			}
+
+			> .avatar {
+				width: 24px;
+				height: 24px;
+				margin-right: 3px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
new file mode 100644
index 0000000000..31342b0b48
--- /dev/null
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -0,0 +1,135 @@
+<template>
+<button
+	v-if="count > 0"
+	ref="buttonRef"
+	v-ripple="canToggle"
+	class="hkzvhatu _button"
+	:class="{ reacted: note.myReaction == reaction, canToggle }"
+	@click="toggleReaction()"
+>
+	<XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/>
+	<span class="count">{{ count }}</span>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import XDetails from '@/components/MkReactionsViewer.details.vue';
+import XReactionIcon from '@/components/MkReactionIcon.vue';
+import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { $i } from '@/account';
+
+const props = defineProps<{
+	reaction: string;
+	count: number;
+	isInitial: boolean;
+	note: misskey.entities.Note;
+}>();
+
+const buttonRef = ref<HTMLElement>();
+
+const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
+
+const toggleReaction = () => {
+	if (!canToggle.value) return;
+
+	const oldReaction = props.note.myReaction;
+	if (oldReaction) {
+		os.api('notes/reactions/delete', {
+			noteId: props.note.id,
+		}).then(() => {
+			if (oldReaction !== props.reaction) {
+				os.api('notes/reactions/create', {
+					noteId: props.note.id,
+					reaction: props.reaction,
+				});
+			}
+		});
+	} else {
+		os.api('notes/reactions/create', {
+			noteId: props.note.id,
+			reaction: props.reaction,
+		});
+	}
+};
+
+const anime = () => {
+	if (document.hidden) return;
+
+	// TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション
+};
+
+watch(() => props.count, (newCount, oldCount) => {
+	if (oldCount < newCount) anime();
+});
+
+onMounted(() => {
+	if (!props.isInitial) anime();
+});
+
+useTooltip(buttonRef, async (showing) => {
+	const reactions = await os.apiGet('notes/reactions', {
+		noteId: props.note.id,
+		type: props.reaction,
+		limit: 11,
+		_cacheKey_: props.count,
+	});
+
+	const users = reactions.map(x => x.user);
+
+	os.popup(XDetails, {
+		showing,
+		reaction: props.reaction,
+		emojis: props.note.emojis,
+		users,
+		count: props.count,
+		targetElement: buttonRef.value,
+	}, {}, 'closed');
+}, 100);
+</script>
+
+<style lang="scss" scoped>
+.hkzvhatu {
+	display: inline-block;
+	height: 32px;
+	margin: 2px;
+	padding: 0 6px;
+	border-radius: 4px;
+
+	&.canToggle {
+		background: rgba(0, 0, 0, 0.05);
+
+		&:hover {
+			background: rgba(0, 0, 0, 0.1);
+		}
+	}
+
+	&:not(.canToggle) {
+		cursor: default;
+	}
+
+	&.reacted {
+		background: var(--accent);
+
+		&:hover {
+			background: var(--accent);
+		}
+
+		> .count {
+			color: var(--fgOnAccent);
+		}
+
+		> .icon {
+			filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
+		}
+	}
+
+	> .count {
+		font-size: 0.9em;
+		line-height: 32px;
+		margin: 0 0 0 4px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
new file mode 100644
index 0000000000..a88311efa1
--- /dev/null
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -0,0 +1,36 @@
+<template>
+<div class="tdflqwzn" :class="{ isMe }">
+	<XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import { $i } from '@/account';
+import XReaction from '@/components/MkReactionsViewer.reaction.vue';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+}>();
+
+const initialReactions = new Set(Object.keys(props.note.reactions));
+
+const isMe = computed(() => $i && $i.id === props.note.userId);
+</script>
+
+<style lang="scss" scoped>
+.tdflqwzn {
+	margin: 4px -2px 0 -2px;
+
+	&:empty {
+		display: none;
+	}
+
+	&.isMe {
+		> span {
+			cursor: default !important;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue
new file mode 100644
index 0000000000..d5dc01c1f8
--- /dev/null
+++ b/packages/frontend/src/components/MkRemoteCaution.vue
@@ -0,0 +1,25 @@
+<template>
+<div class="jmgmzlwq _block"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from '@/i18n';
+
+defineProps<{
+	href: string;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.jmgmzlwq {
+	font-size: 0.8em;
+	padding: 16px;
+	background: var(--infoWarnBg);
+	color: var(--infoWarnFg);
+
+	> .link {
+		margin-left: 4px;
+		color: var(--accent);
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkRenoteButton.vue b/packages/frontend/src/components/MkRenoteButton.vue
new file mode 100644
index 0000000000..e0b1eaafc9
--- /dev/null
+++ b/packages/frontend/src/components/MkRenoteButton.vue
@@ -0,0 +1,99 @@
+<template>
+<button
+	v-if="canRenote"
+	ref="buttonRef"
+	class="eddddedb _button canRenote"
+	@click="renote()"
+>
+	<i class="ti ti-repeat"></i>
+	<p v-if="count > 0" class="count">{{ count }}</p>
+</button>
+<button v-else class="eddddedb _button">
+	<i class="ti ti-ban"></i>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import * as misskey from 'misskey-js';
+import XDetails from '@/components/MkUsersTooltip.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+	count: number;
+}>();
+
+const buttonRef = ref<HTMLElement>();
+
+const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
+
+useTooltip(buttonRef, async (showing) => {
+	const renotes = await os.api('notes/renotes', {
+		noteId: props.note.id,
+		limit: 11,
+	});
+
+	const users = renotes.map(x => x.user);
+
+	if (users.length < 1) return;
+
+	os.popup(XDetails, {
+		showing,
+		users,
+		count: props.count,
+		targetElement: buttonRef.value,
+	}, {}, 'closed');
+});
+
+const renote = (viaKeyboard = false) => {
+	pleaseLogin();
+	os.popupMenu([{
+		text: i18n.ts.renote,
+		icon: 'ti ti-repeat',
+		action: () => {
+			os.api('notes/create', {
+				renoteId: props.note.id,
+			});
+		},
+	}, {
+		text: i18n.ts.quote,
+		icon: 'ti ti-quote',
+		action: () => {
+			os.post({
+				renote: props.note,
+			});
+		},
+	}], buttonRef.value, {
+		viaKeyboard,
+	});
+};
+</script>
+
+<style lang="scss" scoped>
+.eddddedb {
+	display: inline-block;
+	height: 32px;
+	margin: 2px;
+	padding: 0 6px;
+	border-radius: 4px;
+
+	&:not(.canRenote) {
+		cursor: default;
+	}
+
+	&.renoted {
+		background: var(--accent);
+	}
+
+	> .count {
+		display: inline;
+		margin-left: 8px;
+		opacity: 0.7;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkRipple.vue b/packages/frontend/src/components/MkRipple.vue
new file mode 100644
index 0000000000..9d93211d5f
--- /dev/null
+++ b/packages/frontend/src/components/MkRipple.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
+	<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
+		<circle fill="none" cx="64" cy="64">
+			<animate
+				attributeName="r"
+				begin="0s" dur="0.5s"
+				values="4; 32"
+				calcMode="spline"
+				keyTimes="0; 1"
+				keySplines="0.165, 0.84, 0.44, 1"
+				repeatCount="1"
+			/>
+			<animate
+				attributeName="stroke-width"
+				begin="0s" dur="0.5s"
+				values="16; 0"
+				calcMode="spline"
+				keyTimes="0; 1"
+				keySplines="0.3, 0.61, 0.355, 1"
+				repeatCount="1"
+			/>
+		</circle>
+		<g fill="none" fill-rule="evenodd">
+			<circle v-for="(particle, i) in particles" :key="i" :fill="particle.color">
+				<animate
+					attributeName="r"
+					begin="0s" dur="0.8s"
+					:values="`${particle.size}; 0`"
+					calcMode="spline"
+					keyTimes="0; 1"
+					keySplines="0.165, 0.84, 0.44, 1"
+					repeatCount="1"
+				/>
+				<animate
+					attributeName="cx"
+					begin="0s" dur="0.8s"
+					:values="`${particle.xA}; ${particle.xB}`"
+					calcMode="spline"
+					keyTimes="0; 1"
+					keySplines="0.3, 0.61, 0.355, 1"
+					repeatCount="1"
+				/>
+				<animate
+					attributeName="cy"
+					begin="0s" dur="0.8s"
+					:values="`${particle.yA}; ${particle.yB}`"
+					calcMode="spline"
+					keyTimes="0; 1"
+					keySplines="0.3, 0.61, 0.355, 1"
+					repeatCount="1"
+				/>
+			</circle>
+		</g>
+	</svg>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+	x: number;
+	y: number;
+	particle?: boolean;
+}>(), {
+	particle: true,
+});
+
+const emit = defineEmits<{
+	(ev: 'end'): void;
+}>();
+
+const particles = [];
+const origin = 64;
+const colors = ['#FF1493', '#00FFFF', '#FFE202'];
+const zIndex = os.claimZIndex('high');
+
+if (props.particle) {
+	for (let i = 0; i < 12; i++) {
+		const angle = Math.random() * (Math.PI * 2);
+		const pos = Math.random() * 16;
+		const velocity = 16 + (Math.random() * 48);
+		particles.push({
+			size: 4 + (Math.random() * 8),
+			xA: origin + (Math.sin(angle) * pos),
+			yA: origin + (Math.cos(angle) * pos),
+			xB: origin + (Math.sin(angle) * (pos + velocity)),
+			yB: origin + (Math.cos(angle) * (pos + velocity)),
+			color: colors[Math.floor(Math.random() * colors.length)],
+		});
+	}
+}
+
+onMounted(() => {
+	window.setTimeout(() => {
+		emit('end');
+	}, 1100);
+});
+</script>
+
+<style lang="scss" scoped>
+.vswabwbm {
+	pointer-events: none;
+	position: fixed;
+	width: 128px;
+	height: 128px;
+
+	> svg {
+		> circle {
+			stroke: var(--accent);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue
new file mode 100644
index 0000000000..1d25ab54b5
--- /dev/null
+++ b/packages/frontend/src/components/MkSample.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="_card">
+	<div class="_content">
+		<MkInput v-model="text">
+			<template #label>Text</template>
+		</MkInput>
+		<MkSwitch v-model="flag">
+			<span>Switch is now {{ flag ? 'on' : 'off' }}</span>
+		</MkSwitch>
+		<div style="margin: 32px 0;">
+			<MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
+			<MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
+			<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
+		</div>
+		<MkButton inline>This is</MkButton>
+		<MkButton inline primary>the button</MkButton>
+	</div>
+	<div class="_content" style="pointer-events: none;">
+		<Mfm :text="mfm"/>
+	</div>
+	<div class="_content">
+		<MkButton inline primary @click="openMenu">Open menu</MkButton>
+		<MkButton inline primary @click="openDialog">Open dialog</MkButton>
+		<MkButton inline primary @click="openForm">Open form</MkButton>
+		<MkButton inline primary @click="openDrive">Open drive</MkButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as config from '@/config';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkInput,
+		MkSwitch,
+		MkTextarea,
+		MkRadio,
+	},
+
+	data() {
+		return {
+			text: '',
+			flag: true,
+			radio: 'misskey',
+			mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`,
+		};
+	},
+
+	methods: {
+		async openDialog() {
+			os.alert({
+				type: 'warning',
+				title: 'Oh my Aichan',
+				text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+			});
+		},
+
+		async openForm() {
+			os.form('Example form', {
+				foo: {
+					type: 'boolean',
+					default: true,
+					label: 'This is a boolean property',
+				},
+				bar: {
+					type: 'number',
+					default: 300,
+					label: 'This is a number property',
+				},
+				baz: {
+					type: 'string',
+					default: 'Misskey makes you happy.',
+					label: 'This is a string property',
+				},
+			});
+		},
+
+		async openDrive() {
+			os.selectDriveFile();
+		},
+
+		async selectUser() {
+			os.selectUser();
+		},
+
+		async openMenu(ev) {
+			os.popupMenu([{
+				type: 'label',
+				text: 'Fruits',
+			}, {
+				text: 'Create some apples',
+				action: () => {},
+			}, {
+				text: 'Read some oranges',
+				action: () => {},
+			}, {
+				text: 'Update some melons',
+				action: () => {},
+			}, null, {
+				text: 'Delete some bananas',
+				danger: true,
+				action: () => {},
+			}], ev.currentTarget ?? ev.target);
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
new file mode 100644
index 0000000000..96f18f8d61
--- /dev/null
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -0,0 +1,259 @@
+<template>
+<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
+	<div class="auth _section _formRoot">
+		<div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
+		<MkInfo v-if="message">
+			{{ message }}
+		</MkInfo>
+		<div v-if="!totpLogin" class="normal-signin">
+			<MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:model-value="onUsernameChange">
+				<template #prefix>@</template>
+				<template #suffix>@{{ host }}</template>
+			</MkInput>
+			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" class="_formBlock" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
+				<template #prefix><i class="ti ti-lock"></i></template>
+				<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
+			</MkInput>
+			<MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
+		</div>
+		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
+			<div v-if="user && user.securityKeys" class="twofa-group tap-group">
+				<p>{{ i18n.ts.tapSecurityKey }}</p>
+				<MkButton v-if="!queryingKey" @click="queryKey">
+					{{ i18n.ts.retry }}
+				</MkButton>
+			</div>
+			<div v-if="user && user.securityKeys" class="or-hr">
+				<p class="or-msg">{{ i18n.ts.or }}</p>
+			</div>
+			<div class="twofa-group totp-group">
+				<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
+				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required>
+					<template #label>{{ i18n.ts.password }}</template>
+					<template #prefix><i class="ti ti-lock"></i></template>
+				</MkInput>
+				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required>
+					<template #label>{{ i18n.ts.token }}</template>
+					<template #prefix><i class="ti ti-123"></i></template>
+				</MkInput>
+				<MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
+			</div>
+		</div>
+	</div>
+	<div class="social _section">
+		<a v-if="meta && meta.enableTwitterIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/twitter`"><i class="ti ti-brand-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a>
+		<a v-if="meta && meta.enableGithubIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/github`"><i class="ti ti-brand-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a>
+		<a v-if="meta && meta.enableDiscordIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/discord`"><i class="ti ti-brand-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a>
+	</div>
+</form>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { apiUrl, host as configHost } from '@/config';
+import { byteify, hexify } from '@/scripts/2fa';
+import * as os from '@/os';
+import { login } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let signing = $ref(false);
+let user = $ref(null);
+let username = $ref('');
+let password = $ref('');
+let token = $ref('');
+let host = $ref(toUnicode(configHost));
+let totpLogin = $ref(false);
+let credential = $ref(null);
+let challengeData = $ref(null);
+let queryingKey = $ref(false);
+let hCaptchaResponse = $ref(null);
+let reCaptchaResponse = $ref(null);
+
+const meta = $computed(() => instance);
+
+const emit = defineEmits<{
+	(ev: 'login', v: any): void;
+}>();
+
+const props = defineProps({
+	withAvatar: {
+		type: Boolean,
+		required: false,
+		default: true,
+	},
+	autoSet: {
+		type: Boolean,
+		required: false,
+		default: false,
+	},
+	message: {
+		type: String,
+		required: false,
+		default: '',
+	},
+});
+
+function onUsernameChange() {
+	os.api('users/show', {
+		username: username,
+	}).then(userResponse => {
+		user = userResponse;
+	}, () => {
+		user = null;
+	});
+}
+
+function onLogin(res) {
+	if (props.autoSet) {
+		return login(res.i);
+	}
+}
+
+function queryKey() {
+	queryingKey = true;
+	return navigator.credentials.get({
+		publicKey: {
+			challenge: byteify(challengeData.challenge, 'base64'),
+			allowCredentials: challengeData.securityKeys.map(key => ({
+				id: byteify(key.id, 'hex'),
+				type: 'public-key',
+				transports: ['usb', 'nfc', 'ble', 'internal'],
+			})),
+			timeout: 60 * 1000,
+		},
+	}).catch(() => {
+		queryingKey = false;
+		return Promise.reject(null);
+	}).then(credential => {
+		queryingKey = false;
+		signing = true;
+		return os.api('signin', {
+			username,
+			password,
+			signature: hexify(credential.response.signature),
+			authenticatorData: hexify(credential.response.authenticatorData),
+			clientDataJSON: hexify(credential.response.clientDataJSON),
+			credentialId: credential.id,
+			challengeId: challengeData.challengeId,
+			'hcaptcha-response': hCaptchaResponse,
+			'g-recaptcha-response': reCaptchaResponse,
+		});
+	}).then(res => {
+		emit('login', res);
+		return onLogin(res);
+	}).catch(err => {
+		if (err === null) return;
+		os.alert({
+			type: 'error',
+			text: i18n.ts.signinFailed,
+		});
+		signing = false;
+	});
+}
+
+function onSubmit() {
+	signing = true;
+	console.log('submit');
+	if (!totpLogin && user && user.twoFactorEnabled) {
+		if (window.PublicKeyCredential && user.securityKeys) {
+			os.api('signin', {
+				username,
+				password,
+				'hcaptcha-response': hCaptchaResponse,
+				'g-recaptcha-response': reCaptchaResponse,
+			}).then(res => {
+				totpLogin = true;
+				signing = false;
+				challengeData = res;
+				return queryKey();
+			}).catch(loginFailed);
+		} else {
+			totpLogin = true;
+			signing = false;
+		}
+	} else {
+		os.api('signin', {
+			username,
+			password,
+			'hcaptcha-response': hCaptchaResponse,
+			'g-recaptcha-response': reCaptchaResponse,
+			token: user && user.twoFactorEnabled ? token : undefined,
+		}).then(res => {
+			emit('login', res);
+			onLogin(res);
+		}).catch(loginFailed);
+	}
+}
+
+function loginFailed(err) {
+	switch (err.id) {
+		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
+			os.alert({
+				type: 'error',
+				title: i18n.ts.loginFailed,
+				text: i18n.ts.noSuchUser,
+			});
+			break;
+		}
+		case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': {
+			os.alert({
+				type: 'error',
+				title: i18n.ts.loginFailed,
+				text: i18n.ts.incorrectPassword,
+			});
+			break;
+		}
+		case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
+			showSuspendedDialog();
+			break;
+		}
+		case '22d05606-fbcf-421a-a2db-b32610dcfd1b': {
+			os.alert({
+				type: 'error',
+				title: i18n.ts.loginFailed,
+				text: i18n.ts.rateLimitExceeded,
+			});
+			break;
+		}
+		default: {
+			console.log(err);
+			os.alert({
+				type: 'error',
+				title: i18n.ts.loginFailed,
+				text: JSON.stringify(err),
+			});
+		}
+	}
+
+	challengeData = null;
+	totpLogin = false;
+	signing = false;
+}
+
+function resetPassword() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
+	}, 'closed');
+}
+</script>
+
+<style lang="scss" scoped>
+.eppvobhk {
+	> .auth {
+		> .avatar {
+			margin: 0 auto 0 auto;
+			width: 64px;
+			height: 64px;
+			background: #ddd;
+			background-position: center;
+			background-size: cover;
+			border-radius: 100%;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
new file mode 100644
index 0000000000..fd27244516
--- /dev/null
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -0,0 +1,46 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="370"
+	:height="400"
+	@close="onClose"
+	@closed="emit('closed')"
+>
+	<template #header>{{ i18n.ts.login }}</template>
+
+	<MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkSignin from '@/components/MkSignin.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	autoSet?: boolean;
+	message?: string,
+}>(), {
+	autoSet: false,
+	message: '',
+});
+
+const emit = defineEmits<{
+	(ev: 'done'): void;
+	(ev: 'closed'): void;
+	(ev: 'cancelled'): void;
+}>();
+
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
+
+function onClose() {
+	emit('cancelled');
+	dialog.close();
+}
+
+function onLogin(res) {
+	emit('done', res);
+	dialog.close();
+}
+</script>
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
new file mode 100644
index 0000000000..d987425ca3
--- /dev/null
+++ b/packages/frontend/src/components/MkSignup.vue
@@ -0,0 +1,246 @@
+<template>
+<form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit">
+	<MkInput v-if="instance.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required>
+		<template #label>{{ i18n.ts.invitationCode }}</template>
+		<template #prefix><i class="ti ti-key"></i></template>
+	</MkInput>
+	<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
+		<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
+		<template #prefix>@</template>
+		<template #suffix>@{{ host }}</template>
+		<template #caption>
+			<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+			<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+			<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+			<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+			<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
+			<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
+			<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
+		</template>
+	</MkInput>
+	<MkInput v-if="instance.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
+		<template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
+		<template #prefix><i class="ti ti-mail"></i></template>
+		<template #caption>
+			<span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+			<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+			<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
+			<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
+			<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
+			<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
+			<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
+			<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+			<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+		</template>
+	</MkInput>
+	<MkInput v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
+		<template #label>{{ i18n.ts.password }}</template>
+		<template #prefix><i class="ti ti-lock"></i></template>
+		<template #caption>
+			<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
+			<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
+			<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
+		</template>
+	</MkInput>
+	<MkInput v-model="retypedPassword" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
+		<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
+		<template #prefix><i class="ti ti-lock"></i></template>
+		<template #caption>
+			<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
+			<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
+		</template>
+	</MkInput>
+	<MkSwitch v-if="instance.tosUrl" v-model="ToSAgreement" class="_formBlock tou">
+		<I18n :src="i18n.ts.agreeTo">
+			<template #0>
+				<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a>
+			</template>
+		</I18n>
+	</MkSwitch>
+	<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
+	<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
+	<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="_formBlock captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+	<MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
+</form>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import getPasswordStrength from 'syuilo-password-strength';
+import { toUnicode } from 'punycode/';
+import MkButton from './MkButton.vue';
+import MkInput from './form/input.vue';
+import MkSwitch from './form/switch.vue';
+import MkCaptcha from '@/components/MkCaptcha.vue';
+import * as config from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	autoSet?: boolean;
+}>(), {
+	autoSet: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'signup', user: Record<string, any>): void;
+	(ev: 'signupEmailPending'): void;
+}>();
+
+const host = toUnicode(config.host);
+
+let hcaptcha = $ref();
+let recaptcha = $ref();
+let turnstile = $ref();
+
+let username: string = $ref('');
+let password: string = $ref('');
+let retypedPassword: string = $ref('');
+let invitationCode: string = $ref('');
+let email = $ref('');
+let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
+let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
+let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
+let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
+let submitting: boolean = $ref(false);
+let ToSAgreement: boolean = $ref(false);
+let hCaptchaResponse = $ref(null);
+let reCaptchaResponse = $ref(null);
+let turnstileResponse = $ref(null);
+
+const shouldDisableSubmitting = $computed((): boolean => {
+	return submitting ||
+		instance.tosUrl && !ToSAgreement ||
+		instance.enableHcaptcha && !hCaptchaResponse ||
+		instance.enableRecaptcha && !reCaptchaResponse ||
+		instance.enableTurnstile && !turnstileResponse ||
+		passwordRetypeState === 'not-match';
+});
+
+function onChangeUsername(): void {
+	if (username === '') {
+		usernameState = null;
+		return;
+	}
+
+	{
+		const err =
+			!username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
+			username.length < 1 ? 'min-range' :
+			username.length > 20 ? 'max-range' :
+			null;
+
+		if (err) {
+			usernameState = err;
+			return;
+		}
+	}
+
+	usernameState = 'wait';
+
+	os.api('username/available', {
+		username,
+	}).then(result => {
+		usernameState = result.available ? 'ok' : 'unavailable';
+	}).catch(() => {
+		usernameState = 'error';
+	});
+}
+
+function onChangeEmail(): void {
+	if (email === '') {
+		emailState = null;
+		return;
+	}
+
+	emailState = 'wait';
+
+	os.api('email-address/available', {
+		emailAddress: email,
+	}).then(result => {
+		emailState = result.available ? 'ok' :
+			result.reason === 'used' ? 'unavailable:used' :
+			result.reason === 'format' ? 'unavailable:format' :
+			result.reason === 'disposable' ? 'unavailable:disposable' :
+			result.reason === 'mx' ? 'unavailable:mx' :
+			result.reason === 'smtp' ? 'unavailable:smtp' :
+			'unavailable';
+	}).catch(() => {
+		emailState = 'error';
+	});
+}
+
+function onChangePassword(): void {
+	if (password === '') {
+		passwordStrength = '';
+		return;
+	}
+
+	const strength = getPasswordStrength(password);
+	passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
+}
+
+function onChangePasswordRetype(): void {
+	if (retypedPassword === '') {
+		passwordRetypeState = null;
+		return;
+	}
+
+	passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
+}
+
+function onSubmit(): void {
+	if (submitting) return;
+	submitting = true;
+
+	os.api('signup', {
+		username,
+		password,
+		emailAddress: email,
+		invitationCode,
+		'hcaptcha-response': hCaptchaResponse,
+		'g-recaptcha-response': reCaptchaResponse,
+		'turnstile-response': turnstileResponse,
+	}).then(() => {
+		if (instance.emailRequiredForSignup) {
+			os.alert({
+				type: 'success',
+				title: i18n.ts._signup.almostThere,
+				text: i18n.t('_signup.emailSent', { email }),
+			});
+			emit('signupEmailPending');
+		} else {
+			os.api('signin', {
+				username,
+				password,
+			}).then(res => {
+				emit('signup', res);
+
+				if (props.autoSet) {
+					login(res.i);
+				}
+			});
+		}
+	}).catch(() => {
+		submitting = false;
+		hcaptcha.reset?.();
+		recaptcha.reset?.();
+		turnstile.reset?.();
+
+		os.alert({
+			type: 'error',
+			text: i18n.ts.somethingHappened,
+		});
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.qlvuhzng {
+	.captcha {
+		margin: 16px 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
new file mode 100644
index 0000000000..77497021c3
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -0,0 +1,46 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="366"
+	:height="500"
+	@close="dialog.close()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ i18n.ts.signup }}</template>
+
+	<div class="_monolithic_">
+		<div class="_section">
+			<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
+		</div>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XSignup from '@/components/MkSignup.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	autoSet?: boolean;
+}>(), {
+	autoSet: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'done'): void;
+	(ev: 'closed'): void;
+}>();
+
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
+
+function onSignup(res) {
+	emit('done', res);
+	dialog.close();
+}
+
+function onSignupEmailPending() {
+	dialog.close();
+}
+</script>
diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue
new file mode 100644
index 0000000000..cdeaf9c417
--- /dev/null
+++ b/packages/frontend/src/components/MkSparkle.vue
@@ -0,0 +1,130 @@
+<template>
+<span class="mk-sparkle">
+	<span ref="el">
+		<slot></slot>
+	</span>
+	<!-- なぜか path に対する key が機能しないため
+	<svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg">
+		<path v-for="particle in particles" :key="particle.id" style="transform-origin: center; transform-box: fill-box;"
+			:transform="`translate(${particle.x} ${particle.y})`"
+			:fill="particle.color"
+			d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
+		>
+			<animateTransform
+				attributeName="transform"
+				attributeType="XML"
+				type="rotate"
+				from="0 0 0"
+				to="360 0 0"
+				:dur="`${particle.dur}ms`"
+				repeatCount="indefinite"
+				additive="sum"
+			/>
+			<animateTransform
+				attributeName="transform"
+				attributeType="XML"
+				type="scale"
+				:values="`0; ${particle.size}; 0`"
+				:dur="`${particle.dur}ms`"
+				repeatCount="indefinite"
+				additive="sum"
+			/>
+		</path>
+	</svg>
+	-->
+	<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg">
+		<path
+			style="transform-origin: center; transform-box: fill-box;"
+			:transform="`translate(${particle.x} ${particle.y})`"
+			:fill="particle.color"
+			d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
+		>
+			<animateTransform
+				attributeName="transform"
+				attributeType="XML"
+				type="rotate"
+				from="0 0 0"
+				to="360 0 0"
+				:dur="`${particle.dur}ms`"
+				repeatCount="1"
+				additive="sum"
+			/>
+			<animateTransform
+				attributeName="transform"
+				attributeType="XML"
+				type="scale"
+				:values="`0; ${particle.size}; 0`"
+				:dur="`${particle.dur}ms`"
+				repeatCount="1"
+				additive="sum"
+			/>
+		</path>
+	</svg>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+
+const particles = ref([]);
+const el = ref<HTMLElement>();
+const width = ref(0);
+const height = ref(0);
+const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202'];
+let stop = false;
+let ro: ResizeObserver | undefined;
+
+onMounted(() => {
+	ro = new ResizeObserver((entries, observer) => {
+		width.value = el.value?.offsetWidth + 64;
+		height.value = el.value?.offsetHeight + 64;
+	});
+	ro.observe(el.value);
+	const add = () => {
+		if (stop) return;
+		const x = (Math.random() * (width.value - 64));
+		const y = (Math.random() * (height.value - 64));
+		const sizeFactor = Math.random();
+		const particle = {
+			id: Math.random().toString(),
+			x,
+			y,
+			size: 0.2 + ((sizeFactor / 10) * 3),
+			dur: 1000 + (sizeFactor * 1000),
+			color: colors[Math.floor(Math.random() * colors.length)],
+		};
+		particles.value.push(particle);
+		window.setTimeout(() => {
+			particles.value = particles.value.filter(x => x.id !== particle.id);
+		}, particle.dur - 100);
+
+		window.setTimeout(() => {
+			add();
+		}, 500 + (Math.random() * 500));
+	};
+	add();
+});
+
+onUnmounted(() => {
+	if (ro) ro.disconnect();
+	stop = true;
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-sparkle {
+	position: relative;
+	display: inline-block;
+
+	> span {
+		display: inline-block;
+	}
+
+	> svg {
+		position: absolute;
+		top: -32px;
+		left: -32px;
+		pointer-events: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
new file mode 100644
index 0000000000..210923be46
--- /dev/null
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -0,0 +1,90 @@
+<template>
+<div class="wrmlmaau" :class="{ collapsed }">
+	<div class="body">
+		<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+		<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
+		<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+		<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+	</div>
+	<details v-if="note.files.length > 0">
+		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
+		<XMediaList :media-list="note.files"/>
+	</details>
+	<details v-if="note.poll">
+		<summary>{{ i18n.ts.poll }}</summary>
+		<XPoll :note="note"/>
+	</details>
+	<button v-if="collapsed" class="fade _button" @click="collapsed = false">
+		<span>{{ i18n.ts.showMore }}</span>
+	</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import XMediaList from '@/components/MkMediaList.vue';
+import XPoll from '@/components/MkPoll.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	note: misskey.entities.Note;
+}>();
+
+const collapsed = $ref(
+	props.note.cw == null && props.note.text != null && (
+		(props.note.text.split('\n').length > 9) ||
+		(props.note.text.length > 500)
+	));
+</script>
+
+<style lang="scss" scoped>
+.wrmlmaau {
+	overflow-wrap: break-word;
+
+	> .body {
+		> .reply {
+			margin-right: 6px;
+			color: var(--accent);
+		}
+
+		> .rp {
+			margin-left: 4px;
+			font-style: oblique;
+			color: var(--renote);
+		}
+	}
+
+	&.collapsed {
+		position: relative;
+		max-height: 9em;
+		overflow: hidden;
+
+		> .fade {
+			display: block;
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), var(--X15));
+
+			> span {
+				display: inline-block;
+				background: var(--panel);
+				padding: 6px 10px;
+				font-size: 0.8em;
+				border-radius: 999px;
+				box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+			}
+
+			&:hover {
+				> span {
+					background: var(--panelHighlight);
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
new file mode 100644
index 0000000000..e79794aea4
--- /dev/null
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -0,0 +1,161 @@
+<template>
+<div class="rrevdjwu" :class="{ grid }">
+	<div v-for="group in def" class="group">
+		<div v-if="group.title" class="title">{{ group.title }}</div>
+
+		<div class="items">
+			<template v-for="(item, i) in group.items">
+				<a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
+					<span class="text">{{ item.text }}</span>
+				</a>
+				<button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)">
+					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
+					<span class="text">{{ item.text }}</span>
+				</button>
+				<MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+					<i v-if="item.icon" class="icon ti-fw" :class="item.icon"></i>
+					<span class="text">{{ item.text }}</span>
+				</MkA>
+			</template>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, unref } from 'vue';
+
+export default defineComponent({
+	props: {
+		def: {
+			type: Array,
+			required: true,
+		},
+		grid: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwu {
+	> .group {
+		& + .group {
+			margin-top: 16px;
+			padding-top: 16px;
+			border-top: solid 0.5px var(--divider);
+		}
+
+		> .title {
+			opacity: 0.7;
+			margin: 0 0 8px 0;
+			font-size: 0.9em;
+		}
+	
+		> .items {
+			> .item {
+				display: flex;
+				align-items: center;
+				width: 100%;
+				box-sizing: border-box;
+				padding: 10px 16px 10px 8px;
+				border-radius: 9px;
+				font-size: 0.9em;
+
+				&:hover {
+					text-decoration: none;
+					background: var(--panelHighlight);
+				}
+
+				&.active {
+					color: var(--accent);
+					background: var(--accentedBg);
+				}
+
+				&.danger {
+					color: var(--error);
+				}
+
+				> .icon {
+					width: 32px;
+					margin-right: 2px;
+					flex-shrink: 0;
+					text-align: center;
+					opacity: 0.8;
+				}
+
+				> .text {
+					white-space: normal;
+					padding-right: 12px;
+					flex-shrink: 1;
+				}
+
+			}
+		}
+	}
+
+	&.grid {
+		> .group {
+			& + .group {
+				padding-top: 0;
+				border-top: none;
+			}
+
+			margin-left: 0;
+			margin-right: 0;
+
+			> .title {
+				font-size: 1em;
+				opacity: 0.7;
+				margin: 0 0 8px 16px;
+			}
+
+			> .items {
+				display: grid;
+				grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
+				grid-gap: 16px;
+				padding: 0 16px;
+
+				> .item {
+					flex-direction: column;
+					text-align: center;
+					padding: 0;
+
+					&:hover {
+						text-decoration: none;
+						background: none;
+						color: var(--accent);
+
+						> .icon {
+							background: var(--accentedBg);
+						}
+					}
+
+					> .icon {
+						display: grid;
+						place-content: center;
+						margin-right: 0;
+						margin-bottom: 6px;
+						font-size: 1.5em;
+						width: 54px;
+						height: 54px;
+						aspect-ratio: 1;
+						background: var(--panel);
+						border-radius: 100%;
+					}
+
+					> .text {
+						padding-right: 0;
+						width: 100%;
+						font-size: 0.8em;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue
new file mode 100644
index 0000000000..669e9e2e11
--- /dev/null
+++ b/packages/frontend/src/components/MkTab.vue
@@ -0,0 +1,73 @@
+<script lang="ts">
+import { defineComponent, h, resolveDirective, withDirectives } from 'vue';
+
+export default defineComponent({
+	props: {
+		modelValue: {
+			required: true,
+		},
+	},
+	render() {
+		const options = this.$slots.default();
+
+		return withDirectives(h('div', {
+			class: 'pxhvhrfw',
+		}, options.map(option => withDirectives(h('button', {
+			class: ['_button', { active: this.modelValue === option.props.value }],
+			key: option.key,
+			disabled: this.modelValue === option.props.value,
+			onClick: () => {
+				this.$emit('update:modelValue', option.props.value);
+			},
+		}, option.children), [
+			[resolveDirective('click-anime')],
+		]))), [
+			[resolveDirective('size'), { max: [500] }],
+		]);
+	},
+});
+</script>
+
+<style lang="scss">
+.pxhvhrfw {
+	display: flex;
+	font-size: 90%;
+
+	> button {
+		flex: 1;
+		padding: 10px 8px;
+		border-radius: var(--radius);
+
+		&:disabled {
+			opacity: 1 !important;
+			cursor: default;
+		}
+
+		&.active {
+			color: var(--accent);
+			background: var(--accentedBg);
+		}
+
+		&:not(.active):hover {
+			color: var(--fgHighlighted);
+			background: var(--panelHighlight);
+		}
+
+		&:not(:first-child) {
+			margin-left: 8px;
+		}
+
+		> .icon {
+			margin-right: 6px;
+		}
+	}
+
+	&.max-width_500px {
+		font-size: 80%;
+
+		> button {
+			padding: 11px 8px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue
new file mode 100644
index 0000000000..2dfd26edb0
--- /dev/null
+++ b/packages/frontend/src/components/MkTagCloud.vue
@@ -0,0 +1,90 @@
+<template>
+<div ref="rootEl" class="meijqfqm">
+	<canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
+	<div :id="idForTags" ref="tagsEl" class="tags">
+		<ul>
+			<slot></slot>
+		</ul>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch, PropType, onBeforeUnmount } from 'vue';
+import tinycolor from 'tinycolor2';
+
+const loaded = !!window.TagCanvas;
+const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
+const computedStyle = getComputedStyle(document.documentElement);
+const idForCanvas = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
+const idForTags = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
+let available = $ref(false);
+let rootEl = $ref<HTMLElement | null>(null);
+let canvasEl = $ref<HTMLCanvasElement | null>(null);
+let tagsEl = $ref<HTMLElement | null>(null);
+let width = $ref(300);
+
+watch($$(available), () => {
+	try {
+		window.TagCanvas.Start(idForCanvas, idForTags, {
+			textColour: '#ffffff',
+			outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(),
+			outlineRadius: 10,
+			initial: [-0.030, -0.010],
+			frontSelect: true,
+			imageRadius: 8,
+			//dragControl: true,
+			dragThreshold: 3,
+			wheelZoom: false,
+			reverse: true,
+			depth: 0.5,
+			maxSpeed: 0.2,
+			minSpeed: 0.003,
+			stretchX: 0.8,
+			stretchY: 0.8,
+		});
+	} catch (err) {}
+});
+
+onMounted(() => {
+	width = rootEl.offsetWidth;
+
+	if (loaded) {
+		available = true;
+	} else {
+		document.head.appendChild(Object.assign(document.createElement('script'), {
+			async: true,
+			src: '/client-assets/tagcanvas.min.js',
+		})).addEventListener('load', () => available = true);
+	}
+});
+
+onBeforeUnmount(() => {
+	if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
+});
+
+defineExpose({
+	update: () => {
+		window.TagCanvas.Update(idForCanvas);
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.meijqfqm {
+	position: relative;
+	overflow: clip;
+	display: grid;
+	place-items: center;
+
+	> .canvas {
+		display: block;
+	}
+
+	> .tags {
+		position: absolute;
+		top: 999px;
+		left: 999px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
new file mode 100644
index 0000000000..831a194ce3
--- /dev/null
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -0,0 +1,143 @@
+<template>
+<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, provide, onUnmounted } from 'vue';
+import XNotes from '@/components/MkNotes.vue';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import * as sound from '@/scripts/sound';
+import { $i } from '@/account';
+
+const props = defineProps<{
+	src: string;
+	list?: string;
+	antenna?: string;
+	channel?: string;
+	sound?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'note'): void;
+	(ev: 'queue', count: number): void;
+}>();
+
+provide('inChannel', computed(() => props.src === 'channel'));
+
+const tlComponent: InstanceType<typeof XNotes> = $ref();
+
+const prepend = note => {
+	tlComponent.pagingComponent?.prepend(note);
+
+	emit('note');
+
+	if (props.sound) {
+		sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
+	}
+};
+
+const onUserAdded = () => {
+	tlComponent.pagingComponent?.reload();
+};
+
+const onUserRemoved = () => {
+	tlComponent.pagingComponent?.reload();
+};
+
+const onChangeFollowing = () => {
+	if (!tlComponent.pagingComponent?.backed) {
+		tlComponent.pagingComponent?.reload();
+	}
+};
+
+let endpoint;
+let query;
+let connection;
+let connection2;
+
+if (props.src === 'antenna') {
+	endpoint = 'antennas/notes';
+	query = {
+		antennaId: props.antenna,
+	};
+	connection = stream.useChannel('antenna', {
+		antennaId: props.antenna,
+	});
+	connection.on('note', prepend);
+} else if (props.src === 'home') {
+	endpoint = 'notes/timeline';
+	connection = stream.useChannel('homeTimeline');
+	connection.on('note', prepend);
+
+	connection2 = stream.useChannel('main');
+	connection2.on('follow', onChangeFollowing);
+	connection2.on('unfollow', onChangeFollowing);
+} else if (props.src === 'local') {
+	endpoint = 'notes/local-timeline';
+	connection = stream.useChannel('localTimeline');
+	connection.on('note', prepend);
+} else if (props.src === 'social') {
+	endpoint = 'notes/hybrid-timeline';
+	connection = stream.useChannel('hybridTimeline');
+	connection.on('note', prepend);
+} else if (props.src === 'global') {
+	endpoint = 'notes/global-timeline';
+	connection = stream.useChannel('globalTimeline');
+	connection.on('note', prepend);
+} else if (props.src === 'mentions') {
+	endpoint = 'notes/mentions';
+	connection = stream.useChannel('main');
+	connection.on('mention', prepend);
+} else if (props.src === 'directs') {
+	endpoint = 'notes/mentions';
+	query = {
+		visibility: 'specified',
+	};
+	const onNote = note => {
+		if (note.visibility === 'specified') {
+			prepend(note);
+		}
+	};
+	connection = stream.useChannel('main');
+	connection.on('mention', onNote);
+} else if (props.src === 'list') {
+	endpoint = 'notes/user-list-timeline';
+	query = {
+		listId: props.list,
+	};
+	connection = stream.useChannel('userList', {
+		listId: props.list,
+	});
+	connection.on('note', prepend);
+	connection.on('userAdded', onUserAdded);
+	connection.on('userRemoved', onUserRemoved);
+} else if (props.src === 'channel') {
+	endpoint = 'channels/timeline';
+	query = {
+		channelId: props.channel,
+	};
+	connection = stream.useChannel('channel', {
+		channelId: props.channel,
+	});
+	connection.on('note', prepend);
+}
+
+const pagination = {
+	endpoint: endpoint,
+	limit: 10,
+	params: query,
+};
+
+onUnmounted(() => {
+	connection.dispose();
+	if (connection2) connection2.dispose();
+});
+
+/* TODO
+const timetravel = (date?: Date) => {
+	this.date = date;
+	this.$refs.tl.reload();
+};
+*/
+</script>
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
new file mode 100644
index 0000000000..c9fad64eb6
--- /dev/null
+++ b/packages/frontend/src/components/MkToast.vue
@@ -0,0 +1,66 @@
+<template>
+<div class="mk-toast">
+	<transition :name="$store.state.animation ? 'toast' : ''" appear @after-leave="emit('closed')">
+		<div v-if="showing" class="body _acrylic" :style="{ zIndex }">
+			<div class="message">
+				{{ message }}
+			</div>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import * as os from '@/os';
+
+defineProps<{
+	message: string;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const zIndex = os.claimZIndex('high');
+let showing = $ref(true);
+
+onMounted(() => {
+	window.setTimeout(() => {
+		showing = false;
+	}, 4000);
+});
+</script>
+
+<style lang="scss" scoped>
+.toast-enter-active, .toast-leave-active {
+	transition: opacity 0.3s, transform 0.3s !important;
+}
+.toast-enter-from, .toast-leave-to {
+	opacity: 0;
+	transform: translateY(-100%);
+}
+
+.mk-toast {
+	> .body {
+		position: fixed;
+		left: 0;
+		right: 0;
+		top: 0;
+		margin: 0 auto;
+		margin-top: 16px;
+		min-width: 300px;
+		max-width: calc(100% - 32px);
+		width: min-content;
+		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+		border-radius: 8px;
+		overflow: clip;
+		text-align: center;
+		pointer-events: none;
+
+		> .message {
+			padding: 16px 24px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
new file mode 100644
index 0000000000..b846034a24
--- /dev/null
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -0,0 +1,90 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="400"
+	:height="450"
+	:with-ok-button="true"
+	:ok-button-disabled="false"
+	:can-close="false"
+	@close="dialog.close()"
+	@closed="$emit('closed')"
+	@ok="ok()"
+>
+	<template #header>{{ title || $ts.generateAccessToken }}</template>
+	<div v-if="information" class="_section">
+		<MkInfo warn>{{ information }}</MkInfo>
+	</div>
+	<div class="_section">
+		<MkInput v-model="name">
+			<template #label>{{ $ts.name }}</template>
+		</MkInput>
+	</div>
+	<div class="_section">
+		<div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div>
+		<MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
+		<MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
+		<MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { permissions as kinds } from 'misskey-js';
+import MkInput from './form/input.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './MkButton.vue';
+import MkInfo from './MkInfo.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+
+const props = withDefaults(defineProps<{
+	title?: string | null;
+	information?: string | null;
+	initialName?: string | null;
+	initialPermissions?: string[] | null;
+}>(), {
+	title: null,
+	information: null,
+	initialName: null,
+	initialPermissions: null,
+});
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'done', result: { name: string | null, permissions: string[] }): void;
+}>();
+
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
+let name = $ref(props.initialName);
+let permissions = $ref({});
+
+if (props.initialPermissions) {
+	for (const kind of props.initialPermissions) {
+		permissions[kind] = true;
+	}
+} else {
+	for (const kind of kinds) {
+		permissions[kind] = false;
+	}
+}
+
+function ok(): void {
+	emit('done', {
+		name: name,
+		permissions: Object.keys(permissions).filter(p => permissions[p]),
+	});
+	dialog.close();
+}
+
+function disableAll(): void {
+	for (const p in permissions) {
+		permissions[p] = false;
+	}
+}
+
+function enableAll(): void {
+	for (const p in permissions) {
+		permissions[p] = true;
+	}
+}
+</script>
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
new file mode 100644
index 0000000000..4c6258d245
--- /dev/null
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -0,0 +1,101 @@
+<template>
+<transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')">
+	<div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }">
+		<slot>
+			<Mfm v-if="asMfm" :text="text"/>
+			<span v-else>{{ text }}</span>
+		</slot>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import { calcPopupPosition } from '@/scripts/popup-position';
+
+const props = withDefaults(defineProps<{
+	showing: boolean;
+	targetElement?: HTMLElement;
+	x?: number;
+	y?: number;
+	text?: string;
+	asMfm?: boolean;
+	maxWidth?: number;
+	direction?: 'top' | 'bottom' | 'right' | 'left';
+	innerMargin?: number;
+}>(), {
+	maxWidth: 250,
+	direction: 'top',
+	innerMargin: 0,
+});
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const el = ref<HTMLElement>();
+const zIndex = os.claimZIndex('high');
+
+function setPosition() {
+	const data = calcPopupPosition(el.value, {
+		anchorElement: props.targetElement,
+		direction: props.direction,
+		align: 'center',
+		innerMargin: props.innerMargin,
+		x: props.x,
+		y: props.y,
+	});
+
+	el.value.style.transformOrigin = data.transformOrigin;
+	el.value.style.left = data.left + 'px';
+	el.value.style.top = data.top + 'px';
+}
+
+let loopHandler;
+
+onMounted(() => {
+	nextTick(() => {
+		setPosition();
+
+		const loop = () => {
+			loopHandler = window.requestAnimationFrame(() => {
+				setPosition();
+				loop();
+			});
+		};
+
+		loop();
+	});
+});
+
+onUnmounted(() => {
+	window.cancelAnimationFrame(loopHandler);
+});
+</script>
+
+<style lang="scss" scoped>
+.tooltip-enter-active,
+.tooltip-leave-active {
+	opacity: 1;
+	transform: scale(1);
+	transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tooltip-enter-from,
+.tooltip-leave-active {
+	opacity: 0;
+	transform: scale(0.75);
+}
+
+.buebdbiu {
+	position: absolute;
+	font-size: 0.8em;
+	padding: 8px 12px;
+	box-sizing: border-box;
+	text-align: center;
+	border-radius: 4px;
+	border: solid 0.5px var(--divider);
+	pointer-events: none;
+	transform-origin: center center;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue
new file mode 100644
index 0000000000..48aeb30224
--- /dev/null
+++ b/packages/frontend/src/components/MkUpdated.vue
@@ -0,0 +1,51 @@
+<template>
+<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
+	<div class="ewlycnyt">
+		<div class="title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div>
+		<div class="version">✨{{ version }}🚀</div>
+		<MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton>
+		<MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ i18n.ts.gotIt }}</MkButton>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkSparkle from '@/components/MkSparkle.vue';
+import { version } from '@/config';
+import { i18n } from '@/i18n';
+
+const modal = ref<InstanceType<typeof MkModal>>();
+
+const whatIsNew = () => {
+	modal.value.close();
+	window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank');
+};
+</script>
+
+<style lang="scss" scoped>
+.ewlycnyt {
+	position: relative;
+	padding: 32px;
+	min-width: 320px;
+	max-width: 480px;
+	box-sizing: border-box;
+	text-align: center;
+	background: var(--panel);
+	border-radius: var(--radius);
+
+	> .title {
+		font-weight: bold;
+	}
+
+	> .version {
+		margin: 1em 0;
+	}
+
+	> .gotIt {
+		margin: 8px 0 0 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
new file mode 100644
index 0000000000..b2d16ddb01
--- /dev/null
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -0,0 +1,383 @@
+<template>
+<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
+	<button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
+	<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
+</div>
+<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
+	<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
+</div>
+<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
+	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+		<component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
+			<div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`">
+				<button v-if="!playerEnabled && player.url" class="_button" :title="i18n.ts.enablePlayer" @click.prevent="isMobile? playerEnabled = true : openPlayer()"><i class="ti ti-player-play"></i></button>
+			</div>
+			<article>
+				<header>
+					<h1 :title="title">{{ title }}</h1>
+				</header>
+				<p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
+				<footer>
+					<img v-if="icon" class="icon" :src="icon"/>
+					<p :title="sitename">{{ sitename }}</p>
+				</footer>
+			</article>
+		</component>
+	</transition>
+	<div v-if="tweetId" class="expandTweet">
+		<a @click="tweetExpanded = true">
+			<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
+		</a>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, onMounted, onUnmounted } from 'vue';
+import { url as local, lang } from '@/config';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import { deviceKind } from '@/scripts/device-kind';
+
+const props = withDefaults(defineProps<{
+	url: string;
+	detail?: boolean;
+	compact?: boolean;
+}>(), {
+	detail: false,
+	compact: false,
+});
+
+const MOBILE_THRESHOLD = 500;
+const isMobile = $ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
+
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+let fetching = $ref(true);
+let title = $ref<string | null>(null);
+let description = $ref<string | null>(null);
+let thumbnail = $ref<string | null>(null);
+let icon = $ref<string | null>(null);
+let sitename = $ref<string | null>(null);
+let player = $ref({
+	url: null,
+	width: null,
+	height: null,
+});
+let playerEnabled = $ref(false);
+let tweetId = $ref<string | null>(null);
+let tweetExpanded = $ref(props.detail);
+const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
+let tweetHeight = $ref(150);
+
+const requestUrl = new URL(props.url);
+
+if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') {
+	const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
+	if (m) tweetId = m[1];
+}
+
+if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
+	requestUrl.hostname = 'www.youtube.com';
+}
+
+const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
+
+requestUrl.hash = '';
+
+window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
+	res.json().then(info => {
+		if (info.url == null) return;
+		title = info.title;
+		description = info.description;
+		thumbnail = info.thumbnail;
+		icon = info.icon;
+		sitename = info.sitename;
+		fetching = false;
+		player = info.player;
+	});
+});
+
+function adjustTweetHeight(message: any) {
+	if (message.origin !== 'https://platform.twitter.com') return;
+	const embed = message.data?.['twttr.embed'];
+	if (embed?.method !== 'twttr.private.resize') return;
+	if (embed?.id !== embedId) return;
+	const height = embed?.params[0]?.height;
+	if (height) tweetHeight = height;
+}
+
+const openPlayer = (): void => {
+	os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
+		url: requestUrl.href,
+	});
+};
+
+(window as any).addEventListener('message', adjustTweetHeight);
+
+onUnmounted(() => {
+	(window as any).removeEventListener('message', adjustTweetHeight);
+});
+</script>
+
+<style lang="scss" scoped>
+.player {
+	position: relative;
+	width: 100%;
+
+	> button {
+		position: absolute;
+		top: -1.5em;
+		right: 0;
+		font-size: 1em;
+		width: 1.5em;
+		height: 1.5em;
+		padding: 0;
+		margin: 0;
+		color: var(--fg);
+		background: rgba(128, 128, 128, 0.2);
+		opacity: 0.7;
+
+		&:hover {
+			opacity: 0.9;
+		}
+	}
+
+	> iframe {
+		height: 100%;
+		left: 0;
+		position: absolute;
+		top: 0;
+		width: 100%;
+	}
+}
+
+.mk-url-preview {
+	&.max-width_400px {
+		> .link {
+			font-size: 12px;
+
+			> .thumbnail {
+				height: 80px;
+			}
+
+			> article {
+				padding: 12px;
+			}
+		}
+	}
+
+	&.max-width_350px {
+		> .link {
+			font-size: 10px;
+
+			> .thumbnail {
+				height: 70px;
+			}
+
+			> article {
+				padding: 8px;
+
+				> header {
+					margin-bottom: 4px;
+				}
+
+				> footer {
+					margin-top: 4px;
+
+					> img {
+						width: 12px;
+						height: 12px;
+					}
+				}
+			}
+
+			&.compact {
+				> .thumbnail {
+					position: absolute;
+					width: 56px;
+					height: 100%;
+				}
+
+				> article {
+					left: 56px;
+					width: calc(100% - 56px);
+					padding: 4px;
+
+					> header {
+						margin-bottom: 2px;
+					}
+
+					> footer {
+						margin-top: 2px;
+					}
+				}
+			}
+		}
+	}
+
+	> .link {
+		position: relative;
+		display: block;
+		font-size: 14px;
+		box-shadow: 0 0 0 1px var(--divider);
+		border-radius: 8px;
+		overflow: hidden;
+
+		&:hover {
+			text-decoration: none;
+			border-color: rgba(0, 0, 0, 0.2);
+
+			> article > header > h1 {
+				text-decoration: underline;
+			}
+		}
+
+		> .thumbnail {
+			position: absolute;
+			width: 100px;
+			height: 100%;
+			background-position: center;
+			background-size: cover;
+			display: flex;
+			justify-content: center;
+			align-items: center;
+
+			> button {
+				font-size: 3.5em;
+				opacity: 0.7;
+
+				&:hover {
+					font-size: 4em;
+					opacity: 0.9;
+				}
+			}
+
+			& + article {
+				left: 100px;
+				width: calc(100% - 100px);
+			}
+		}
+
+		> article {
+			position: relative;
+			box-sizing: border-box;
+			padding: 16px;
+
+			> header {
+				margin-bottom: 8px;
+
+				> h1 {
+					margin: 0;
+					font-size: 1em;
+				}
+			}
+
+			> p {
+				margin: 0;
+				font-size: 0.8em;
+			}
+
+			> footer {
+				margin-top: 8px;
+				height: 16px;
+
+				> img {
+					display: inline-block;
+					width: 16px;
+					height: 16px;
+					margin-right: 4px;
+					vertical-align: top;
+				}
+
+				> p {
+					display: inline-block;
+					margin: 0;
+					color: var(--urlPreviewInfo);
+					font-size: 0.8em;
+					line-height: 16px;
+					vertical-align: top;
+				}
+			}
+		}
+
+		&.compact {
+			> article {
+				> header h1, p, footer {
+					overflow: hidden;
+					white-space: nowrap;
+					text-overflow: ellipsis;
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 400px) {
+	.mk-url-preview {
+		> .link {
+			font-size: 12px;
+
+			> .thumbnail {
+				height: 80px;
+			}
+
+			> article {
+				padding: 12px;
+			}
+		}
+	}
+}
+
+@container (max-width: 350px) {
+	.mk-url-preview {
+		> .link {
+			font-size: 10px;
+
+			> .thumbnail {
+				height: 70px;
+			}
+
+			> article {
+				padding: 8px;
+
+				> header {
+					margin-bottom: 4px;
+				}
+
+				> footer {
+					margin-top: 4px;
+
+					> img {
+						width: 12px;
+						height: 12px;
+					}
+				}
+			}
+
+			&.compact {
+				> .thumbnail {
+					position: absolute;
+					width: 56px;
+					height: 100%;
+				}
+
+				> article {
+					left: 56px;
+					width: calc(100% - 56px);
+					padding: 4px;
+
+					> header {
+						margin-bottom: 2px;
+					}
+
+					> footer {
+						margin-top: 2px;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue
new file mode 100644
index 0000000000..f343c6d8a6
--- /dev/null
+++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
+	<transition :name="$store.state.animation ? 'zoom' : ''" @after-leave="emit('closed')">
+		<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import * as os from '@/os';
+
+const props = defineProps<{
+	showing: boolean;
+	url: string;
+	source: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const zIndex = os.claimZIndex('middle');
+let top = $ref(0);
+let left = $ref(0);
+
+onMounted(() => {
+	const rect = props.source.getBoundingClientRect();
+	const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
+	const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+
+	top = y;
+	left = x;
+});
+</script>
+
+<style lang="scss" scoped>
+.fgmtyycl {
+	position: absolute;
+	width: 500px;
+	max-width: calc(90vw - 12px);
+	pointer-events: none;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue
new file mode 100644
index 0000000000..1a4c494987
--- /dev/null
+++ b/packages/frontend/src/components/MkUserCardMini.vue
@@ -0,0 +1,99 @@
+<template>
+<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]">
+	<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+	<div class="body">
+		<span class="name"><MkUserName class="name" :user="user"/></span>
+		<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+	</div>
+	<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { acct } from '@/filters/user';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+let chartValues = $ref<number[] | null>(null);
+
+os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => {
+	// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
+	res.inc.splice(0, 1);
+	chartValues = res.inc;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	$bodyTitleHieght: 18px;
+	$bodyInfoHieght: 16px;
+
+	display: flex;
+	align-items: center;
+	padding: 16px;
+	background: var(--panel);
+	border-radius: 8px;
+
+	> :global(.avatar) {
+		display: block;
+		width: ($bodyTitleHieght + $bodyInfoHieght);
+		height: ($bodyTitleHieght + $bodyInfoHieght);
+		margin-right: 12px;
+	}
+
+	> :global(.body) {
+		flex: 1;
+		overflow: hidden;
+		font-size: 0.9em;
+		color: var(--fg);
+		padding-right: 8px;
+
+		> :global(.name) {
+			display: block;
+			width: 100%;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			line-height: $bodyTitleHieght;
+		}
+
+		> :global(.sub) {
+			display: block;
+			width: 100%;
+			font-size: 95%;
+			opacity: 0.7;
+			line-height: $bodyInfoHieght;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+	}
+
+	> :global(.chart) {
+		height: 30px;
+	}
+
+	&:global(.yellow) {
+		--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;
+	}
+
+	&:global(.red) {
+		--c: rgb(255 0 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;
+	}
+
+	&:global(.gray) {
+		--c: var(--bg);
+		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/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
new file mode 100644
index 0000000000..036cbea304
--- /dev/null
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -0,0 +1,137 @@
+<template>
+<div class="_panel vjnjpkug">
+	<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+	<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+	<div class="title">
+		<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+		<p class="username"><MkAcct :user="user"/></p>
+	</div>
+	<span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+	<div class="description">
+		<div v-if="user.description" class="mfm">
+			<Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+		</div>
+		<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
+	</div>
+	<div class="status">
+		<div>
+			<p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span>
+		</div>
+		<div>
+			<p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span>
+		</div>
+		<div>
+			<p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span>
+		</div>
+	</div>
+	<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+
+defineProps<{
+	user: misskey.entities.UserDetailed;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.vjnjpkug {
+	position: relative;
+
+	> .banner {
+		height: 84px;
+		background-color: rgba(0, 0, 0, 0.1);
+		background-size: cover;
+		background-position: center;
+	}
+
+	> .avatar {
+		display: block;
+		position: absolute;
+		top: 62px;
+		left: 13px;
+		z-index: 2;
+		width: 58px;
+		height: 58px;
+		border: solid 4px var(--panel);
+	}
+
+	> .title {
+		display: block;
+		padding: 10px 0 10px 88px;
+
+		> .name {
+			display: inline-block;
+			margin: 0;
+			font-weight: bold;
+			line-height: 16px;
+			word-break: break-all;
+		}
+
+		> .username {
+			display: block;
+			margin: 0;
+			line-height: 16px;
+			font-size: 0.8em;
+			color: var(--fg);
+			opacity: 0.7;
+		}
+	}
+	
+	> .followed {
+		position: absolute;
+		top: 12px;
+		left: 12px;
+		padding: 4px 8px;
+		color: #fff;
+		background: rgba(0, 0, 0, 0.7);
+		font-size: 0.7em;
+		border-radius: 6px;
+	}
+	
+	> .description {
+		padding: 16px;
+		font-size: 0.8em;
+		border-top: solid 0.5px var(--divider);
+
+		> .mfm {
+			display: -webkit-box;
+			-webkit-line-clamp: 3;
+			-webkit-box-orient: vertical;  
+			overflow: hidden;
+		}
+	}
+
+	> .status {
+		padding: 10px 16px;
+		border-top: solid 0.5px var(--divider);
+
+		> div {
+			display: inline-block;
+			width: 33%;
+
+			> p {
+				margin: 0;
+				font-size: 0.7em;
+				color: var(--fg);
+			}
+
+			> span {
+				font-size: 1em;
+				color: var(--accent);
+			}
+		}
+	}
+
+	> .koudoku-button {
+		position: absolute;
+		top: 8px;
+		right: 8px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
new file mode 100644
index 0000000000..e1f47c7673
--- /dev/null
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -0,0 +1,39 @@
+<template>
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ i18n.ts.noUsers }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: users }">
+		<div class="efvhhmdq">
+			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
+		</div>
+	</template>
+</MkPagination>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkUserInfo from '@/components/MkUserInfo.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+}>();
+
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
+</script>
+
+<style lang="scss" scoped>
+.efvhhmdq {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue
new file mode 100644
index 0000000000..a4f6f80383
--- /dev/null
+++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue
@@ -0,0 +1,45 @@
+<template>
+<div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+const text = $computed(() => {
+	switch (props.user.onlineStatus) {
+		case 'online': return i18n.ts.online;
+		case 'active': return i18n.ts.active;
+		case 'offline': return i18n.ts.offline;
+		case 'unknown': return i18n.ts.unknown;
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.fzgwjkgc {
+	box-shadow: 0 0 0 3px var(--panel);
+	border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる
+
+	&.online {
+		background: #58d4c9;
+	}
+
+	&.active {
+		background: #e4bc48;
+	}
+
+	&.offline {
+		background: #ea5353;
+	}
+
+	&.unknown {
+		background: #888;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue
new file mode 100644
index 0000000000..4de2e8baa2
--- /dev/null
+++ b/packages/frontend/src/components/MkUserPreview.vue
@@ -0,0 +1,184 @@
+<template>
+<transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="emit('closed')">
+	<div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
+		<div v-if="user != null" class="info">
+			<div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
+				<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+			</div>
+			<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+			<div class="title">
+				<MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+				<p class="username"><MkAcct :user="user"/></p>
+			</div>
+			<div class="description">
+				<Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+			</div>
+			<div class="status">
+				<div>
+					<p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
+				</div>
+				<div>
+					<p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
+				</div>
+				<div>
+					<p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
+				</div>
+			</div>
+			<MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
+		</div>
+		<div v-else>
+			<MkLoading/>
+		</div>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+const props = defineProps<{
+	showing: boolean;
+	q: string;
+	source: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'mouseover'): void;
+	(ev: 'mouseleave'): void;
+}>();
+
+const zIndex = os.claimZIndex('middle');
+let user = $ref<misskey.entities.UserDetailed | null>(null);
+let top = $ref(0);
+let left = $ref(0);
+
+onMounted(() => {
+	if (typeof props.q === 'object') {
+		user = props.q;
+	} else {
+		const query = props.q.startsWith('@') ?
+			Acct.parse(props.q.substr(1)) :
+			{ userId: props.q };
+
+		os.api('users/show', query).then(res => {
+			if (!props.showing) return;
+			user = res;
+		});
+	}
+
+	const rect = props.source.getBoundingClientRect();
+	const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
+	const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+
+	top = y;
+	left = x;
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-enter-active, .popup-leave-active {
+	transition: opacity 0.3s, transform 0.3s !important;
+}
+.popup-enter-from, .popup-leave-to {
+	opacity: 0;
+	transform: scale(0.9);
+}
+
+.fxxzrfni {
+	position: absolute;
+	width: 300px;
+	overflow: hidden;
+	transform-origin: center top;
+
+	> .info {
+		> .banner {
+			height: 84px;
+			background-color: rgba(0, 0, 0, 0.1);
+			background-size: cover;
+			background-position: center;
+			> .followed {
+					position: absolute;
+					top: 12px;
+					left: 12px;
+					padding: 4px 8px;
+					color: #fff;
+					background: rgba(0, 0, 0, 0.7);
+					font-size: 0.7em;
+					border-radius: 6px;
+			}
+		}
+
+		> .avatar {
+			display: block;
+			position: absolute;
+			top: 62px;
+			left: 13px;
+			z-index: 2;
+			width: 58px;
+			height: 58px;
+			border: solid 3px var(--face);
+			border-radius: 8px;
+		}
+
+		> .title {
+			display: block;
+			padding: 8px 0 8px 82px;
+
+			> .name {
+				display: inline-block;
+				margin: 0;
+				font-weight: bold;
+				line-height: 16px;
+				word-break: break-all;
+			}
+
+			> .username {
+				display: block;
+				margin: 0;
+				line-height: 16px;
+				font-size: 0.8em;
+				color: var(--fg);
+				opacity: 0.7;
+			}
+		}
+
+		> .description {
+			padding: 0 16px;
+			font-size: 0.8em;
+			color: var(--fg);
+		}
+
+		> .status {
+			padding: 8px 16px;
+
+			> div {
+				display: inline-block;
+				width: 33%;
+
+				> p {
+					margin: 0;
+					font-size: 0.7em;
+					color: var(--fg);
+				}
+
+				> span {
+					font-size: 1em;
+					color: var(--accent);
+				}
+			}
+		}
+
+		> .koudoku-button {
+			position: absolute;
+			top: 8px;
+			right: 8px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
new file mode 100644
index 0000000000..1d31769c30
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -0,0 +1,190 @@
+<template>
+<XModalWindow
+	ref="dialogEl"
+	:with-ok-button="true"
+	:ok-button-disabled="selected == null"
+	@click="cancel()"
+	@close="cancel()"
+	@ok="ok()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ i18n.ts.selectUser }}</template>
+	<div class="tbhwbxda">
+		<div class="form">
+			<FormSplit :min-width="170">
+				<MkInput v-model="username" :autofocus="true" @update:model-value="search">
+					<template #label>{{ i18n.ts.username }}</template>
+					<template #prefix>@</template>
+				</MkInput>
+				<MkInput v-model="host" @update:model-value="search">
+					<template #label>{{ i18n.ts.host }}</template>
+					<template #prefix>@</template>
+				</MkInput>
+			</FormSplit>
+		</div>
+		<div v-if="username != '' || host != ''" class="result" :class="{ hit: users.length > 0 }">
+			<div v-if="users.length > 0" class="users">
+				<div v-for="user in users" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+					<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+					<div class="body">
+						<MkUserName :user="user" class="name"/>
+						<MkAcct :user="user" class="acct"/>
+					</div>
+				</div>
+			</div>
+			<div v-else class="empty">
+				<span>{{ i18n.ts.noUsers }}</span>
+			</div>
+		</div>
+		<div v-if="username == '' && host == ''" class="recent">
+			<div class="users">
+				<div v-for="user in recentUsers" :key="user.id" class="user" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+					<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+					<div class="body">
+						<MkUserName :user="user" class="name"/>
+						<MkAcct :user="user" class="acct"/>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import MkInput from '@/components/form/input.vue';
+import FormSplit from '@/components/form/split.vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const emit = defineEmits<{
+	(ev: 'ok', selected: misskey.entities.UserDetailed): void;
+	(ev: 'cancel'): void;
+	(ev: 'closed'): void;
+}>();
+
+let username = $ref('');
+let host = $ref('');
+let users: misskey.entities.UserDetailed[] = $ref([]);
+let recentUsers: misskey.entities.UserDetailed[] = $ref([]);
+let selected: misskey.entities.UserDetailed | null = $ref(null);
+let dialogEl = $ref();
+
+const search = () => {
+	if (username === '' && host === '') {
+		users = [];
+		return;
+	}
+	os.api('users/search-by-username-and-host', {
+		username: username,
+		host: host,
+		limit: 10,
+		detail: false,
+	}).then(_users => {
+		users = _users;
+	});
+};
+
+const ok = () => {
+	if (selected == null) return;
+	emit('ok', selected);
+	dialogEl.close();
+
+	// 最近使ったユーザー更新
+	let recents = defaultStore.state.recentlyUsedUsers;
+	recents = recents.filter(x => x !== selected.id);
+	recents.unshift(selected.id);
+	defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
+};
+
+const cancel = () => {
+	emit('cancel');
+	dialogEl.close();
+};
+
+onMounted(() => {
+	os.api('users/show', {
+		userIds: defaultStore.state.recentlyUsedUsers,
+	}).then(users => {
+		recentUsers = users;
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+.tbhwbxda {
+	> .form {
+		padding: 0 var(--root-margin);
+	}
+
+	> .result, > .recent {
+		display: flex;
+		flex-direction: column;
+		overflow: auto;
+		height: 100%;
+
+		&.result.hit {
+			padding: 0;
+		}
+
+		&.recent {
+			padding: 0;
+		}
+
+		> .users {
+			flex: 1;
+			overflow: auto;
+			padding: 8px 0;
+
+			> .user {
+				display: flex;
+				align-items: center;
+				padding: 8px var(--root-margin);
+				font-size: 14px;
+
+				&:hover {
+					background: var(--X7);
+				}
+
+				&.selected {
+					background: var(--accent);
+					color: #fff;
+				}
+
+				> * {
+					pointer-events: none;
+					user-select: none;
+				}
+
+				> .avatar {
+					width: 45px;
+					height: 45px;
+				}
+
+				> .body {
+					padding: 0 8px;
+					min-width: 0;
+
+					> .name {
+						display: block;
+						font-weight: bold;
+					}
+
+					> .acct {
+						opacity: 0.5;
+					}
+				}
+			}
+		}
+
+		> .empty {
+			opacity: 0.7;
+			text-align: center;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue
new file mode 100644
index 0000000000..f0384e2d65
--- /dev/null
+++ b/packages/frontend/src/components/MkUsersTooltip.vue
@@ -0,0 +1,51 @@
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="250" @closed="emit('closed')">
+	<div class="beaffaef">
+		<div v-for="u in users" :key="u.id" class="user">
+			<MkAvatar class="avatar" :user="u"/>
+			<MkUserName class="name" :user="u" :nowrap="true"/>
+		</div>
+		<div v-if="users.length < count" class="omitted">+{{ count - users.length }}</div>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from './MkTooltip.vue';
+
+defineProps<{
+	showing: boolean;
+	users: any[]; // TODO
+	count: number;
+	targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.beaffaef {
+	font-size: 0.9em;
+	text-align: left;
+
+	> .user {
+		line-height: 24px;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+
+		&:not(:last-child) {
+			margin-bottom: 3px;
+		}
+
+		> .avatar {
+			width: 24px;
+			height: 24px;
+			margin-right: 3px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkVisibility.vue b/packages/frontend/src/components/MkVisibility.vue
new file mode 100644
index 0000000000..229907fbb8
--- /dev/null
+++ b/packages/frontend/src/components/MkVisibility.vue
@@ -0,0 +1,48 @@
+<template>
+<span v-if="note.visibility !== 'public'" :class="$style.visibility" :title="i18n.ts._visibility[note.visibility]">
+	<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+	<i v-else-if="note.visibility === 'followers'" class="ti ti-lock-open"></i>
+	<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+</span>
+<span v-if="note.localOnly" :class="$style.localOnly" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import XDetails from '@/components/MkUsersTooltip.vue';
+import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	note: {
+		visibility: string;
+		localOnly?: boolean;
+		visibleUserIds?: string[];
+	},
+}>();
+
+const specified = $ref<HTMLElement>();
+
+if (props.note.visibility === 'specified') {
+	useTooltip($$(specified), async (showing) => {
+		const users = await os.api('users/show', {
+			userIds: props.note.visibleUserIds,
+			limit: 10,
+		});
+
+		os.popup(XDetails, {
+			showing,
+			users,
+			count: props.note.visibleUserIds.length,
+			targetElement: specified,
+		}, {}, 'closed');
+	});
+}
+</script>
+
+<style lang="scss" module>
+.visibility, .localOnly {
+	margin-left: 0.5em;
+}
+</style>
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
new file mode 100644
index 0000000000..8f0bcdeae8
--- /dev/null
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -0,0 +1,159 @@
+<template>
+<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
+	<div class="gqyayizv _popup">
+		<button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')">
+			<div><i class="ti ti-world"></i></div>
+			<div>
+				<span>{{ i18n.ts._visibility.public }}</span>
+				<span>{{ i18n.ts._visibility.publicDescription }}</span>
+			</div>
+		</button>
+		<button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')">
+			<div><i class="ti ti-home"></i></div>
+			<div>
+				<span>{{ i18n.ts._visibility.home }}</span>
+				<span>{{ i18n.ts._visibility.homeDescription }}</span>
+			</div>
+		</button>
+		<button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
+			<div><i class="ti ti-lock-open"></i></div>
+			<div>
+				<span>{{ i18n.ts._visibility.followers }}</span>
+				<span>{{ i18n.ts._visibility.followersDescription }}</span>
+			</div>
+		</button>
+		<button key="specified" :disabled="localOnly" class="_button" :class="{ active: v === 'specified' }" data-index="4" @click="choose('specified')">
+			<div><i class="ti ti-mail"></i></div>
+			<div>
+				<span>{{ i18n.ts._visibility.specified }}</span>
+				<span>{{ i18n.ts._visibility.specifiedDescription }}</span>
+			</div>
+		</button>
+		<div class="divider"></div>
+		<button key="localOnly" class="_button localOnly" :class="{ active: localOnly }" data-index="5" @click="localOnly = !localOnly">
+			<div><i class="ti ti-world-off"></i></div>
+			<div>
+				<span>{{ i18n.ts._visibility.localOnly }}</span>
+				<span>{{ i18n.ts._visibility.localOnlyDescription }}</span>
+			</div>
+			<div><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div>
+		</button>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MkModal from '@/components/MkModal.vue';
+import { i18n } from '@/i18n';
+
+const modal = $ref<InstanceType<typeof MkModal>>();
+
+const props = withDefaults(defineProps<{
+	currentVisibility: typeof misskey.noteVisibilities[number];
+	currentLocalOnly: boolean;
+	src?: HTMLElement;
+}>(), {
+});
+
+const emit = defineEmits<{
+	(ev: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void;
+	(ev: 'changeLocalOnly', v: boolean): void;
+	(ev: 'closed'): void;
+}>();
+
+let v = $ref(props.currentVisibility);
+let localOnly = $ref(props.currentLocalOnly);
+
+watch($$(localOnly), () => {
+	emit('changeLocalOnly', localOnly);
+});
+
+function choose(visibility: typeof misskey.noteVisibilities[number]): void {
+	v = visibility;
+	emit('changeVisibility', visibility);
+	nextTick(() => {
+		modal.close();
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.gqyayizv {
+	width: 240px;
+	padding: 8px 0;
+
+	> .divider {
+		margin: 8px 0;
+		border-top: solid 0.5px var(--divider);
+	}
+
+	> button {
+		display: flex;
+		padding: 8px 14px;
+		font-size: 12px;
+		text-align: left;
+		width: 100%;
+		box-sizing: border-box;
+
+		&:hover {
+			background: rgba(0, 0, 0, 0.05);
+		}
+
+		&:active {
+			background: rgba(0, 0, 0, 0.1);
+		}
+
+		&.active {
+			color: var(--fgOnAccent);
+			background: var(--accent);
+		}
+
+		&.localOnly.active {
+			color: var(--accent);
+			background: inherit;
+		}
+
+		> *:nth-child(1) {
+			display: flex;
+			justify-content: center;
+			align-items: center;
+			margin-right: 10px;
+			width: 16px;
+			top: 0;
+			bottom: 0;
+			margin-top: auto;
+			margin-bottom: auto;
+		}
+
+		> *:nth-child(2) {
+			flex: 1 1 auto;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+
+			> span:first-child {
+				display: block;
+				font-weight: bold;
+			}
+
+			> span:last-child:not(:first-child) {
+				opacity: 0.6;
+			}
+		}
+
+		> *:nth-child(3) {
+			display: flex;
+			justify-content: center;
+			align-items: center;
+			margin-left: 10px;
+			width: 16px;
+			top: 0;
+			bottom: 0;
+			margin-top: auto;
+			margin-bottom: auto;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue
new file mode 100644
index 0000000000..f4a9f4f22c
--- /dev/null
+++ b/packages/frontend/src/components/MkWaitingDialog.vue
@@ -0,0 +1,73 @@
+<template>
+<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
+	<div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
+		<i v-if="success" class="ti ti-check icon success"></i>
+		<MkLoading v-else class="icon waiting" :em="true"/>
+		<div v-if="text && !success" class="text">{{ text }}<MkEllipsis/></div>
+	</div>
+</MkModal>
+</template>
+
+<script lang="ts" setup>
+import { watch, ref } from 'vue';
+import MkModal from '@/components/MkModal.vue';
+
+const modal = ref<InstanceType<typeof MkModal>>();
+
+const props = defineProps<{
+	success: boolean;
+	showing: boolean;
+	text?: string;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'done');
+	(ev: 'closed');
+}>();
+
+function done() {
+	emit('done');
+	modal.value.close();
+}
+
+watch(() => props.showing, () => {
+	if (!props.showing) done();
+});
+</script>
+
+<style lang="scss" scoped>
+.iuyakobc {
+	position: relative;
+	padding: 32px;
+	box-sizing: border-box;
+	text-align: center;
+	background: var(--panel);
+	border-radius: var(--radius);
+	width: 250px;
+
+	&.iconOnly {
+		padding: 0;
+		width: 96px;
+		height: 96px;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+
+	> .icon {
+		font-size: 32px;
+
+		&.success {
+			color: var(--accent);
+		}
+
+		&.waiting {
+			opacity: 0.7;
+		}
+	}
+
+	> .text {
+		margin-top: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
new file mode 100644
index 0000000000..fff89117ce
--- /dev/null
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="vjoppmmu">
+	<template v-if="edit">
+		<header>
+			<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select">
+				<template #label>{{ i18n.ts.selectWidget }}</template>
+				<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option>
+			</MkSelect>
+			<MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+			<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
+		</header>
+		<Sortable
+			:model-value="props.widgets"
+			item-key="id"
+			handle=".handle"
+			:animation="150"
+			@update:model-value="v => emit('updateWidgets', v)"
+		>
+			<template #item="{element}">
+				<div class="customize-container">
+					<button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
+					<button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
+					<div class="handle">
+						<component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @update-props="updateWidget(element.id, $event)"/>
+					</div>
+				</div>
+			</template>
+		</Sortable>
+	</template>
+	<component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" class="widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, reactive, ref, computed } from 'vue';
+import { v4 as uuid } from 'uuid';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/MkButton.vue';
+import { widgets as widgetDefs } from '@/widgets';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { deepClone } from '@/scripts/clone';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+type Widget = {
+	name: string;
+	id: string;
+	data: Record<string, any>;
+};
+
+const props = defineProps<{
+	widgets: Widget[];
+	edit: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'updateWidgets', widgets: Widget[]): void;
+	(ev: 'addWidget', widget: Widget): void;
+	(ev: 'removeWidget', widget: Widget): void;
+	(ev: 'updateWidget', widget: Partial<Widget>): void;
+	(ev: 'exit'): void;
+}>();
+
+const widgetRefs = {};
+const configWidget = (id: string) => {
+	widgetRefs[id].configure();
+};
+const widgetAdderSelected = ref(null);
+const addWidget = () => {
+	if (widgetAdderSelected.value == null) return;
+
+	emit('addWidget', {
+		name: widgetAdderSelected.value,
+		id: uuid(),
+		data: {},
+	});
+
+	widgetAdderSelected.value = null;
+};
+const removeWidget = (widget) => {
+	emit('removeWidget', widget);
+};
+const updateWidget = (id, data) => {
+	emit('updateWidget', { id, data });
+};
+
+function onContextmenu(widget: Widget, ev: MouseEvent) {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
+	if (window.getSelection()?.toString() !== '') return;
+
+	os.contextMenu([{
+		type: 'label',
+		text: i18n.t(`_widgets.${widget.name}`),
+	}, {
+		icon: 'ti ti-settings',
+		text: i18n.ts.settings,
+		action: () => {
+			configWidget(widget.id);
+		},
+	}], ev);
+}
+</script>
+
+<style lang="scss" scoped>
+.vjoppmmu {
+	container-type: inline-size;
+
+	> header {
+		margin: 16px 0;
+
+		> * {
+			width: 100%;
+			padding: 4px;
+		}
+	}
+
+	> .widget, .customize-container {
+		contain: content;
+		margin: var(--margin) 0;
+
+		&:first-of-type {
+			margin-top: 0;
+		}
+	}
+
+	.customize-container {
+		position: relative;
+		cursor: move;
+
+		> .config,
+		> .remove {
+			position: absolute;
+			z-index: 10000;
+			top: 8px;
+			width: 32px;
+			height: 32px;
+			color: #fff;
+			background: rgba(#000, 0.7);
+			border-radius: 4px;
+		}
+
+		> .config {
+			right: 8px + 8px + 32px;
+		}
+
+		> .remove {
+			right: 8px;
+		}
+
+		> .handle {
+			> .widget {
+				pointer-events: none;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
new file mode 100644
index 0000000000..629c105810
--- /dev/null
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -0,0 +1,571 @@
+<template>
+<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
+	<div v-if="showing" ref="rootEl" class="ebkgocck" :class="{ maximized }">
+		<div class="body _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
+			<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
+				<span class="left">
+					<button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
+				</span>
+				<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
+					<slot name="header"></slot>
+				</span>
+				<span class="right">
+					<button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
+					<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="button _button" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
+					<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="button _button" @click="maximize()"><i class="ti ti-rectangle"></i></button>
+					<button v-if="closeButton" v-tooltip="i18n.ts.close" class="button _button" @click="close()"><i class="ti ti-x"></i></button>
+				</span>
+			</div>
+			<div class="body">
+				<slot></slot>
+			</div>
+		</div>
+		<template v-if="canResize">
+			<div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
+			<div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
+			<div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
+			<div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
+			<div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
+			<div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
+			<div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
+			<div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
+		</template>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, provide } from 'vue';
+import contains from '@/scripts/contains';
+import * as os from '@/os';
+import { MenuItem } from '@/types/menu';
+import { i18n } from '@/i18n';
+
+const minHeight = 50;
+const minWidth = 250;
+
+function dragListen(fn: (ev: MouseEvent) => void) {
+	window.addEventListener('mousemove', fn);
+	window.addEventListener('touchmove', fn);
+	window.addEventListener('mouseleave', dragClear.bind(null, fn));
+	window.addEventListener('mouseup', dragClear.bind(null, fn));
+	window.addEventListener('touchend', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+	window.removeEventListener('mousemove', fn);
+	window.removeEventListener('touchmove', fn);
+	window.removeEventListener('mouseleave', dragClear);
+	window.removeEventListener('mouseup', dragClear);
+	window.removeEventListener('touchend', dragClear);
+}
+
+const props = withDefaults(defineProps<{
+	initialWidth?: number;
+	initialHeight?: number | null;
+	canResize?: boolean;
+	closeButton?: boolean;
+	mini?: boolean;
+	front?: boolean;
+	contextmenu?: MenuItem[] | null;
+	buttonsLeft?: any[];
+	buttonsRight?: any[];
+}>(), {
+	initialWidth: 400,
+	initialHeight: null,
+	canResize: false,
+	closeButton: true,
+	mini: false,
+	front: false,
+	contextmenu: null,
+	buttonsLeft: () => [],
+	buttonsRight: () => [],
+});
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+provide('inWindow', true);
+
+let rootEl = $ref<HTMLElement | null>();
+let showing = $ref(true);
+let beforeClickedAt = 0;
+let maximized = $ref(false);
+let unMaximizedTop = '';
+let unMaximizedLeft = '';
+let unMaximizedWidth = '';
+let unMaximizedHeight = '';
+
+function close() {
+	showing = false;
+}
+
+function onKeydown(evt) {
+	if (evt.which === 27) { // Esc
+		evt.preventDefault();
+		evt.stopPropagation();
+		close();
+	}
+}
+
+function onContextmenu(ev: MouseEvent) {
+	if (props.contextmenu) {
+		os.contextMenu(props.contextmenu, ev);
+	}
+}
+
+// 最前面へ移動
+function top() {
+	if (rootEl) {
+		rootEl.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low');
+	}
+}
+
+function maximize() {
+	maximized = true;
+	unMaximizedTop = rootEl.style.top;
+	unMaximizedLeft = rootEl.style.left;
+	unMaximizedWidth = rootEl.style.width;
+	unMaximizedHeight = rootEl.style.height;
+	rootEl.style.top = '0';
+	rootEl.style.left = '0';
+	rootEl.style.width = '100%';
+	rootEl.style.height = '100%';
+}
+
+function unMaximize() {
+	maximized = false;
+	rootEl.style.top = unMaximizedTop;
+	rootEl.style.left = unMaximizedLeft;
+	rootEl.style.width = unMaximizedWidth;
+	rootEl.style.height = unMaximizedHeight;
+}
+
+function onBodyMousedown() {
+	top();
+}
+
+function onDblClick() {
+	maximize();
+}
+
+function onHeaderMousedown(evt: MouseEvent) {
+	// 右クリックはコンテキストメニューを開こうとした可能性が高いため無視
+	if (evt.button === 2) return;
+
+	let beforeMaximized = false;
+
+	if (maximized) {
+		beforeMaximized = true;
+		unMaximize();
+	}
+
+	// ダブルクリック判定
+	if (Date.now() - beforeClickedAt < 300) {
+		beforeClickedAt = Date.now();
+		onDblClick();
+		return;
+	}
+
+	beforeClickedAt = Date.now();
+
+	const main = rootEl;
+	if (main == null) return;
+
+	if (!contains(main, document.activeElement)) main.focus();
+
+	const position = main.getBoundingClientRect();
+
+	const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
+	const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
+	const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
+	const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
+	const browserWidth = window.innerWidth;
+	const browserHeight = window.innerHeight;
+	const windowWidth = main.offsetWidth;
+	const windowHeight = main.offsetHeight;
+
+	function move(x: number, y: number) {
+		let moveLeft = x - moveBaseX;
+		let moveTop = y - moveBaseY;
+
+		// 下はみ出し
+		if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
+
+		// 左はみ出し
+		if (moveLeft < 0) moveLeft = 0;
+
+		// 上はみ出し
+		if (moveTop < 0) moveTop = 0;
+
+		// 右はみ出し
+		if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
+
+		rootEl.style.left = moveLeft + 'px';
+		rootEl.style.top = moveTop + 'px';
+	}
+
+	if (beforeMaximized) {
+		move(clickX, clickY);
+	}
+
+	// 動かした時
+	dragListen(me => {
+		const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX;
+		const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY;
+
+		move(x, y);
+	});
+}
+
+// 上ハンドル掴み時
+function onTopHandleMousedown(evt) {
+	const main = rootEl;
+	// どういうわけかnullになることがある
+	if (main == null) return;
+
+	const base = evt.clientY;
+	const height = parseInt(getComputedStyle(main, '').height, 10);
+	const top = parseInt(getComputedStyle(main, '').top, 10);
+
+	// 動かした時
+	dragListen(me => {
+		const move = me.clientY - base;
+		if (top + move > 0) {
+			if (height + -move > minHeight) {
+				applyTransformHeight(height + -move);
+				applyTransformTop(top + move);
+			} else { // 最小の高さより小さくなろうとした時
+				applyTransformHeight(minHeight);
+				applyTransformTop(top + (height - minHeight));
+			}
+		} else { // 上のはみ出し時
+			applyTransformHeight(top + height);
+			applyTransformTop(0);
+		}
+	});
+}
+
+// 右ハンドル掴み時
+function onRightHandleMousedown(evt) {
+	const main = rootEl;
+	if (main == null) return;
+
+	const base = evt.clientX;
+	const width = parseInt(getComputedStyle(main, '').width, 10);
+	const left = parseInt(getComputedStyle(main, '').left, 10);
+	const browserWidth = window.innerWidth;
+
+	// 動かした時
+	dragListen(me => {
+		const move = me.clientX - base;
+		if (left + width + move < browserWidth) {
+			if (width + move > minWidth) {
+				applyTransformWidth(width + move);
+			} else { // 最小の幅より小さくなろうとした時
+				applyTransformWidth(minWidth);
+			}
+		} else { // 右のはみ出し時
+			applyTransformWidth(browserWidth - left);
+		}
+	});
+}
+
+// 下ハンドル掴み時
+function onBottomHandleMousedown(evt) {
+	const main = rootEl;
+	if (main == null) return;
+
+	const base = evt.clientY;
+	const height = parseInt(getComputedStyle(main, '').height, 10);
+	const top = parseInt(getComputedStyle(main, '').top, 10);
+	const browserHeight = window.innerHeight;
+
+	// 動かした時
+	dragListen(me => {
+		const move = me.clientY - base;
+		if (top + height + move < browserHeight) {
+			if (height + move > minHeight) {
+				applyTransformHeight(height + move);
+			} else { // 最小の高さより小さくなろうとした時
+				applyTransformHeight(minHeight);
+			}
+		} else { // 下のはみ出し時
+			applyTransformHeight(browserHeight - top);
+		}
+	});
+}
+
+// 左ハンドル掴み時
+function onLeftHandleMousedown(evt) {
+	const main = rootEl;
+	if (main == null) return;
+
+	const base = evt.clientX;
+	const width = parseInt(getComputedStyle(main, '').width, 10);
+	const left = parseInt(getComputedStyle(main, '').left, 10);
+
+	// 動かした時
+	dragListen(me => {
+		const move = me.clientX - base;
+		if (left + move > 0) {
+			if (width + -move > minWidth) {
+				applyTransformWidth(width + -move);
+				applyTransformLeft(left + move);
+			} else { // 最小の幅より小さくなろうとした時
+				applyTransformWidth(minWidth);
+				applyTransformLeft(left + (width - minWidth));
+			}
+		} else { // 左のはみ出し時
+			applyTransformWidth(left + width);
+			applyTransformLeft(0);
+		}
+	});
+}
+
+// 左上ハンドル掴み時
+function onTopLeftHandleMousedown(evt) {
+	onTopHandleMousedown(evt);
+	onLeftHandleMousedown(evt);
+}
+
+// 右上ハンドル掴み時
+function onTopRightHandleMousedown(evt) {
+	onTopHandleMousedown(evt);
+	onRightHandleMousedown(evt);
+}
+
+// 右下ハンドル掴み時
+function onBottomRightHandleMousedown(evt) {
+	onBottomHandleMousedown(evt);
+	onRightHandleMousedown(evt);
+}
+
+// 左下ハンドル掴み時
+function onBottomLeftHandleMousedown(evt) {
+	onBottomHandleMousedown(evt);
+	onLeftHandleMousedown(evt);
+}
+
+// 高さを適用
+function applyTransformHeight(height) {
+	if (height > window.innerHeight) height = window.innerHeight;
+	rootEl.style.height = height + 'px';
+}
+
+// 幅を適用
+function applyTransformWidth(width) {
+	if (width > window.innerWidth) width = window.innerWidth;
+	rootEl.style.width = width + 'px';
+}
+
+// Y座標を適用
+function applyTransformTop(top) {
+	rootEl.style.top = top + 'px';
+}
+
+// X座標を適用
+function applyTransformLeft(left) {
+	rootEl.style.left = left + 'px';
+}
+
+function onBrowserResize() {
+	const main = rootEl;
+	if (main == null) return;
+
+	const position = main.getBoundingClientRect();
+	const browserWidth = window.innerWidth;
+	const browserHeight = window.innerHeight;
+	const windowWidth = main.offsetWidth;
+	const windowHeight = main.offsetHeight;
+	if (position.left < 0) main.style.left = '0'; // 左はみ出し
+	if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
+	if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
+	if (position.top < 0) main.style.top = '0'; // 上はみ出し
+}
+
+onMounted(() => {
+	if (props.initialWidth) applyTransformWidth(props.initialWidth);
+	if (props.initialHeight) applyTransformHeight(props.initialHeight);
+
+	applyTransformTop((window.innerHeight / 2) - (rootEl.offsetHeight / 2));
+	applyTransformLeft((window.innerWidth / 2) - (rootEl.offsetWidth / 2));
+
+	// 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする
+	top();
+
+	window.addEventListener('resize', onBrowserResize);
+});
+
+onBeforeUnmount(() => {
+	window.removeEventListener('resize', onBrowserResize);
+});
+
+defineExpose({
+	close,
+});
+</script>
+
+<style lang="scss" scoped>
+.window-enter-active, .window-leave-active {
+	transition: opacity 0.2s, transform 0.2s !important;
+}
+.window-enter-from, .window-leave-to {
+	pointer-events: none;
+	opacity: 0;
+	transform: scale(0.9);
+}
+
+.ebkgocck {
+	position: fixed;
+	top: 0;
+	left: 0;
+
+	> .body {
+		overflow: clip;
+		display: flex;
+		flex-direction: column;
+		contain: content;
+		width: 100%;
+		height: 100%;
+		border-radius: var(--radius);
+
+		> .header {
+			--height: 39px;
+
+			&.mini {
+				--height: 32px;
+			}
+
+			display: flex;
+			position: relative;
+			z-index: 1;
+			flex-shrink: 0;
+			user-select: none;
+			height: var(--height);
+			background: var(--windowHeader);
+			-webkit-backdrop-filter: var(--blur, blur(15px));
+			backdrop-filter: var(--blur, blur(15px));
+			//border-bottom: solid 1px var(--divider);
+			font-size: 95%;
+			font-weight: bold;
+
+			> .left, > .right {
+				> .button {
+					height: var(--height);
+					width: var(--height);
+
+					&:hover {
+						color: var(--fgHighlighted);
+					}
+
+					&.highlighted {
+						color: var(--accent);
+					}
+				}
+			}
+
+			> .left {
+				margin-right: 16px;
+			}
+
+			> .right {
+				min-width: 16px;
+			}
+
+			> .title {
+				flex: 1;
+				position: relative;
+				line-height: var(--height);
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+				cursor: move;
+			}
+		}
+
+		> .body {
+			flex: 1;
+			overflow: auto;
+			background: var(--panel);
+		}
+	}
+
+	> .handle {
+		$size: 8px;
+
+		position: absolute;
+
+		&.top {
+			top: -($size);
+			left: 0;
+			width: 100%;
+			height: $size;
+			cursor: ns-resize;
+		}
+
+		&.right {
+			top: 0;
+			right: -($size);
+			width: $size;
+			height: 100%;
+			cursor: ew-resize;
+		}
+
+		&.bottom {
+			bottom: -($size);
+			left: 0;
+			width: 100%;
+			height: $size;
+			cursor: ns-resize;
+		}
+
+		&.left {
+			top: 0;
+			left: -($size);
+			width: $size;
+			height: 100%;
+			cursor: ew-resize;
+		}
+
+		&.top-left {
+			top: -($size);
+			left: -($size);
+			width: $size * 2;
+			height: $size * 2;
+			cursor: nwse-resize;
+		}
+
+		&.top-right {
+			top: -($size);
+			right: -($size);
+			width: $size * 2;
+			height: $size * 2;
+			cursor: nesw-resize;
+		}
+
+		&.bottom-right {
+			bottom: -($size);
+			right: -($size);
+			width: $size * 2;
+			height: $size * 2;
+			cursor: nwse-resize;
+		}
+
+		&.bottom-left {
+			bottom: -($size);
+			left: -($size);
+			width: $size * 2;
+			height: $size * 2;
+			cursor: nesw-resize;
+		}
+	}
+
+	&.maximized {
+		> .body {
+			border-radius: 0;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkYoutubePlayer.vue b/packages/frontend/src/components/MkYoutubePlayer.vue
new file mode 100644
index 0000000000..8cd481a39c
--- /dev/null
+++ b/packages/frontend/src/components/MkYoutubePlayer.vue
@@ -0,0 +1,72 @@
+<template>
+<XWindow :initial-width="640" :initial-height="402" :can-resize="true" :close-button="true">
+	<template #header>
+		<i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i>
+		<span>{{ title ?? 'YouTube' }}</span>
+	</template>
+
+	<div class="poamfof">
+		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+			<div v-if="player.url" class="player">
+				<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
+			</div>
+		</transition>
+		<MkLoading v-if="fetching"/>
+		<MkError v-else-if="!player.url" @retry="ytFetch()"/>
+	</div>
+</XWindow>
+</template>
+
+<script lang="ts" setup>
+import XWindow from '@/components/MkWindow.vue';
+import { lang } from '@/config';
+
+const props = defineProps<{
+	url: string;
+}>();
+
+const requestUrl = new URL(props.url);
+
+let fetching = $ref(true);
+let title = $ref<string | null>(null);
+let player = $ref({
+	url: null,
+	width: null,
+	height: null,
+});
+
+const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
+
+const ytFetch = (): void => {
+	fetching = true;
+	window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
+		res.json().then(info => {
+			if (info.url == null) return;
+			title = info.title;
+			fetching = false;
+			player = info.player;
+		});
+	});
+};
+
+ytFetch();
+
+</script>
+
+<style lang="scss">
+.poamfof {
+	position: relative;
+	overflow: hidden;
+	height: 100%;
+
+	.player {
+		position: absolute;
+		inset: 0;
+
+		iframe {
+			width: 100%;
+			height: 100%;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/checkbox.vue b/packages/frontend/src/components/form/checkbox.vue
new file mode 100644
index 0000000000..ba3b2dc146
--- /dev/null
+++ b/packages/frontend/src/components/form/checkbox.vue
@@ -0,0 +1,144 @@
+<template>
+<div
+	class="ziffeoms"
+	:class="{ disabled, checked }"
+>
+	<input
+		ref="input"
+		type="checkbox"
+		:disabled="disabled"
+		@keydown.enter="toggle"
+	>
+	<span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+		<i class="check ti ti-check"></i>
+	</span>
+	<span class="label">
+		<!-- TODO: 無名slotの方は廃止 -->
+		<span @click="toggle"><slot name="label"></slot><slot></slot></span>
+		<p class="caption"><slot name="caption"></slot></p>
+	</span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { toRefs, Ref } from 'vue';
+import * as os from '@/os';
+import Ripple from '@/components/MkRipple.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: boolean | Ref<boolean>;
+	disabled?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', v: boolean): void;
+}>();
+
+let button = $ref<HTMLElement>();
+const checked = toRefs(props).modelValue;
+const toggle = () => {
+	if (props.disabled) return;
+	emit('update:modelValue', !checked.value);
+
+	if (!checked.value) {
+		const rect = button.getBoundingClientRect();
+		const x = rect.left + (button.offsetWidth / 2);
+		const y = rect.top + (button.offsetHeight / 2);
+		os.popup(Ripple, { x, y, particle: false }, {}, 'end');
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.ziffeoms {
+	position: relative;
+	display: flex;
+	transition: all 0.2s ease;
+
+	> * {
+		user-select: none;
+	}
+
+	> input {
+		position: absolute;
+		width: 0;
+		height: 0;
+		opacity: 0;
+		margin: 0;
+	}
+
+	> .button {
+		position: relative;
+		display: inline-flex;
+		flex-shrink: 0;
+		margin: 0;
+		box-sizing: border-box;
+		width: 23px;
+		height: 23px;
+		outline: none;
+		background: var(--panel);
+		border: solid 1px var(--panel);
+		border-radius: 4px;
+		cursor: pointer;
+		transition: inherit;
+
+		> .check {
+			margin: auto;
+			opacity: 0;
+			color: var(--fgOnAccent);
+			font-size: 13px;
+			transform: scale(0.5);
+			transition: all 0.2s ease;
+		}
+	}
+
+	&:hover {
+		> .button {
+			border-color: var(--inputBorderHover) !important;
+		}
+	}
+
+	> .label {
+		margin-left: 12px;
+		margin-top: 2px;
+		display: block;
+		transition: inherit;
+		color: var(--fg);
+
+		> span {
+			display: block;
+			line-height: 20px;
+			cursor: pointer;
+			transition: inherit;
+		}
+
+		> .caption {
+			margin: 8px 0 0 0;
+			color: var(--fgTransparentWeak);
+			font-size: 0.85em;
+
+			&:empty {
+				display: none;
+			}
+		}
+	}
+
+	&.disabled {
+		opacity: 0.6;
+		cursor: not-allowed;
+	}
+
+	&.checked {
+		> .button {
+			background-color: var(--accent) !important;
+			border-color: var(--accent) !important;
+
+			> .check {
+				opacity: 1;
+				transform: scale(1);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/folder.vue b/packages/frontend/src/components/form/folder.vue
new file mode 100644
index 0000000000..1256dfcbb4
--- /dev/null
+++ b/packages/frontend/src/components/form/folder.vue
@@ -0,0 +1,107 @@
+<template>
+<div class="dwzlatin" :class="{ opened }">
+	<div class="header _button" @click="toggle">
+		<span class="icon"><slot name="icon"></slot></span>
+		<span class="text"><slot name="label"></slot></span>
+		<span class="right">
+			<span class="text"><slot name="suffix"></slot></span>
+			<i v-if="opened" class="ti ti-chevron-up icon"></i>
+			<i v-else class="ti ti-chevron-down icon"></i>
+		</span>
+	</div>
+	<KeepAlive>
+		<div v-if="openedAtLeastOnce" v-show="opened" class="body">
+			<MkSpacer :margin-min="14" :margin-max="22">
+				<slot></slot>
+			</MkSpacer>
+		</div>
+	</KeepAlive>
+</div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+	defaultOpen: boolean;
+}>(), {
+	defaultOpen: false,
+});
+
+let opened = $ref(props.defaultOpen);
+let openedAtLeastOnce = $ref(props.defaultOpen);
+
+const toggle = () => {
+	opened = !opened;
+	if (opened) {
+		openedAtLeastOnce = true;
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.dwzlatin {
+	display: block;
+
+	> .header {
+		display: flex;
+		align-items: center;
+		width: 100%;
+		box-sizing: border-box;
+		padding: 10px 14px 10px 14px;
+		background: var(--buttonBg);
+		border-radius: 6px;
+
+		&:hover {
+			text-decoration: none;
+			background: var(--buttonHoverBg);
+		}
+
+		&.active {
+			color: var(--accent);
+			background: var(--buttonHoverBg);
+		}
+
+		> .icon {
+			margin-right: 0.75em;
+			flex-shrink: 0;
+			text-align: center;
+			opacity: 0.8;
+
+			&:empty {
+				display: none;
+
+				& + .text {
+					padding-left: 4px;
+				}
+			}
+		}
+
+		> .text {
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			overflow: hidden;
+			padding-right: 12px;
+		}
+
+		> .right {
+			margin-left: auto;
+			opacity: 0.7;
+			white-space: nowrap;
+
+			> .text:not(:empty) {
+				margin-right: 0.75em;
+			}
+		}
+	}
+
+	> .body {
+		background: var(--panel);
+		border-radius: 0 0 6px 6px;
+	}
+
+	&.opened {
+		> .header {
+			border-radius: 6px 6px 0 0;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/input.vue b/packages/frontend/src/components/form/input.vue
new file mode 100644
index 0000000000..939e9691a6
--- /dev/null
+++ b/packages/frontend/src/components/form/input.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="matxzzsk">
+	<div class="label" @click="focus"><slot name="label"></slot></div>
+	<div class="input" :class="{ inline, disabled, focused }">
+		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
+		<input
+			ref="inputEl"
+			v-model="v"
+			v-adaptive-border
+			:type="type"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			:placeholder="placeholder"
+			:pattern="pattern"
+			:autocomplete="autocomplete"
+			:spellcheck="spellcheck"
+			:step="step"
+			:list="id"
+			@focus="focused = true"
+			@blur="focused = false"
+			@keydown="onKeydown($event)"
+			@input="onInput"
+		>
+		<datalist v-if="datalist" :id="id">
+			<option v-for="data in datalist" :value="data"/>
+		</datalist>
+		<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
+	</div>
+	<div class="caption"><slot name="caption"></slot></div>
+
+	<MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/MkButton.vue';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: string | number;
+	type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search';
+	required?: boolean;
+	readonly?: boolean;
+	disabled?: boolean;
+	pattern?: string;
+	placeholder?: string;
+	autofocus?: boolean;
+	autocomplete?: boolean;
+	spellcheck?: boolean;
+	step?: any;
+	datalist?: string[];
+	inline?: boolean;
+	debounce?: boolean;
+	manualSave?: boolean;
+	small?: boolean;
+	large?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'change', _ev: KeyboardEvent): void;
+	(ev: 'keydown', _ev: KeyboardEvent): void;
+	(ev: 'enter'): void;
+	(ev: 'update:modelValue', value: string | number): void;
+}>();
+
+const { modelValue, type, autofocus } = toRefs(props);
+const v = ref(modelValue.value);
+const id = Math.random().toString(); // TODO: uuid?
+const focused = ref(false);
+const changed = ref(false);
+const invalid = ref(false);
+const filled = computed(() => v.value !== '' && v.value != null);
+const inputEl = ref<HTMLElement>();
+const prefixEl = ref<HTMLElement>();
+const suffixEl = ref<HTMLElement>();
+const height =
+	props.small ? 36 :
+	props.large ? 40 :
+	38;
+
+const focus = () => inputEl.value.focus();
+const onInput = (ev: KeyboardEvent) => {
+	changed.value = true;
+	emit('change', ev);
+};
+const onKeydown = (ev: KeyboardEvent) => {
+	emit('keydown', ev);
+
+	if (ev.code === 'Enter') {
+		emit('enter');
+	}
+};
+
+const updated = () => {
+	changed.value = false;
+	if (type.value === 'number') {
+		emit('update:modelValue', parseFloat(v.value));
+	} else {
+		emit('update:modelValue', v.value);
+	}
+};
+
+const debouncedUpdated = debounce(1000, updated);
+
+watch(modelValue, newValue => {
+	v.value = newValue;
+});
+
+watch(v, newValue => {
+	if (!props.manualSave) {
+		if (props.debounce) {
+			debouncedUpdated();
+		} else {
+			updated();
+		}
+	}
+
+	invalid.value = inputEl.value.validity.badInput;
+});
+
+// このコンポーネントが作成された時、非表示状態である場合がある
+// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+useInterval(() => {
+	if (prefixEl.value) {
+		if (prefixEl.value.offsetWidth) {
+			inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+		}
+	}
+	if (suffixEl.value) {
+		if (suffixEl.value.offsetWidth) {
+			inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+		}
+	}
+}, 100, {
+	immediate: true,
+	afterMounted: true,
+});
+
+onMounted(() => {
+	nextTick(() => {
+		if (autofocus.value) {
+			focus();
+		}
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+.matxzzsk {
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .input {
+		position: relative;
+
+		> input {
+			appearance: none;
+			-webkit-appearance: none;
+			display: block;
+			height: v-bind("height + 'px'");
+			width: 100%;
+			margin: 0;
+			padding: 0 12px;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			color: var(--fg);
+			background: var(--panel);
+			border: solid 1px var(--panel);
+			border-radius: 6px;
+			outline: none;
+			box-shadow: none;
+			box-sizing: border-box;
+			transition: border-color 0.1s ease-out;
+
+			&:hover {
+				border-color: var(--inputBorderHover) !important;
+			}
+		}
+
+		> .prefix,
+		> .suffix {
+			display: flex;
+			align-items: center;
+			position: absolute;
+			z-index: 1;
+			top: 0;
+			padding: 0 12px;
+			font-size: 1em;
+			height: v-bind("height + 'px'");
+			pointer-events: none;
+
+			&:empty {
+				display: none;
+			}
+
+			> * {
+				display: inline-block;
+				min-width: 16px;
+				max-width: 150px;
+				overflow: hidden;
+				white-space: nowrap;
+				text-overflow: ellipsis;
+			}
+		}
+
+		> .prefix {
+			left: 0;
+			padding-right: 6px;
+		}
+
+		> .suffix {
+			right: 0;
+			padding-left: 6px;
+		}
+
+		&.inline {
+			display: inline-block;
+			margin: 0;
+		}
+
+		&.focused {
+			> input {
+				border-color: var(--accent) !important;
+				//box-shadow: 0 0 0 4px var(--focus);
+			}
+		}
+
+		&.disabled {
+			opacity: 0.7;
+
+			&, * {
+				cursor: not-allowed !important;
+			}
+		}
+	}
+
+	> .save {
+		margin: 8px 0 0 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue
new file mode 100644
index 0000000000..a1775c0bdb
--- /dev/null
+++ b/packages/frontend/src/components/form/link.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="ffcbddfc" :class="{ inline }">
+	<a v-if="external" class="main _button" :href="to" target="_blank">
+		<span class="icon"><slot name="icon"></slot></span>
+		<span class="text"><slot></slot></span>
+		<span class="right">
+			<span class="text"><slot name="suffix"></slot></span>
+			<i class="ti ti-external-link icon"></i>
+		</span>
+	</a>
+	<MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior">
+		<span class="icon"><slot name="icon"></slot></span>
+		<span class="text"><slot></slot></span>
+		<span class="right">
+			<span class="text"><slot name="suffix"></slot></span>
+			<i class="ti ti-chevron-right icon"></i>
+		</span>
+	</MkA>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	to: string;
+	active?: boolean;
+	external?: boolean;
+	behavior?: null | 'window' | 'browser' | 'modalWindow';
+	inline?: boolean;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.ffcbddfc {
+	display: block;
+
+	&.inline {
+		display: inline-block;
+	}
+
+	> .main {
+		display: flex;
+		align-items: center;
+		width: 100%;
+		box-sizing: border-box;
+		padding: 10px 14px;
+		background: var(--buttonBg);
+		border-radius: 6px;
+		font-size: 0.9em;
+
+		&:hover {
+			text-decoration: none;
+			background: var(--buttonHoverBg);
+		}
+
+		&.active {
+			color: var(--accent);
+			background: var(--buttonHoverBg);
+		}
+
+		> .icon {
+			margin-right: 0.75em;
+			flex-shrink: 0;
+			text-align: center;
+			color: var(--fgTransparentWeak);
+
+			&:empty {
+				display: none;
+
+				& + .text {
+					padding-left: 4px;
+				}
+			}
+		}
+
+		> .text {
+			flex-shrink: 1;
+			white-space: normal;
+			padding-right: 12px;
+			text-align: center;
+		}
+
+		> .right {
+			margin-left: auto;
+			opacity: 0.7;
+			white-space: nowrap;
+
+			> .text:not(:empty) {
+				margin-right: 0.75em;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/radio.vue b/packages/frontend/src/components/form/radio.vue
new file mode 100644
index 0000000000..fcf454c77a
--- /dev/null
+++ b/packages/frontend/src/components/form/radio.vue
@@ -0,0 +1,132 @@
+<template>
+<div
+	v-adaptive-border
+	class="novjtctn"
+	:class="{ disabled, checked }"
+	:aria-checked="checked"
+	:aria-disabled="disabled"
+	@click="toggle"
+>
+	<input
+		type="radio"
+		:disabled="disabled"
+	>
+	<span class="button">
+		<span></span>
+	</span>
+	<span class="label"><slot></slot></span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	modelValue: any;
+	value: any;
+	disabled: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+let checked = $computed(() => props.modelValue === props.value);
+
+function toggle(): void {
+	if (props.disabled) return;
+	emit('update:modelValue', props.value);
+}
+</script>
+
+<style lang="scss" scoped>
+.novjtctn {
+	position: relative;
+	display: inline-block;
+	text-align: left;
+	cursor: pointer;
+	padding: 7px 10px;
+	min-width: 60px;
+	background-color: var(--panel);
+	background-clip: padding-box !important;
+	border: solid 1px var(--panel);
+	border-radius: 6px;
+	font-size: 90%;
+	transition: all 0.2s;
+
+	> * {
+		user-select: none;
+	}
+
+	&.disabled {
+		opacity: 0.6;
+
+		&, * {
+			cursor: not-allowed !important;
+		}
+	}
+
+	&:hover {
+		border-color: var(--inputBorderHover) !important;
+	}
+
+	&.checked {
+		background-color: var(--accentedBg) !important;
+		border-color: var(--accentedBg) !important;
+		color: var(--accent);
+
+		&, * {
+			cursor: default !important;
+		}
+
+		> .button {
+			border-color: var(--accent);
+
+			&:after {
+				background-color: var(--accent);
+				transform: scale(1);
+				opacity: 1;
+			}
+		}
+	}
+
+	> input {
+		position: absolute;
+		width: 0;
+		height: 0;
+		opacity: 0;
+		margin: 0;
+	}
+
+	> .button {
+		position: absolute;
+		width: 14px;
+		height: 14px;
+		background: none;
+		border: solid 2px var(--inputBorder);
+		border-radius: 100%;
+		transition: inherit;
+
+		&:after {
+			content: '';
+			display: block;
+			position: absolute;
+			top: 3px;
+			right: 3px;
+			bottom: 3px;
+			left: 3px;
+			border-radius: 100%;
+			opacity: 0;
+			transform: scale(0);
+			transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
+		}
+	}
+
+	> .label {
+		margin-left: 28px;
+		display: block;
+		line-height: 20px;
+		cursor: pointer;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/radios.vue b/packages/frontend/src/components/form/radios.vue
new file mode 100644
index 0000000000..bde4a8fb00
--- /dev/null
+++ b/packages/frontend/src/components/form/radios.vue
@@ -0,0 +1,83 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from './radio.vue';
+
+export default defineComponent({
+	components: {
+		MkRadio,
+	},
+	props: {
+		modelValue: {
+			required: false,
+		},
+	},
+	data() {
+		return {
+			value: this.modelValue,
+		};
+	},
+	watch: {
+		value() {
+			this.$emit('update:modelValue', this.value);
+		},
+	},
+	render() {
+		let options = this.$slots.default();
+		const label = this.$slots.label && this.$slots.label();
+		const caption = this.$slots.caption && this.$slots.caption();
+
+		// なぜかFragmentになることがあるため
+		if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+		return h('div', {
+			class: 'novjtcto',
+		}, [
+			...(label ? [h('div', {
+				class: 'label',
+			}, [label])] : []),
+			h('div', {
+				class: 'body',
+			}, options.map(option => h(MkRadio, {
+				key: option.key,
+				value: option.props.value,
+				modelValue: this.value,
+				'onUpdate:modelValue': value => this.value = value,
+			}, option.children)),
+			),
+			...(caption ? [h('div', {
+				class: 'caption',
+			}, [caption])] : []),
+		]);
+	},
+});
+</script>
+
+<style lang="scss">
+.novjtcto {
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .body {
+		display: flex;
+    gap: 12px;
+    flex-wrap: wrap;
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/range.vue b/packages/frontend/src/components/form/range.vue
new file mode 100644
index 0000000000..db21c35717
--- /dev/null
+++ b/packages/frontend/src/components/form/range.vue
@@ -0,0 +1,259 @@
+<template>
+<div class="timctyfi" :class="{ disabled, easing }">
+	<div class="label"><slot name="label"></slot></div>
+	<div v-adaptive-border class="body">
+		<div ref="containerEl" class="container">
+			<div class="track">
+				<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
+			</div>
+			<div v-if="steps && showTicks" class="ticks">
+				<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
+			</div>
+			<div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div>
+		</div>
+	</div>
+	<div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+	modelValue: number;
+	disabled?: boolean;
+	min: number;
+	max: number;
+	step?: number;
+	textConverter?: (value: number) => string,
+	showTicks?: boolean;
+	easing?: boolean;
+}>(), {
+	step: 1,
+	textConverter: (v) => v.toString(),
+	easing: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: number): void;
+}>();
+
+const containerEl = ref<HTMLElement>();
+const thumbEl = ref<HTMLElement>();
+
+const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
+const steppedRawValue = computed(() => {
+	if (props.step) {
+		const step = props.step / (props.max - props.min);
+		return (step * Math.round(rawValue.value / step));
+	} else {
+		return rawValue.value;
+	}
+});
+const finalValue = computed(() => {
+	if (Number.isInteger(props.step)) {
+		return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min);
+	} else {
+		return (steppedRawValue.value * (props.max - props.min)) + props.min;
+	}
+});
+
+const thumbWidth = computed(() => {
+	if (thumbEl.value == null) return 0;
+	return thumbEl.value!.offsetWidth;
+});
+const thumbPosition = ref(0);
+const calcThumbPosition = () => {
+	if (containerEl.value == null) {
+		thumbPosition.value = 0;
+	} else {
+		thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value;
+	}
+};
+watch([steppedRawValue, containerEl], calcThumbPosition);
+
+let ro: ResizeObserver | undefined;
+
+onMounted(() => {
+	ro = new ResizeObserver((entries, observer) => {
+		calcThumbPosition();
+	});
+	ro.observe(containerEl.value);
+});
+
+onUnmounted(() => {
+	if (ro) ro.disconnect();
+});
+
+const steps = computed(() => {
+	if (props.step) {
+		return (props.max - props.min) / props.step;
+	} else {
+		return 0;
+	}
+});
+
+const onMousedown = (ev: MouseEvent | TouchEvent) => {
+	ev.preventDefault();
+
+	const tooltipShowing = ref(true);
+	os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
+		showing: tooltipShowing,
+		text: computed(() => {
+			return props.textConverter(finalValue.value);
+		}),
+		targetElement: thumbEl,
+	}, {}, 'closed');
+
+	const style = document.createElement('style');
+	style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
+	document.head.appendChild(style);
+
+	const onDrag = (ev: MouseEvent | TouchEvent) => {
+		ev.preventDefault();
+		const containerRect = containerEl.value!.getBoundingClientRect();
+		const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
+		const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2));
+		rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value)));
+	};
+
+	let beforeValue = finalValue.value;
+
+	const onMouseup = () => {
+		document.head.removeChild(style);
+		tooltipShowing.value = false;
+		window.removeEventListener('mousemove', onDrag);
+		window.removeEventListener('touchmove', onDrag);
+		window.removeEventListener('mouseup', onMouseup);
+		window.removeEventListener('touchend', onMouseup);
+
+		// 値が変わってたら通知
+		if (beforeValue !== finalValue.value) {
+			emit('update:modelValue', finalValue.value);
+		}
+	};
+
+	window.addEventListener('mousemove', onDrag);
+	window.addEventListener('touchmove', onDrag);
+	window.addEventListener('mouseup', onMouseup, { once: true });
+	window.addEventListener('touchend', onMouseup, { once: true });
+};
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+.timctyfi {
+	position: relative;
+
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	$thumbHeight: 20px;
+	$thumbWidth: 20px;
+
+	> .body {
+		padding: 10px 12px;
+		background: var(--panel);
+		border: solid 1px var(--panel);
+		border-radius: 6px;
+
+		> .container {
+			position: relative;
+			height: $thumbHeight;
+
+			> .track {
+				position: absolute;
+				top: 0;
+				bottom: 0;
+				left: 0;
+				right: 0;
+				margin: auto;
+				width: calc(100% - #{$thumbWidth});
+				height: 3px;
+				background: rgba(0, 0, 0, 0.1);
+				border-radius: 999px;
+				overflow: clip;
+
+				> .highlight {
+					position: absolute;
+					top: 0;
+					left: 0;
+					height: 100%;
+					background: var(--accent);
+					opacity: 0.5;
+				}
+			}
+
+			> .ticks {
+				$tickWidth: 3px;
+
+				position: absolute;
+				top: 0;
+				bottom: 0;
+				left: 0;
+				right: 0;
+				margin: auto;
+				width: calc(100% - #{$thumbWidth});
+
+				> .tick {
+					position: absolute;
+					bottom: 0;
+					width: $tickWidth;
+					height: 3px;
+					margin-left: - math.div($tickWidth, 2);
+					background: var(--divider);
+					border-radius: 999px;
+				}
+			}
+
+			> .thumb {
+				position: absolute;
+				width: $thumbWidth;
+				height: $thumbHeight;
+				cursor: grab;
+				background: var(--accent);
+				border-radius: 999px;
+
+				&:hover {
+					background: var(--accentLighten);
+				}
+			}
+		}
+	}
+
+	&.easing {
+		> .body {
+			> .container {
+				> .track {
+					> .highlight {
+						transition: width 0.2s cubic-bezier(0,0,0,1);
+					}
+				}
+
+				> .thumb {
+					transition: left 0.2s cubic-bezier(0,0,0,1);
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue
new file mode 100644
index 0000000000..c6e34ef1cc
--- /dev/null
+++ b/packages/frontend/src/components/form/section.vue
@@ -0,0 +1,43 @@
+<template>
+<div class="vrtktovh _formBlock">
+	<div class="label"><slot name="label"></slot></div>
+	<div class="main _formRoot">
+		<slot></slot>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+</script>
+
+<style lang="scss" scoped>
+.vrtktovh {
+	border-top: solid 0.5px var(--divider);
+	border-bottom: solid 0.5px var(--divider);
+
+	& + .vrtktovh {
+		border-top: none;
+	}
+
+	&:first-child {
+		border-top: none;
+	}
+
+	&:last-child {
+		border-bottom: none;
+	}
+
+	> .label {
+		font-weight: bold;
+		margin: 1.5em 0 16px 0;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .main {
+		margin: 1.5em 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/select.vue b/packages/frontend/src/components/form/select.vue
new file mode 100644
index 0000000000..eaf4b131cd
--- /dev/null
+++ b/packages/frontend/src/components/form/select.vue
@@ -0,0 +1,279 @@
+<template>
+<div class="vblkjoeq">
+	<div class="label" @click="focus"><slot name="label"></slot></div>
+	<div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
+		<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
+		<select
+			ref="inputEl"
+			v-model="v"
+			v-adaptive-border
+			class="select"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			:placeholder="placeholder"
+			@focus="focused = true"
+			@blur="focused = false"
+			@input="onInput"
+		>
+			<slot></slot>
+		</select>
+		<div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down"></i></div>
+	</div>
+	<div class="caption"><slot name="caption"></slot></div>
+
+	<MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: string;
+	required?: boolean;
+	readonly?: boolean;
+	disabled?: boolean;
+	placeholder?: string;
+	autofocus?: boolean;
+	inline?: boolean;
+	manualSave?: boolean;
+	small?: boolean;
+	large?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'change', _ev: KeyboardEvent): void;
+	(ev: 'update:modelValue', value: string): void;
+}>();
+
+const slots = useSlots();
+
+const { modelValue, autofocus } = toRefs(props);
+const v = ref(modelValue.value);
+const focused = ref(false);
+const changed = ref(false);
+const invalid = ref(false);
+const filled = computed(() => v.value !== '' && v.value != null);
+const inputEl = ref(null);
+const prefixEl = ref(null);
+const suffixEl = ref(null);
+const container = ref(null);
+const height =
+	props.small ? 36 :
+	props.large ? 40 :
+	38;
+
+const focus = () => inputEl.value.focus();
+const onInput = (ev) => {
+	changed.value = true;
+	emit('change', ev);
+};
+
+const updated = () => {
+	changed.value = false;
+	emit('update:modelValue', v.value);
+};
+
+watch(modelValue, newValue => {
+	v.value = newValue;
+});
+
+watch(v, newValue => {
+	if (!props.manualSave) {
+		updated();
+	}
+
+	invalid.value = inputEl.value.validity.badInput;
+});
+
+// このコンポーネントが作成された時、非表示状態である場合がある
+// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+useInterval(() => {
+	if (prefixEl.value) {
+		if (prefixEl.value.offsetWidth) {
+			inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+		}
+	}
+	if (suffixEl.value) {
+		if (suffixEl.value.offsetWidth) {
+			inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+		}
+	}
+}, 100, {
+	immediate: true,
+	afterMounted: true,
+});
+
+onMounted(() => {
+	nextTick(() => {
+		if (autofocus.value) {
+			focus();
+		}
+	});
+});
+
+const onClick = (ev: MouseEvent) => {
+	focused.value = true;
+
+	const menu = [];
+	let options = slots.default!();
+
+	const pushOption = (option: VNode) => {
+		menu.push({
+			text: option.children,
+			active: v.value === option.props.value,
+			action: () => {
+				v.value = option.props.value;
+			},
+		});
+	};
+
+	const scanOptions = (options: VNode[]) => {
+		for (const vnode of options) {
+			if (vnode.type === 'optgroup') {
+				const optgroup = vnode;
+				menu.push({
+					type: 'label',
+					text: optgroup.props.label,
+				});
+				scanOptions(optgroup.children);
+			} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
+				const fragment = vnode;
+				scanOptions(fragment.children);
+			} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
+				// nop?
+			} else {
+				const option = vnode;
+				pushOption(option);
+			}
+		}
+	};
+
+	scanOptions(options);
+
+	os.popupMenu(menu, container.value, {
+		width: container.value.offsetWidth,
+	}).then(() => {
+		focused.value = false;
+	});
+};
+</script>
+
+<style lang="scss" scoped>
+.vblkjoeq {
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .input {
+		position: relative;
+		cursor: pointer;
+
+		&:hover {
+			> .select {
+				border-color: var(--inputBorderHover) !important;
+			}
+		}
+
+		> .select {
+			appearance: none;
+			-webkit-appearance: none;
+			display: block;
+			height: v-bind("height + 'px'");
+			width: 100%;
+			margin: 0;
+			padding: 0 12px;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			color: var(--fg);
+			background: var(--panel);
+			border: solid 1px var(--panel);
+			border-radius: 6px;
+			outline: none;
+			box-shadow: none;
+			box-sizing: border-box;
+			cursor: pointer;
+			transition: border-color 0.1s ease-out;
+			pointer-events: none;
+			user-select: none;
+		}
+
+		> .prefix,
+		> .suffix {
+			display: flex;
+			align-items: center;
+			position: absolute;
+			z-index: 1;
+			top: 0;
+			padding: 0 12px;
+			font-size: 1em;
+			height: v-bind("height + 'px'");
+			pointer-events: none;
+
+			&:empty {
+				display: none;
+			}
+
+			> * {
+				display: inline-block;
+				min-width: 16px;
+				max-width: 150px;
+				overflow: hidden;
+				white-space: nowrap;
+				text-overflow: ellipsis;
+			}
+		}
+
+		> .prefix {
+			left: 0;
+			padding-right: 6px;
+		}
+
+		> .suffix {
+			right: 0;
+			padding-left: 6px;
+		}
+
+		&.inline {
+			display: inline-block;
+			margin: 0;
+		}
+
+		&.focused {
+			> select {
+				border-color: var(--accent) !important;
+			}
+		}
+
+		&.disabled {
+			opacity: 0.7;
+
+			&, * {
+				cursor: not-allowed !important;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue
new file mode 100644
index 0000000000..79ce8fe51f
--- /dev/null
+++ b/packages/frontend/src/components/form/slot.vue
@@ -0,0 +1,41 @@
+<template>
+<div class="adhpbeou">
+	<div class="label" @click="focus"><slot name="label"></slot></div>
+	<div class="content">
+		<slot></slot>
+	</div>
+	<div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+function focus() {
+	// TODO
+}
+</script>
+
+<style lang="scss" scoped>
+.adhpbeou {
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/split.vue b/packages/frontend/src/components/form/split.vue
new file mode 100644
index 0000000000..301a8a84e5
--- /dev/null
+++ b/packages/frontend/src/components/form/split.vue
@@ -0,0 +1,27 @@
+<template>
+<div class="terlnhxf _formBlock">
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+	minWidth?: number;
+}>(), {
+	minWidth: 210,
+});
+
+const minWidth = props.minWidth + 'px';
+</script>
+
+<style lang="scss" scoped>
+.terlnhxf {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(v-bind('minWidth'), 1fr));
+	grid-gap: 12px;
+
+	> ::v-deep(*) {
+		margin: 0 !important;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue
new file mode 100644
index 0000000000..7efa501f27
--- /dev/null
+++ b/packages/frontend/src/components/form/suspense.vue
@@ -0,0 +1,98 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+	<div v-if="pending">
+		<MkLoading/>
+	</div>
+	<div v-else-if="resolved">
+		<slot :result="result"></slot>
+	</div>
+	<div v-else>
+		<div class="wszdbhzo">
+			<div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div>
+			<MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton>
+		</div>
+	</div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, ref, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+
+	props: {
+		p: {
+			type: Function as PropType<() => Promise<any>>,
+			required: true,
+		},
+	},
+
+	setup(props, context) {
+		const pending = ref(true);
+		const resolved = ref(false);
+		const rejected = ref(false);
+		const result = ref(null);
+
+		const process = () => {
+			if (props.p == null) {
+				return;
+			}
+			const promise = props.p();
+			pending.value = true;
+			resolved.value = false;
+			rejected.value = false;
+			promise.then((_result) => {
+				pending.value = false;
+				resolved.value = true;
+				result.value = _result;
+			});
+			promise.catch(() => {
+				pending.value = false;
+				rejected.value = true;
+			});
+		};
+
+		watch(() => props.p, () => {
+			process();
+		}, {
+			immediate: true,
+		});
+
+		const retry = () => {
+			process();
+		};
+
+		return {
+			pending,
+			resolved,
+			rejected,
+			result,
+			retry,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.wszdbhzo {
+	padding: 16px;
+	text-align: center;
+
+	> .retry {
+		margin-top: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/switch.vue b/packages/frontend/src/components/form/switch.vue
new file mode 100644
index 0000000000..1ed00ae655
--- /dev/null
+++ b/packages/frontend/src/components/form/switch.vue
@@ -0,0 +1,144 @@
+<template>
+<div
+	class="ziffeomt"
+	:class="{ disabled, checked }"
+>
+	<input
+		ref="input"
+		type="checkbox"
+		:disabled="disabled"
+		@keydown.enter="toggle"
+	>
+	<span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+		<div class="knob"></div>
+	</span>
+	<span class="label">
+		<!-- TODO: 無名slotの方は廃止 -->
+		<span @click="toggle"><slot name="label"></slot><slot></slot></span>
+		<p class="caption"><slot name="caption"></slot></p>
+	</span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { toRefs, Ref } from 'vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	modelValue: boolean | Ref<boolean>;
+	disabled?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', v: boolean): void;
+}>();
+
+let button = $ref<HTMLElement>();
+const checked = toRefs(props).modelValue;
+const toggle = () => {
+	if (props.disabled) return;
+	emit('update:modelValue', !checked.value);
+
+	if (!checked.value) {
+
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.ziffeomt {
+	position: relative;
+	display: flex;
+	transition: all 0.2s ease;
+
+	> * {
+		user-select: none;
+	}
+
+	> input {
+		position: absolute;
+		width: 0;
+		height: 0;
+		opacity: 0;
+		margin: 0;
+	}
+
+	> .button {
+		position: relative;
+		display: inline-flex;
+		flex-shrink: 0;
+		margin: 0;
+		box-sizing: border-box;
+		width: 32px;
+		height: 23px;
+		outline: none;
+		background: var(--swutchOffBg);
+		background-clip: content-box;
+		border: solid 1px var(--swutchOffBg);
+		border-radius: 999px;
+		cursor: pointer;
+		transition: inherit;
+		user-select: none;
+
+		> .knob {
+			position: absolute;
+			top: 3px;
+			left: 3px;
+			width: 15px;
+			height: 15px;
+			background: var(--swutchOffFg);
+			border-radius: 999px;
+			transition: all 0.2s ease;
+		}
+	}
+
+	&:hover {
+		> .button {
+			border-color: var(--inputBorderHover) !important;
+		}
+	}
+
+	> .label {
+		margin-left: 12px;
+		margin-top: 2px;
+		display: block;
+		transition: inherit;
+		color: var(--fg);
+
+		> span {
+			display: block;
+			line-height: 20px;
+			cursor: pointer;
+			transition: inherit;
+		}
+
+		> .caption {
+			margin: 8px 0 0 0;
+			color: var(--fgTransparentWeak);
+			font-size: 0.85em;
+
+			&:empty {
+				display: none;
+			}
+		}
+	}
+
+	&.disabled {
+		opacity: 0.6;
+		cursor: not-allowed;
+	}
+
+	&.checked {
+		> .button {
+			background-color: var(--swutchOnBg) !important;
+			border-color: var(--swutchOnBg) !important;
+
+			> .knob {
+				left: 12px;
+				background: var(--swutchOnFg);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/form/textarea.vue b/packages/frontend/src/components/form/textarea.vue
new file mode 100644
index 0000000000..d34d7b1775
--- /dev/null
+++ b/packages/frontend/src/components/form/textarea.vue
@@ -0,0 +1,260 @@
+<template>
+<div class="adhpbeos">
+	<div class="label" @click="focus"><slot name="label"></slot></div>
+	<div class="input" :class="{ disabled, focused, tall, pre }">
+		<textarea
+			ref="inputEl"
+			v-model="v"
+			v-adaptive-border
+			:class="{ code, _monospace: code }"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			:placeholder="placeholder"
+			:pattern="pattern"
+			:autocomplete="autocomplete"
+			:spellcheck="spellcheck"
+			@focus="focused = true"
+			@blur="focused = false"
+			@keydown="onKeydown($event)"
+			@input="onInput"
+		></textarea>
+	</div>
+	<div class="caption"><slot name="caption"></slot></div>
+
+	<MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+
+	props: {
+		modelValue: {
+			required: true,
+		},
+		type: {
+			type: String,
+			required: false,
+		},
+		required: {
+			type: Boolean,
+			required: false,
+		},
+		readonly: {
+			type: Boolean,
+			required: false,
+		},
+		disabled: {
+			type: Boolean,
+			required: false,
+		},
+		pattern: {
+			type: String,
+			required: false,
+		},
+		placeholder: {
+			type: String,
+			required: false,
+		},
+		autofocus: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		autocomplete: {
+			required: false,
+		},
+		spellcheck: {
+			required: false,
+		},
+		code: {
+			type: Boolean,
+			required: false,
+		},
+		tall: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		pre: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		debounce: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		manualSave: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+	},
+
+	emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+	setup(props, context) {
+		const { modelValue, autofocus } = toRefs(props);
+		const v = ref(modelValue.value);
+		const focused = ref(false);
+		const changed = ref(false);
+		const invalid = ref(false);
+		const filled = computed(() => v.value !== '' && v.value != null);
+		const inputEl = ref(null);
+
+		const focus = () => inputEl.value.focus();
+		const onInput = (ev) => {
+			changed.value = true;
+			context.emit('change', ev);
+		};
+		const onKeydown = (ev: KeyboardEvent) => {
+			context.emit('keydown', ev);
+
+			if (ev.code === 'Enter') {
+				context.emit('enter');
+			}
+		};
+
+		const updated = () => {
+			changed.value = false;
+			context.emit('update:modelValue', v.value);
+		};
+
+		const debouncedUpdated = debounce(1000, updated);
+
+		watch(modelValue, newValue => {
+			v.value = newValue;
+		});
+
+		watch(v, newValue => {
+			if (!props.manualSave) {
+				if (props.debounce) {
+					debouncedUpdated();
+				} else {
+					updated();
+				}
+			}
+
+			invalid.value = inputEl.value.validity.badInput;
+		});
+
+		onMounted(() => {
+			nextTick(() => {
+				if (autofocus.value) {
+					focus();
+				}
+			});
+		});
+
+		return {
+			v,
+			focused,
+			invalid,
+			changed,
+			filled,
+			inputEl,
+			focus,
+			onInput,
+			onKeydown,
+			updated,
+			i18n,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeos {
+	> .label {
+		font-size: 0.85em;
+		padding: 0 0 8px 0;
+		user-select: none;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .caption {
+		font-size: 0.85em;
+		padding: 8px 0 0 0;
+		color: var(--fgTransparentWeak);
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .input {
+		position: relative;
+
+		> textarea {
+			appearance: none;
+			-webkit-appearance: none;
+			display: block;
+			width: 100%;
+			min-width: 100%;
+			max-width: 100%;
+			min-height: 130px;
+			margin: 0;
+			padding: 12px;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			color: var(--fg);
+			background: var(--panel);
+			border: solid 1px var(--panel);
+			border-radius: 6px;
+			outline: none;
+			box-shadow: none;
+			box-sizing: border-box;
+			transition: border-color 0.1s ease-out;
+
+			&:hover {
+				border-color: var(--inputBorderHover) !important;
+			}
+		}
+
+		&.focused {
+			> textarea {
+				border-color: var(--accent) !important;
+			}
+		}
+
+		&.disabled {
+			opacity: 0.7;
+
+			&, * {
+				cursor: not-allowed !important;
+			}
+		}
+
+		&.tall {
+			> textarea {
+				min-height: 200px;
+			}
+		}
+
+		&.pre {
+			> textarea {
+				white-space: pre;
+			}
+		}
+	}
+
+	> .save {
+		margin: 8px 0 0 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
new file mode 100644
index 0000000000..5a0ba0d8d3
--- /dev/null
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -0,0 +1,102 @@
+<template>
+<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
+	<slot></slot>
+</a>
+</template>
+
+<script lang="ts" setup>
+import { inject } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import { popout as popout_ } from '@/scripts/popout';
+import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+
+const props = withDefaults(defineProps<{
+	to: string;
+	activeClass?: null | string;
+	behavior?: null | 'window' | 'browser' | 'modalWindow';
+}>(), {
+	activeClass: null,
+	behavior: null,
+});
+
+const router = useRouter();
+
+const active = $computed(() => {
+	if (props.activeClass == null) return false;
+	const resolved = router.resolve(props.to);
+	if (resolved == null) return false;
+	if (resolved.route.path === router.currentRoute.value.path) return true;
+	if (resolved.route.name == null) return false;
+	if (router.currentRoute.value.name == null) return false;
+	return resolved.route.name === router.currentRoute.value.name;
+});
+
+function onContextmenu(ev) {
+	const selection = window.getSelection();
+	if (selection && selection.toString() !== '') return;
+	os.contextMenu([{
+		type: 'label',
+		text: props.to,
+	}, {
+		icon: 'ti ti-app-window',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(props.to);
+		},
+	}, {
+		icon: 'ti ti-player-eject',
+		text: i18n.ts.showInPage,
+		action: () => {
+			router.push(props.to, 'forcePage');
+		},
+	}, null, {
+		icon: 'ti ti-external-link',
+		text: i18n.ts.openInNewTab,
+		action: () => {
+			window.open(props.to, '_blank');
+		},
+	}, {
+		icon: 'ti ti-link',
+		text: i18n.ts.copyLink,
+		action: () => {
+			copyToClipboard(`${url}${props.to}`);
+		},
+	}], ev);
+}
+
+function openWindow() {
+	os.pageWindow(props.to);
+}
+
+function modalWindow() {
+	os.modalPageWindow(props.to);
+}
+
+function popout() {
+	popout_(props.to);
+}
+
+function nav(ev: MouseEvent) {
+	if (props.behavior === 'browser') {
+		location.href = props.to;
+		return;
+	}
+
+	if (props.behavior) {
+		if (props.behavior === 'window') {
+			return openWindow();
+		} else if (props.behavior === 'modalWindow') {
+			return modalWindow();
+		}
+	}
+
+	if (ev.shiftKey) {
+		return openWindow();
+	}
+
+	router.push(props.to, ev.ctrlKey ? 'forcePage' : null);
+}
+</script>
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
new file mode 100644
index 0000000000..c3e806b5fb
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -0,0 +1,27 @@
+<template>
+<span class="mk-acct">
+	<span class="name">@{{ user.username }}</span>
+	<span v-if="user.host || detail || $store.state.showFullAcct" class="host">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import { toUnicode } from 'punycode/';
+import { host as hostRaw } from '@/config';
+
+defineProps<{
+	user: misskey.entities.UserDetailed;
+	detail?: boolean;
+}>();
+
+const host = toUnicode(hostRaw);
+</script>
+
+<style lang="scss" scoped>
+.mk-acct {
+	> .host {
+		opacity: 0.5;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
new file mode 100644
index 0000000000..a80efb142c
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -0,0 +1,186 @@
+<template>
+<div v-if="chosen" class="qiivuoyo">
+	<div v-if="!showMenu" class="main" :class="chosen.place">
+		<a :href="chosen.url" target="_blank">
+			<img :src="chosen.imageUrl">
+			<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="ti ti-info-circle info-circle"></span></button>
+		</a>
+	</div>
+	<div v-else class="menu">
+		<div class="body">
+			<div>Ads by {{ host }}</div>
+			<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
+			<MkButton v-if="chosen.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
+			<button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
+		</div>
+	</div>
+</div>
+<div v-else></div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { instance } from '@/instance';
+import { host } from '@/config';
+import MkButton from '@/components/MkButton.vue';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+
+type Ad = (typeof instance)['ads'][number];
+
+const props = defineProps<{
+	prefer: string[];
+	specify?: Ad;
+}>();
+
+const showMenu = ref(false);
+const toggleMenu = (): void => {
+	showMenu.value = !showMenu.value;
+};
+
+const choseAd = (): Ad | null => {
+	if (props.specify) {
+		return props.specify;
+	}
+
+	const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
+		...ad,
+		ratio: 0,
+	} : ad);
+
+	let ads = allAds.filter(ad => props.prefer.includes(ad.place));
+
+	if (ads.length === 0) {
+		ads = allAds.filter(ad => ad.place === 'square');
+	}
+
+	const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
+	ads = ads.filter(ad => ad.ratio !== 0);
+
+	if (ads.length === 0) {
+		if (lowPriorityAds.length !== 0) {
+			return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
+		} else {
+			return null;
+		}
+	}
+
+	const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
+	const r = Math.random() * totalFactor;
+
+	let stackedFactor = 0;
+	for (const ad of ads) {
+		if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
+			return ad;
+		} else {
+			stackedFactor += ad.ratio;
+		}
+	}
+
+	return null;
+};
+
+const chosen = ref(choseAd());
+
+function reduceFrequency(): void {
+	if (chosen.value == null) return;
+	if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
+	defaultStore.push('mutedAds', chosen.value.id);
+	os.success();
+	chosen.value = choseAd();
+	showMenu.value = false;
+}
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+	background-size: auto auto;
+	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+	> .main {
+		text-align: center;
+
+		> a {
+			display: inline-block;
+			position: relative;
+			vertical-align: bottom;
+
+			&:hover {
+				> img {
+					filter: contrast(120%);
+				}
+			}
+
+			> img {
+				display: block;
+				object-fit: contain;
+				margin: auto;
+				border-radius: 5px;
+			}
+
+			> .menu {
+				position: absolute;
+				top: 1px;
+				right: 1px;
+
+				> .info-circle {
+					border: 3px solid var(--panel);
+					border-radius: 50%;
+					background: var(--panel);
+				}
+			}
+		}
+
+		&.square {
+			> a ,
+			> a > img {
+				max-width: min(300px, 100%);
+				max-height: 300px;
+			}
+		}
+
+		&.horizontal {
+			padding: 8px;
+
+			> a ,
+			> a > img {
+				max-width: min(600px, 100%);
+				max-height: 80px;
+			}
+		}
+
+		&.horizontal-big {
+			padding: 8px;
+
+			> a ,
+			> a > img {
+				max-width: min(600px, 100%);
+				max-height: 250px;
+			}
+		}
+
+		&.vertical {
+			> a ,
+			> a > img {
+				max-width: min(100px, 100%);
+			}
+		}
+	}
+
+	> .menu {
+		padding: 8px;
+		text-align: center;
+
+		> .body {
+			padding: 8px;
+			margin: 0 auto;
+			max-width: 400px;
+			border: solid 1px var(--divider);
+
+			> .button {
+				margin: 8px auto;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
new file mode 100644
index 0000000000..5f3e3c176d
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -0,0 +1,143 @@
+<template>
+<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
+	<img class="inner" :src="url" decoding="async"/>
+	<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</span>
+<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
+	<img class="inner" :src="url" decoding="async"/>
+	<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import { acct, userPage } from '@/filters/user';
+import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
+import { defaultStore } from '@/store';
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.User;
+	target?: string | null;
+	disableLink?: boolean;
+	disablePreview?: boolean;
+	showIndicator?: boolean;
+}>(), {
+	target: null,
+	disableLink: false,
+	disablePreview: false,
+	showIndicator: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'click', v: MouseEvent): void;
+}>();
+
+const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
+	? getStaticImageUrl(props.user.avatarUrl)
+	: props.user.avatarUrl);
+
+function onClick(ev: MouseEvent) {
+	emit('click', ev);
+}
+
+let color = $ref();
+
+watch(() => props.user.avatarBlurhash, () => {
+	color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
+}, {
+	immediate: true,
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes earwiggleleft {
+	from { transform: rotate(37.6deg) skew(30deg); }
+	25% { transform: rotate(10deg) skew(30deg); }
+	50% { transform: rotate(20deg) skew(30deg); }
+	75% { transform: rotate(0deg) skew(30deg); }
+	to { transform: rotate(37.6deg) skew(30deg); }
+}
+
+@keyframes earwiggleright {
+	from { transform: rotate(-37.6deg) skew(-30deg); }
+	30% { transform: rotate(-10deg) skew(-30deg); }
+	55% { transform: rotate(-20deg) skew(-30deg); }
+	75% { transform: rotate(0deg) skew(-30deg); }
+	to { transform: rotate(-37.6deg) skew(-30deg); }
+}
+
+.eiwwqkts {
+	position: relative;
+	display: inline-block;
+	vertical-align: bottom;
+	flex-shrink: 0;
+	border-radius: 100%;
+	line-height: 16px;
+
+	> .inner {
+		position: absolute;
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: 0;
+		border-radius: 100%;
+		z-index: 1;
+		overflow: hidden;
+		object-fit: cover;
+		width: 100%;
+		height: 100%;
+	}
+
+	> .indicator {
+		position: absolute;
+		z-index: 1;
+		bottom: 0;
+		left: 0;
+		width: 20%;
+		height: 20%;
+	}
+
+	&.square {
+		border-radius: 20%;
+
+		> .inner {
+			border-radius: 20%;
+		}
+	}
+
+	&.cat {
+		&:before, &:after {
+			background: #df548f;
+			border: solid 4px currentColor;
+			box-sizing: border-box;
+			content: '';
+			display: inline-block;
+			height: 50%;
+			width: 50%;
+		}
+
+		&:before {
+			border-radius: 0 75% 75%;
+			transform: rotate(37.5deg) skew(30deg);
+		}
+
+		&:after {
+			border-radius: 75% 0 75% 75%;
+			transform: rotate(-37.5deg) skew(-30deg);
+		}
+
+		&:hover {
+			&:before {
+				animation: earwiggleleft 1s infinite;
+			}
+
+			&:after {
+				animation: earwiggleright 1s infinite;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue
new file mode 100644
index 0000000000..0a46f486d6
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEllipsis.vue
@@ -0,0 +1,34 @@
+<template>
+	<span class="mk-ellipsis">
+		<span>.</span><span>.</span><span>.</span>
+	</span>
+</template>
+
+<style lang="scss" scoped>
+.mk-ellipsis {
+	> span {
+		animation: ellipsis 1.4s infinite ease-in-out both;
+
+		&:nth-child(1) {
+			animation-delay: 0s;
+		}
+
+		&:nth-child(2) {
+			animation-delay: 0.16s;
+		}
+
+		&:nth-child(3) {
+			animation-delay: 0.32s;
+		}
+	}
+}
+
+@keyframes ellipsis {
+	0%, 80%, 100% {
+		opacity: 1;
+	}
+	40% {
+		opacity: 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
new file mode 100644
index 0000000000..ce1299a39f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -0,0 +1,81 @@
+<template>
+<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/>
+<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span>
+<span v-else>{{ emoji }}</span>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { CustomEmoji } from 'misskey-js/built/entities';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
+import { defaultStore } from '@/store';
+import { instance } from '@/instance';
+import { getEmojiName } from '@/scripts/emojilist';
+
+const props = defineProps<{
+	emoji: string;
+	normal?: boolean;
+	noStyle?: boolean;
+	customEmojis?: CustomEmoji[];
+	isReaction?: boolean;
+}>();
+
+const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
+
+const isCustom = computed(() => props.emoji.startsWith(':'));
+const char = computed(() => isCustom.value ? undefined : props.emoji);
+const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction);
+const ce = computed(() => props.customEmojis ?? instance.emojis ?? []);
+const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : undefined);
+const url = computed(() => {
+	if (char.value) {
+		return char2path(char.value);
+	} else {
+		const rawUrl = (customEmoji.value as CustomEmoji).url;
+		return defaultStore.state.disableShowingAnimatedImages
+			? getStaticImageUrl(rawUrl)
+			: rawUrl;
+	}
+});
+const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value);
+
+// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
+function computeTitle(event: PointerEvent): void {
+	const title = customEmoji.value
+		? `:${customEmoji.value.name}:`
+		: (getEmojiName(char.value as string) ?? char.value as string);
+	(event.target as HTMLElement).title = title;
+}
+</script>
+
+<style lang="scss" scoped>
+.mk-emoji {
+	height: 1.25em;
+	vertical-align: -0.25em;
+
+	&.custom {
+		height: 2.5em;
+		vertical-align: middle;
+		transition: transform 0.2s ease;
+
+		&:hover {
+			transform: scale(1.2);
+		}
+
+		&.normal {
+			height: 1.25em;
+			vertical-align: -0.25em;
+
+			&:hover {
+				transform: none;
+			}
+		}
+	}
+
+	&.noStyle {
+		height: auto !important;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue
new file mode 100644
index 0000000000..e135d4184b
--- /dev/null
+++ b/packages/frontend/src/components/global/MkError.vue
@@ -0,0 +1,36 @@
+<template>
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+	<div class="mjndxjcg">
+		<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+		<p><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
+		<MkButton class="button" @click="() => $emit('retry')">{{ i18n.ts.retry }}</MkButton>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+</script>
+
+<style lang="scss" scoped>
+.mjndxjcg {
+	padding: 32px;
+	text-align: center;
+
+	> p {
+		margin: 0 0 8px 0;
+	}
+
+	> .button {
+		margin: 0 auto;
+	}
+
+	> img {
+		vertical-align: bottom;
+		height: 128px;
+		margin-bottom: 16px;
+		border-radius: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue
new file mode 100644
index 0000000000..64e12e3b44
--- /dev/null
+++ b/packages/frontend/src/components/global/MkLoading.vue
@@ -0,0 +1,101 @@
+<template>
+<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]">
+	<div :class="$style.container">
+		<svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+			<g transform="matrix(1.125,0,0,1.125,12,12)">
+				<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+			</g>
+		</svg>
+		<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+			<g transform="matrix(1.125,0,0,1.125,12,12)">
+				<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+			</g>
+		</svg>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+	inline?: boolean;
+	colored?: boolean;
+	mini?: boolean;
+	em?: boolean;
+}>(), {
+	inline: false,
+	colored: true,
+	mini: false,
+	em: false,
+});
+</script>
+
+<style lang="scss" module>
+@keyframes spinner {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+
+.root {
+	padding: 32px;
+	text-align: center;
+	cursor: wait;
+
+	--size: 38px;
+
+	&.colored {
+		color: var(--accent);
+	}
+
+	&.inline {
+		display: inline;
+		padding: 0;
+		--size: 32px;
+	}
+
+	&.mini {
+		padding: 16px;
+		--size: 32px;
+	}
+
+	&.em {
+		display: inline-block;
+		vertical-align: middle;
+		padding: 0;
+		--size: 1em;
+	}
+}
+
+.container {
+	position: relative;
+	width: var(--size);
+	height: var(--size);
+	margin: 0 auto;
+}
+
+.spinner {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: var(--size);
+	height: var(--size);
+	fill-rule: evenodd;
+	clip-rule: evenodd;
+	stroke-linecap: round;
+	stroke-linejoin: round;
+	stroke-miterlimit: 1.5;
+}
+
+.bg {
+	opacity: 0.275;
+}
+
+.fg {
+	animation: spinner 0.5s linear infinite;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
new file mode 100644
index 0000000000..70d0108e9f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
@@ -0,0 +1,191 @@
+<template>
+<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MfmCore from '@/components/mfm';
+
+const props = withDefaults(defineProps<{
+	text: string;
+	plain?: boolean;
+	nowrap?: boolean;
+	author?: any;
+	customEmojis?: any;
+	isNote?: boolean;
+}>(), {
+	plain: false,
+	nowrap: false,
+	author: null,
+	isNote: true,
+});
+</script>
+
+<style lang="scss">
+._mfm_blur_ {
+	filter: blur(6px);
+	transition: filter 0.3s;
+
+	&:hover {
+		filter: blur(0px);
+	}
+}
+
+.mfm-x2 {
+	--mfm-zoom-size: 200%;
+}
+
+.mfm-x3 {
+	--mfm-zoom-size: 400%;
+}
+
+.mfm-x4 {
+	--mfm-zoom-size: 600%;
+}
+
+.mfm-x2, .mfm-x3, .mfm-x4 {
+	font-size: var(--mfm-zoom-size);
+
+	.mfm-x2, .mfm-x3, .mfm-x4 {
+		/* only half effective */
+		font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
+
+		.mfm-x2, .mfm-x3, .mfm-x4 {
+			/* disabled */
+			font-size: 100%;
+		}
+	}
+}
+
+@keyframes mfm-spin {
+	0% { transform: rotate(0deg); }
+	100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+	0% { transform: perspective(128px) rotateX(0deg); }
+	100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+	0% { transform: perspective(128px) rotateY(0deg); }
+	100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+	0% { transform: translateY(0); }
+	25% { transform: translateY(-16px); }
+	50% { transform: translateY(0); }
+	75% { transform: translateY(-8px); }
+	100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+	0% { transform: translateY(0) scale(1, 1); }
+	25% { transform: translateY(-16px) scale(1, 1); }
+	50% { transform: translateY(0) scale(1, 1); }
+	75% { transform: translateY(0) scale(1.5, 0.75); }
+	100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+	0% { transform: translate(7px, -2px) }
+	5% { transform: translate(-3px, 1px) }
+	10% { transform: translate(-7px, -1px) }
+	15% { transform: translate(0px, -1px) }
+	20% { transform: translate(-8px, 6px) }
+	25% { transform: translate(-4px, -3px) }
+	30% { transform: translate(-4px, -6px) }
+	35% { transform: translate(-8px, -8px) }
+	40% { transform: translate(4px, 6px) }
+	45% { transform: translate(-3px, 1px) }
+	50% { transform: translate(2px, -10px) }
+	55% { transform: translate(-7px, 0px) }
+	60% { transform: translate(-2px, 4px) }
+	65% { transform: translate(3px, -8px) }
+	70% { transform: translate(6px, 7px) }
+	75% { transform: translate(-7px, -2px) }
+	80% { transform: translate(-7px, -8px) }
+	85% { transform: translate(9px, 3px) }
+	90% { transform: translate(-3px, -2px) }
+	95% { transform: translate(-10px, 2px) }
+	100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+	0% { transform: translate(-3px, -1px) rotate(-8deg) }
+	5% { transform: translate(0px, -1px) rotate(-10deg) }
+	10% { transform: translate(1px, -3px) rotate(0deg) }
+	15% { transform: translate(1px, 1px) rotate(11deg) }
+	20% { transform: translate(-2px, 1px) rotate(1deg) }
+	25% { transform: translate(-1px, -2px) rotate(-2deg) }
+	30% { transform: translate(-1px, 2px) rotate(-3deg) }
+	35% { transform: translate(2px, 1px) rotate(6deg) }
+	40% { transform: translate(-2px, -3px) rotate(-9deg) }
+	45% { transform: translate(0px, -1px) rotate(-12deg) }
+	50% { transform: translate(1px, 2px) rotate(10deg) }
+	55% { transform: translate(0px, -3px) rotate(8deg) }
+	60% { transform: translate(1px, -1px) rotate(8deg) }
+	65% { transform: translate(0px, -1px) rotate(-7deg) }
+	70% { transform: translate(-1px, -3px) rotate(6deg) }
+	75% { transform: translate(0px, -2px) rotate(4deg) }
+	80% { transform: translate(-2px, -1px) rotate(3deg) }
+	85% { transform: translate(1px, -3px) rotate(-10deg) }
+	90% { transform: translate(1px, 0px) rotate(3deg) }
+	95% { transform: translate(-2px, 0px) rotate(-3deg) }
+	100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+	from { transform: scale3d(1, 1, 1); }
+	30% { transform: scale3d(1.25, 0.75, 1); }
+	40% { transform: scale3d(0.75, 1.25, 1); }
+	50% { transform: scale3d(1.15, 0.85, 1); }
+	65% { transform: scale3d(0.95, 1.05, 1); }
+	75% { transform: scale3d(1.05, 0.95, 1); }
+	to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+	0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+	100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
+</style>
+
+<style lang="scss" scoped>
+.havbbuyv {
+	white-space: pre-wrap;
+
+	&.nowrap {
+		white-space: pre;
+		word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	::v-deep(.quote) {
+		display: block;
+		margin: 8px;
+		padding: 6px 0 6px 12px;
+		color: var(--fg);
+		border-left: solid 3px var(--fg);
+		opacity: 0.7;
+	}
+
+	::v-deep(pre) {
+		font-size: 0.8em;
+	}
+
+	> ::v-deep(code) {
+		font-size: 0.8em;
+		word-break: break-all;
+		padding: 4px 6px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
new file mode 100644
index 0000000000..a228dfe883
--- /dev/null
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -0,0 +1,368 @@
+<template>
+<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
+	<div v-if="narrow" class="buttons left">
+		<MkAvatar v-if="props.displayMyAvatar && $i" class="avatar" :user="$i" :disable-preview="true"/>
+	</div>
+	<template v-if="metadata">
+		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
+			<MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
+			<i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/>
+				<div v-else-if="metadata.title" class="title">{{ metadata.title }}</div>
+				<div v-if="!narrow && metadata.subtitle" class="subtitle">
+					{{ metadata.subtitle }}
+				</div>
+				<div v-if="narrow && hasTabs" class="subtitle activeTab">
+					{{ tabs.find(tab => tab.key === props.tab)?.title }}
+					<i class="chevron ti ti-chevron-down"></i>
+				</div>
+			</div>
+		</div>
+		<div v-if="!narrow || hideTitle" class="tabs">
+			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+			<div ref="tabHighlightEl" class="highlight"></div>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-for="action in actions">
+			<button v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { scrollToTop } from '@/scripts/scroll';
+import { globalEvents } from '@/events';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+type Tab = {
+	key: string;
+	title: string;
+	icon?: string;
+	iconOnly?: boolean;
+	onClick?: (ev: MouseEvent) => void;
+};
+
+const props = withDefaults(defineProps<{
+	tabs?: Tab[];
+	tab?: string;
+	actions?: {
+		text: string;
+		icon: string;
+		highlighted?: boolean;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+	displayMyAvatar?: boolean;
+}>(), {
+	tabs: () => ([] as Tab[])
+});
+
+const emit = defineEmits<{
+	(ev: 'update:tab', key: string);
+}>();
+
+const metadata = injectPageMetadata();
+
+const hideTitle = inject('shouldOmitHeaderTitle', false);
+const thin_ = props.thin || inject('shouldHeaderThin', false);
+
+const el = $ref<HTMLElement | undefined>(undefined);
+const tabRefs: Record<string, HTMLElement | null> = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
+const bg = ref<string | undefined>(undefined);
+let 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 || hasActions;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs) return;
+	if (!narrow) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		active: tab.key != null && tab.key === props.tab,
+		action: (ev) => {
+			onTabClick(tab, ev);
+		},
+	}));
+	popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	if (el) {
+		scrollToTop(el as HTMLElement, { behavior: 'smooth' });
+	}
+};
+
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+	if (tab.onClick) {
+		ev.preventDefault();
+		ev.stopPropagation();
+		tab.onClick(ev);
+	}
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+let ro: ResizeObserver | null;
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+
+	watch(() => [props.tab, props.tabs], () => {
+		nextTick(() => {
+			const tabEl = props.tab ? tabRefs[props.tab] : undefined;
+			if (tabEl && tabHighlightEl && tabEl.parentElement) {
+				// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+				// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+				const parentRect = tabEl.parentElement.getBoundingClientRect();
+				const rect = tabEl.getBoundingClientRect();
+				tabHighlightEl.style.width = rect.width + 'px';
+				tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+			}
+		});
+	}, {
+		immediate: true,
+	});
+
+	if (el && el.parentElement) {
+		narrow = el.parentElement.offsetWidth < 500;
+		ro = new ResizeObserver((entries, observer) => {
+			if (el.parentElement && document.body.contains(el as HTMLElement)) {
+				narrow = el.parentElement.offsetWidth < 500;
+			}
+		});
+		ro.observe(el.parentElement as HTMLElement);
+	}
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+	if (ro) ro.disconnect();
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+	--height: 52px;
+	display: flex;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+	border-bottom: solid 0.5px var(--divider);
+	contain: strict;
+	height: var(--height);
+
+	&.thin {
+		--height: 42px;
+
+		> .buttons {
+			> .button {
+				font-size: 0.9em;
+			}
+		}
+	}
+
+	&.slim {
+		text-align: center;
+
+		> .titleContainer {
+			flex: 1;
+			margin: 0 auto;
+
+			> *:first-child {
+				margin-left: auto;
+			}
+
+			> *:last-child {
+				margin-right: auto;
+			}
+		}
+	}
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		min-width: var(--height);
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.left {
+			margin-right: auto;
+
+			> .avatar {
+				$size: 32px;
+				display: inline-block;
+				width: $size;
+				height: $size;
+				vertical-align: bottom;
+				margin: 0 8px;
+				pointer-events: none;
+			}
+		}
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+			width: 16px;
+			text-align: center;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		position: relative;
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+
+		> .highlight {
+			position: absolute;
+			bottom: 0;
+			height: 3px;
+			background: var(--accent);
+			border-radius: 999px;
+			transition: all 0.2s ease;
+			pointer-events: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue
new file mode 100644
index 0000000000..b3a42d77e7
--- /dev/null
+++ b/packages/frontend/src/components/global/MkSpacer.vue
@@ -0,0 +1,96 @@
+<template>
+<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }">
+	<div ref="content" :class="$style.content">
+		<slot></slot>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, onMounted, onUnmounted, ref } from 'vue';
+import { deviceKind } from '@/scripts/device-kind';
+
+const props = withDefaults(defineProps<{
+	contentMax?: number | null;
+	marginMin?: number;
+	marginMax?: number;
+}>(), {
+	contentMax: null,
+	marginMin: 12,
+	marginMax: 24,
+});
+
+let ro: ResizeObserver;
+let root = $ref<HTMLElement>();
+let content = $ref<HTMLElement>();
+let margin = $ref(0);
+const widthHistory = [null, null] as [number | null, number | null];
+const heightHistory = [null, null] as [number | null, number | null];
+const shouldSpacerMin = inject('shouldSpacerMin', false);
+
+const adjust = (rect: { width: number; height: number; }) => {
+	if (shouldSpacerMin || deviceKind === 'smartphone') {
+		margin = props.marginMin;
+		return;
+	}
+
+	if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) {
+		margin = props.marginMax;
+	} else {
+		margin = props.marginMin;
+	}
+};
+
+onMounted(() => {
+	ro = new ResizeObserver((entries) => {
+		/* iOSが対応していない
+		adjust({
+			width: entries[0].borderBoxSize[0].inlineSize,
+			height: entries[0].borderBoxSize[0].blockSize,
+		});
+		*/
+
+		const width = root!.offsetWidth;
+		const height = root!.offsetHeight;
+
+		//#region Prevent infinite resizing
+		// https://github.com/misskey-dev/misskey/issues/9076
+		const pastWidth = widthHistory.pop();
+		widthHistory.unshift(width);
+		const pastHeight = heightHistory.pop();
+		heightHistory.unshift(height);
+
+
+		if (pastWidth === width && pastHeight === height) {
+			return;
+		}
+		//#endregion
+
+		adjust({
+			width,
+			height,
+		});
+	});
+	ro.observe(root!);
+
+	if (props.contentMax) {
+		content!.style.maxWidth = `${props.contentMax}px`;
+	}
+});
+
+onUnmounted(() => {
+	ro.disconnect();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	box-sizing: border-box;
+	width: 100%;
+}
+
+.content {
+	margin: 0 auto;
+	container-type: inline-size;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
new file mode 100644
index 0000000000..44f4f065a6
--- /dev/null
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -0,0 +1,66 @@
+<template>
+<div ref="rootEl">
+	<div ref="headerEl">
+		<slot name="header"></slot>
+	</div>
+	<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
+		<slot></slot>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+// なんか動かない
+//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
+const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
+</script>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
+
+const rootEl = $ref<HTMLElement>();
+const headerEl = $ref<HTMLElement>();
+const bodyEl = $ref<HTMLElement>();
+
+let headerHeight = $ref<string | undefined>();
+let childStickyTop = $ref(0);
+const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0));
+provide(CURRENT_STICKY_TOP, $$(childStickyTop));
+
+const calc = () => {
+	childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
+	headerHeight = headerEl.offsetHeight.toString();
+};
+
+const observer = new ResizeObserver(() => {
+	window.setTimeout(() => {
+		calc();
+	}, 100);
+});
+
+onMounted(() => {
+	calc();
+
+	watch(parentStickyTop, calc);
+
+	watch($$(childStickyTop), () => {
+		bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`);
+	}, {
+		immediate: true,
+	});
+
+	headerEl.style.position = 'sticky';
+	headerEl.style.top = 'var(--stickyTop, 0)';
+	headerEl.style.zIndex = '1000';
+
+	observer.observe(headerEl);
+});
+
+onUnmounted(() => {
+	observer.disconnect();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
new file mode 100644
index 0000000000..f72b153f56
--- /dev/null
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -0,0 +1,56 @@
+<template>
+<time :title="absolute">
+	<template v-if="mode === 'relative'">{{ relative }}</template>
+	<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
+	<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted } from 'vue';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	time: Date | string;
+	mode?: 'relative' | 'absolute' | 'detail';
+}>(), {
+	mode: 'relative',
+});
+
+const _time = typeof props.time === 'string' ? new Date(props.time) : props.time;
+const absolute = _time.toLocaleString();
+
+let now = $shallowRef(new Date());
+const relative = $computed(() => {
+	const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
+	return (
+		ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
+		ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
+		ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) :
+		ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
+		ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
+		ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
+		ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
+		ago >= -1 ? i18n.ts._ago.justNow :
+		i18n.ts._ago.future);
+});
+
+function tick() {
+	// TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
+	now = new Date();
+
+	tickId = window.setTimeout(() => {
+		window.requestAnimationFrame(tick);
+	}, 10000);
+}
+
+let tickId: number;
+
+if (props.mode === 'relative' || props.mode === 'detail') {
+	tickId = window.requestAnimationFrame(tick);
+
+	onUnmounted(() => {
+		window.cancelAnimationFrame(tickId);
+	});
+}
+</script>
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
new file mode 100644
index 0000000000..9f5be96224
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -0,0 +1,89 @@
+<template>
+<component
+	:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel" :target="target"
+	@contextmenu.stop="() => {}"
+>
+	<template v-if="!self">
+		<span class="schema">{{ schema }}//</span>
+		<span class="hostname">{{ hostname }}</span>
+		<span v-if="port != ''" class="port">:{{ port }}</span>
+	</template>
+	<template v-if="pathname === '/' && self">
+		<span class="self">{{ hostname }}</span>
+	</template>
+	<span v-if="pathname != ''" class="pathname">{{ self ? pathname.substring(1) : pathname }}</span>
+	<span class="query">{{ query }}</span>
+	<span class="hash">{{ hash }}</span>
+	<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import { url as local } from '@/config';
+import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { safeURIDecode } from '@/scripts/safe-uri-decode';
+
+const props = defineProps<{
+	url: string;
+	rel?: string;
+}>();
+
+const self = props.url.startsWith(local);
+const url = new URL(props.url);
+const el = ref();
+
+useTooltip(el, (showing) => {
+	os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
+		showing,
+		url: props.url,
+		source: el.value,
+	}, {}, 'closed');
+});
+
+const schema = url.protocol;
+const hostname = decodePunycode(url.hostname);
+const port = url.port;
+const pathname = safeURIDecode(url.pathname);
+const query = safeURIDecode(url.search);
+const hash = safeURIDecode(url.hash);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+</script>
+
+<style lang="scss" scoped>
+.ieqqeuvs {
+	word-break: break-all;
+
+	> .icon {
+		padding-left: 2px;
+		font-size: .9em;
+	}
+
+	> .self {
+		font-weight: bold;
+	}
+
+	> .schema {
+		opacity: 0.5;
+	}
+
+	> .hostname {
+		font-weight: bold;
+	}
+
+	> .pathname {
+		opacity: 0.8;
+	}
+
+	> .query {
+		opacity: 0.5;
+	}
+
+	> .hash {
+		font-style: italic;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue
new file mode 100644
index 0000000000..090de3df30
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUserName.vue
@@ -0,0 +1,15 @@
+<template>
+<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.User;
+	nowrap?: boolean;
+}>(), {
+	nowrap: true,
+});
+</script>
diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue
new file mode 100644
index 0000000000..e21a57471c
--- /dev/null
+++ b/packages/frontend/src/components/global/RouterView.vue
@@ -0,0 +1,61 @@
+<template>
+<KeepAlive :max="defaultStore.state.numberOfPageCache">
+	<Suspense>
+		<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
+
+		<template #fallback>
+			<MkLoading/>
+		</template>
+	</Suspense>
+</KeepAlive>
+</template>
+
+<script lang="ts" setup>
+import { inject, nextTick, onBeforeUnmount, onMounted, onUnmounted, provide, watch } from 'vue';
+import { Resolved, Router } from '@/nirax';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+	router?: Router;
+}>();
+
+const router = props.router ?? inject('router');
+
+if (router == null) {
+	throw new Error('no router provided');
+}
+
+const currentDepth = inject('routerCurrentDepth', 0);
+provide('routerCurrentDepth', currentDepth + 1);
+
+function resolveNested(current: Resolved, d = 0): Resolved | null {
+	if (d === currentDepth) {
+		return current;
+	} else {
+		if (current.child) {
+			return resolveNested(current.child, d + 1);
+		} else {
+			return null;
+		}
+	}
+}
+
+const current = resolveNested(router.current)!;
+let currentPageComponent = $shallowRef(current.route.component);
+let currentPageProps = $ref(current.props);
+let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
+
+function onChange({ resolved, key: newKey }) {
+	const current = resolveNested(resolved);
+	if (current == null) return;
+	currentPageComponent = current.route.component;
+	currentPageProps = current.props;
+	key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
+}
+
+router.addListener('change', onChange);
+
+onBeforeUnmount(() => {
+	router.removeListener('change', onChange);
+});
+</script>
diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts
new file mode 100644
index 0000000000..1fd293ba10
--- /dev/null
+++ b/packages/frontend/src/components/global/i18n.ts
@@ -0,0 +1,42 @@
+import { h, defineComponent } from 'vue';
+
+export default defineComponent({
+	props: {
+		src: {
+			type: String,
+			required: true,
+		},
+		tag: {
+			type: String,
+			required: false,
+			default: 'span',
+		},
+		textTag: {
+			type: String,
+			required: false,
+			default: null,
+		},
+	},
+	render() {
+		let str = this.src;
+		const parsed = [] as (string | { arg: string; })[];
+		while (true) {
+			const nextBracketOpen = str.indexOf('{');
+			const nextBracketClose = str.indexOf('}');
+
+			if (nextBracketOpen === -1) {
+				parsed.push(str);
+				break;
+			} else {
+				if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
+				parsed.push({
+					arg: str.substring(nextBracketOpen + 1, nextBracketClose),
+				});
+			}
+
+			str = str.substr(nextBracketClose + 1);
+		}
+
+		return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]()));
+	},
+});
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
new file mode 100644
index 0000000000..8639257003
--- /dev/null
+++ b/packages/frontend/src/components/index.ts
@@ -0,0 +1,61 @@
+import { App } from 'vue';
+
+import Mfm from './global/MkMisskeyFlavoredMarkdown.vue';
+import MkA from './global/MkA.vue';
+import MkAcct from './global/MkAcct.vue';
+import MkAvatar from './global/MkAvatar.vue';
+import MkEmoji from './global/MkEmoji.vue';
+import MkUserName from './global/MkUserName.vue';
+import MkEllipsis from './global/MkEllipsis.vue';
+import MkTime from './global/MkTime.vue';
+import MkUrl from './global/MkUrl.vue';
+import I18n from './global/i18n';
+import RouterView from './global/RouterView.vue';
+import MkLoading from './global/MkLoading.vue';
+import MkError from './global/MkError.vue';
+import MkAd from './global/MkAd.vue';
+import MkPageHeader from './global/MkPageHeader.vue';
+import MkSpacer from './global/MkSpacer.vue';
+import MkStickyContainer from './global/MkStickyContainer.vue';
+
+export default function(app: App) {
+	app.component('I18n', I18n);
+	app.component('RouterView', RouterView);
+	app.component('Mfm', Mfm);
+	app.component('MkA', MkA);
+	app.component('MkAcct', MkAcct);
+	app.component('MkAvatar', MkAvatar);
+	app.component('MkEmoji', MkEmoji);
+	app.component('MkUserName', MkUserName);
+	app.component('MkEllipsis', MkEllipsis);
+	app.component('MkTime', MkTime);
+	app.component('MkUrl', MkUrl);
+	app.component('MkLoading', MkLoading);
+	app.component('MkError', MkError);
+	app.component('MkAd', MkAd);
+	app.component('MkPageHeader', MkPageHeader);
+	app.component('MkSpacer', MkSpacer);
+	app.component('MkStickyContainer', MkStickyContainer);
+}
+
+declare module '@vue/runtime-core' {
+	export interface GlobalComponents {
+		I18n: typeof I18n;
+		RouterView: typeof RouterView;
+		Mfm: typeof Mfm;
+		MkA: typeof MkA;
+		MkAcct: typeof MkAcct;
+		MkAvatar: typeof MkAvatar;
+		MkEmoji: typeof MkEmoji;
+		MkUserName: typeof MkUserName;
+		MkEllipsis: typeof MkEllipsis;
+		MkTime: typeof MkTime;
+		MkUrl: typeof MkUrl;
+		MkLoading: typeof MkLoading;
+		MkError: typeof MkError;
+		MkAd: typeof MkAd;
+		MkPageHeader: typeof MkPageHeader;
+		MkSpacer: typeof MkSpacer;
+		MkStickyContainer: typeof MkStickyContainer;
+	}
+}
diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts
new file mode 100644
index 0000000000..5b5b1caae3
--- /dev/null
+++ b/packages/frontend/src/components/mfm.ts
@@ -0,0 +1,331 @@
+import { VNode, defineComponent, h } from 'vue';
+import * as mfm from 'mfm-js';
+import MkUrl from '@/components/global/MkUrl.vue';
+import MkLink from '@/components/MkLink.vue';
+import MkMention from '@/components/MkMention.vue';
+import MkEmoji from '@/components/global/MkEmoji.vue';
+import { concat } from '@/scripts/array';
+import MkFormula from '@/components/MkFormula.vue';
+import MkCode from '@/components/MkCode.vue';
+import MkGoogle from '@/components/MkGoogle.vue';
+import MkSparkle from '@/components/MkSparkle.vue';
+import MkA from '@/components/global/MkA.vue';
+import { host } from '@/config';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+
+export default defineComponent({
+	props: {
+		text: {
+			type: String,
+			required: true,
+		},
+		plain: {
+			type: Boolean,
+			default: false,
+		},
+		nowrap: {
+			type: Boolean,
+			default: false,
+		},
+		author: {
+			type: Object,
+			default: null,
+		},
+		i: {
+			type: Object,
+			default: null,
+		},
+		customEmojis: {
+			required: false,
+		},
+		isNote: {
+			type: Boolean,
+			default: true,
+		},
+	},
+
+	render() {
+		if (this.text == null || this.text === '') return;
+
+		const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS });
+
+		const validTime = (t: string | null | undefined) => {
+			if (t == null) return null;
+			return t.match(/^[0-9.]+s$/) ? t : null;
+		};
+
+		const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => {
+			switch (token.type) {
+				case 'text': {
+					const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+
+					if (!this.plain) {
+						const res: (VNode | string)[] = [];
+						for (const t of text.split('\n')) {
+							res.push(h('br'));
+							res.push(t);
+						}
+						res.shift();
+						return res;
+					} else {
+						return [text.replace(/\n/g, ' ')];
+					}
+				}
+
+				case 'bold': {
+					return [h('b', genEl(token.children))];
+				}
+
+				case 'strike': {
+					return [h('del', genEl(token.children))];
+				}
+
+				case 'italic': {
+					return h('i', {
+						style: 'font-style: oblique;',
+					}, genEl(token.children));
+				}
+
+				case 'fn': {
+					// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+					let style;
+					switch (token.props.name) {
+						case 'tada': {
+							const speed = validTime(token.props.args.speed) || '1s';
+							style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : '');
+							break;
+						}
+						case 'jelly': {
+							const speed = validTime(token.props.args.speed) || '1s';
+							style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
+							break;
+						}
+						case 'twitch': {
+							const speed = validTime(token.props.args.speed) || '0.5s';
+							style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
+							break;
+						}
+						case 'shake': {
+							const speed = validTime(token.props.args.speed) || '0.5s';
+							style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
+							break;
+						}
+						case 'spin': {
+							const direction =
+								token.props.args.left ? 'reverse' :
+								token.props.args.alternate ? 'alternate' :
+								'normal';
+							const anime =
+								token.props.args.x ? 'mfm-spinX' :
+								token.props.args.y ? 'mfm-spinY' :
+								'mfm-spin';
+							const speed = validTime(token.props.args.speed) || '1.5s';
+							style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
+							break;
+						}
+						case 'jump': {
+							const speed = validTime(token.props.args.speed) || '0.75s';
+							style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : '';
+							break;
+						}
+						case 'bounce': {
+							const speed = validTime(token.props.args.speed) || '0.75s';
+							style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
+							break;
+						}
+						case 'flip': {
+							const transform =
+								(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
+								token.props.args.v ? 'scaleY(-1)' :
+								'scaleX(-1)';
+							style = `transform: ${transform};`;
+							break;
+						}
+						case 'x2': {
+							return h('span', {
+								class: 'mfm-x2',
+							}, genEl(token.children));
+						}
+						case 'x3': {
+							return h('span', {
+								class: 'mfm-x3',
+							}, genEl(token.children));
+						}
+						case 'x4': {
+							return h('span', {
+								class: 'mfm-x4',
+							}, genEl(token.children));
+						}
+						case 'font': {
+							const family =
+								token.props.args.serif ? 'serif' :
+								token.props.args.monospace ? 'monospace' :
+								token.props.args.cursive ? 'cursive' :
+								token.props.args.fantasy ? 'fantasy' :
+								token.props.args.emoji ? 'emoji' :
+								token.props.args.math ? 'math' :
+								null;
+							if (family) style = `font-family: ${family};`;
+							break;
+						}
+						case 'blur': {
+							return h('span', {
+								class: '_mfm_blur_',
+							}, genEl(token.children));
+						}
+						case 'rainbow': {
+							const speed = validTime(token.props.args.speed) || '1s';
+							style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
+							break;
+						}
+						case 'sparkle': {
+							if (!this.$store.state.animatedMfm) {
+								return genEl(token.children);
+							}
+							return h(MkSparkle, {}, genEl(token.children));
+						}
+						case 'rotate': {
+							const degrees = parseInt(token.props.args.deg) || '90';
+							style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
+							break;
+						}
+					}
+					if (style == null) {
+						return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
+					} else {
+						return h('span', {
+							style: 'display: inline-block;' + style,
+						}, genEl(token.children));
+					}
+				}
+
+				case 'small': {
+					return [h('small', {
+						style: 'opacity: 0.7;',
+					}, genEl(token.children))];
+				}
+
+				case 'center': {
+					return [h('div', {
+						style: 'text-align:center;',
+					}, genEl(token.children))];
+				}
+
+				case 'url': {
+					return [h(MkUrl, {
+						key: Math.random(),
+						url: token.props.url,
+						rel: 'nofollow noopener',
+					})];
+				}
+
+				case 'link': {
+					return [h(MkLink, {
+						key: Math.random(),
+						url: token.props.url,
+						rel: 'nofollow noopener',
+					}, genEl(token.children))];
+				}
+
+				case 'mention': {
+					return [h(MkMention, {
+						key: Math.random(),
+						host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
+						username: token.props.username,
+					})];
+				}
+
+				case 'hashtag': {
+					return [h(MkA, {
+						key: Math.random(),
+						to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
+						style: 'color:var(--hashtag);',
+					}, `#${token.props.hashtag}`)];
+				}
+
+				case 'blockCode': {
+					return [h(MkCode, {
+						key: Math.random(),
+						code: token.props.code,
+						lang: token.props.lang,
+					})];
+				}
+
+				case 'inlineCode': {
+					return [h(MkCode, {
+						key: Math.random(),
+						code: token.props.code,
+						inline: true,
+					})];
+				}
+
+				case 'quote': {
+					if (!this.nowrap) {
+						return [h('div', {
+							class: 'quote',
+						}, genEl(token.children))];
+					} else {
+						return [h('span', {
+							class: 'quote',
+						}, genEl(token.children))];
+					}
+				}
+
+				case 'emojiCode': {
+					return [h(MkEmoji, {
+						key: Math.random(),
+						emoji: `:${token.props.name}:`,
+						customEmojis: this.customEmojis,
+						normal: this.plain,
+					})];
+				}
+
+				case 'unicodeEmoji': {
+					return [h(MkEmoji, {
+						key: Math.random(),
+						emoji: token.props.emoji,
+						customEmojis: this.customEmojis,
+						normal: this.plain,
+					})];
+				}
+
+				case 'mathInline': {
+					return [h(MkFormula, {
+						key: Math.random(),
+						formula: token.props.formula,
+						block: false,
+					})];
+				}
+
+				case 'mathBlock': {
+					return [h(MkFormula, {
+						key: Math.random(),
+						formula: token.props.formula,
+						block: true,
+					})];
+				}
+
+				case 'search': {
+					return [h(MkGoogle, {
+						key: Math.random(),
+						q: token.props.query,
+					})];
+				}
+
+				case 'plain': {
+					return [h('span', genEl(token.children))];
+				}
+
+				default: {
+					// eslint-disable-next-line @typescript-eslint/no-explicit-any
+					console.error('unrecognized ast type:', (token as any).type);
+
+					return [];
+				}
+			}
+		}).flat(Infinity) as (VNode | string)[];
+
+		// Parse ast to DOM
+		return h('span', genEl(ast));
+	},
+});
diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue
new file mode 100644
index 0000000000..f3e7764604
--- /dev/null
+++ b/packages/frontend/src/components/page/page.block.vue
@@ -0,0 +1,44 @@
+<template>
+<component :is="'x-' + block.type" :key="block.id" :block="block" :hpml="hpml" :h="h"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import XText from './page.text.vue';
+import XSection from './page.section.vue';
+import XImage from './page.image.vue';
+import XButton from './page.button.vue';
+import XNumberInput from './page.number-input.vue';
+import XTextInput from './page.text-input.vue';
+import XTextareaInput from './page.textarea-input.vue';
+import XSwitch from './page.switch.vue';
+import XIf from './page.if.vue';
+import XTextarea from './page.textarea.vue';
+import XPost from './page.post.vue';
+import XCounter from './page.counter.vue';
+import XRadioButton from './page.radio-button.vue';
+import XCanvas from './page.canvas.vue';
+import XNote from './page.note.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { Block } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote,
+	},
+	props: {
+		block: {
+			type: Object as PropType<Block>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+		h: {
+			type: Number,
+			required: true,
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/page/page.button.vue b/packages/frontend/src/components/page/page.button.vue
new file mode 100644
index 0000000000..83931021d8
--- /dev/null
+++ b/packages/frontend/src/components/page/page.button.vue
@@ -0,0 +1,66 @@
+<template>
+<div>
+	<MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, unref } from 'vue';
+import MkButton from '../MkButton.vue';
+import * as os from '@/os';
+import { ButtonBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+	props: {
+		block: {
+			type: Object as PropType<ButtonBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	methods: {
+		click() {
+			if (this.block.action === 'dialog') {
+				this.hpml.eval();
+				os.alert({
+					text: this.hpml.interpolate(this.block.content),
+				});
+			} else if (this.block.action === 'resetRandom') {
+				this.hpml.updateRandomSeed(Math.random());
+				this.hpml.eval();
+			} else if (this.block.action === 'pushEvent') {
+				os.api('page-push', {
+					pageId: this.hpml.page.id,
+					event: this.block.event,
+					...(this.block.var ? {
+						var: unref(this.hpml.vars)[this.block.var],
+					} : {}),
+				});
+
+				os.alert({
+					type: 'success',
+					text: this.hpml.interpolate(this.block.message),
+				});
+			} else if (this.block.action === 'callAiScript') {
+				this.hpml.callAiScript(this.block.fn);
+			}
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+	display: inline-block;
+	min-width: 200px;
+	max-width: 450px;
+	margin: 8px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.canvas.vue b/packages/frontend/src/components/page/page.canvas.vue
new file mode 100644
index 0000000000..80f6c8339c
--- /dev/null
+++ b/packages/frontend/src/components/page/page.canvas.vue
@@ -0,0 +1,49 @@
+<template>
+<div class="ysrxegms">
+	<canvas ref="canvas" :width="block.width" :height="block.height"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import * as os from '@/os';
+import { CanvasBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+	props: {
+		block: {
+			type: Object as PropType<CanvasBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const canvas: Ref<any> = ref(null);
+
+		onMounted(() => {
+			props.hpml.registerCanvas(props.block.name, canvas.value);
+		});
+
+		return {
+			canvas,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ysrxegms {
+	display: inline-block;
+	vertical-align: bottom;
+	overflow: auto;
+	max-width: 100%;
+
+	> canvas {
+		display: block;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.counter.vue b/packages/frontend/src/components/page/page.counter.vue
new file mode 100644
index 0000000000..a9e1f41a54
--- /dev/null
+++ b/packages/frontend/src/components/page/page.counter.vue
@@ -0,0 +1,52 @@
+<template>
+<div>
+	<MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkButton from '../MkButton.vue';
+import * as os from '@/os';
+import { CounterVarBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+	props: {
+		block: {
+			type: Object as PropType<CounterVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function click() {
+			props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1));
+			props.hpml.eval();
+		}
+
+		return {
+			click,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.llumlmnx {
+	display: inline-block;
+	min-width: 300px;
+	max-width: 450px;
+	margin: 8px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.if.vue b/packages/frontend/src/components/page/page.if.vue
new file mode 100644
index 0000000000..372a15f0c6
--- /dev/null
+++ b/packages/frontend/src/components/page/page.if.vue
@@ -0,0 +1,31 @@
+<template>
+<div v-show="hpml.vars.value[block.var]">
+	<XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h"/>
+</div>
+</template>
+
+<script lang="ts">
+import { IfBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+
+export default defineComponent({
+	components: {
+		XBlock: defineAsyncComponent(() => import('./page.block.vue')),
+	},
+	props: {
+		block: {
+			type: Object as PropType<IfBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+		h: {
+			type: Number,
+			required: true,
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue
new file mode 100644
index 0000000000..8ba70c5855
--- /dev/null
+++ b/packages/frontend/src/components/page/page.image.vue
@@ -0,0 +1,28 @@
+<template>
+<div class="lzyxtsnt">
+	<ImgWithBlurhash v-if="image" :hash="image.blurhash" :src="image.url" :alt="image.comment" :title="image.comment" :cover="false"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent, PropType } from 'vue';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+import * as os from '@/os';
+import { ImageBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+const props = defineProps<{
+	block: PropType<ImageBlock>,
+	hpml: PropType<Hpml>,
+}>();
+
+const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId);
+</script>
+
+<style lang="scss" scoped>
+.lzyxtsnt {
+	> img {
+		max-width: 100%;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
new file mode 100644
index 0000000000..7d5c484a1b
--- /dev/null
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="voxdxuby">
+	<XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
+	<XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import XNote from '@/components/MkNote.vue';
+import XNoteDetailed from '@/components/MkNoteDetailed.vue';
+import * as os from '@/os';
+import { NoteBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		XNote,
+		XNoteDetailed,
+	},
+	props: {
+		block: {
+			type: Object as PropType<NoteBlock>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const note: Ref<Record<string, any> | null> = ref(null);
+
+		onMounted(() => {
+			os.api('notes/show', { noteId: props.block.note })
+			.then(result => {
+				note.value = result;
+			});
+		});
+
+		return {
+			note,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.voxdxuby {
+	margin: 1em 0;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.number-input.vue b/packages/frontend/src/components/page/page.number-input.vue
new file mode 100644
index 0000000000..50cf6d0770
--- /dev/null
+++ b/packages/frontend/src/components/page/page.number-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+	<MkInput class="kudkigyw" :model-value="value" type="number" @update:model-value="updateValue($event)">
+		<template #label>{{ hpml.interpolate(block.text) }}</template>
+	</MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { NumberInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		MkInput,
+	},
+	props: {
+		block: {
+			type: Object as PropType<NumberInputVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function updateValue(newValue) {
+			props.hpml.updatePageVar(props.block.name, newValue);
+			props.hpml.eval();
+		}
+
+		return {
+			value,
+			updateValue,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+	display: inline-block;
+	min-width: 300px;
+	max-width: 450px;
+	margin: 8px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue
new file mode 100644
index 0000000000..0ef50d65cd
--- /dev/null
+++ b/packages/frontend/src/components/page/page.post.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="ngbfujlo">
+	<MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea>
+	<MkButton class="button" primary :disabled="posting || posted" @click="post()">
+		<i v-if="posted" class="ti ti-check"></i>
+		<i v-else class="ti ti-send"></i>
+	</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import MkButton from '../MkButton.vue';
+import { apiUrl } from '@/config';
+import * as os from '@/os';
+import { PostBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+	components: {
+		MkTextarea,
+		MkButton,
+	},
+	props: {
+		block: {
+			type: Object as PropType<PostBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	data() {
+		return {
+			text: this.hpml.interpolate(this.block.text),
+			posted: false,
+			posting: false,
+		};
+	},
+	watch: {
+		'hpml.vars': {
+			handler() {
+				this.text = this.hpml.interpolate(this.block.text);
+			},
+			deep: true,
+		},
+	},
+	methods: {
+		upload() {
+			const promise = new Promise((ok) => {
+				const canvas = this.hpml.canvases[this.block.canvasId];
+				canvas.toBlob(blob => {
+					const formData = new FormData();
+					formData.append('file', blob);
+					formData.append('i', this.$i.token);
+					if (this.$store.state.uploadFolder) {
+						formData.append('folderId', this.$store.state.uploadFolder);
+					}
+
+					window.fetch(apiUrl + '/drive/files/create', {
+						method: 'POST',
+						body: formData,
+					})
+						.then(response => response.json())
+						.then(f => {
+							ok(f);
+						});
+				});
+			});
+			os.promiseDialog(promise);
+			return promise;
+		},
+		async post() {
+			this.posting = true;
+			const file = this.block.attachCanvasImage ? await this.upload() : null;
+			os.apiWithDialog('notes/create', {
+				text: this.text === '' ? null : this.text,
+				fileIds: file ? [file.id] : undefined,
+			}).then(() => {
+				this.posted = true;
+			});
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ngbfujlo {
+	position: relative;
+	padding: 32px;
+	border-radius: 6px;
+	box-shadow: 0 2px 8px var(--shadow);
+	z-index: 1;
+
+	> .button {
+		margin-top: 32px;
+	}
+
+	@media (max-width: 600px) {
+		padding: 16px;
+
+		> .button {
+			margin-top: 16px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.radio-button.vue b/packages/frontend/src/components/page/page.radio-button.vue
new file mode 100644
index 0000000000..b4d9e01a54
--- /dev/null
+++ b/packages/frontend/src/components/page/page.radio-button.vue
@@ -0,0 +1,45 @@
+<template>
+<div>
+	<div>{{ hpml.interpolate(block.title) }}</div>
+	<MkRadio v-for="item in block.values" :key="item" :modelValue="value" :value="item" @update:model-value="updateValue($event)">{{ item }}</MkRadio>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkRadio from '../form/radio.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { RadioButtonVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		MkRadio,
+	},
+	props: {
+		block: {
+			type: Object as PropType<RadioButtonVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function updateValue(newValue: string) {
+			props.hpml.updatePageVar(props.block.name, newValue);
+			props.hpml.eval();
+		}
+
+		return {
+			value,
+			updateValue,
+		};
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue
new file mode 100644
index 0000000000..630c1f5179
--- /dev/null
+++ b/packages/frontend/src/components/page/page.section.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="sdgxphyu">
+	<component :is="'h' + h">{{ block.title }}</component>
+
+	<div class="children">
+		<XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h + 1"/>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+import * as os from '@/os';
+import { SectionBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+	components: {
+		XBlock: defineAsyncComponent(() => import('./page.block.vue')),
+	},
+	props: {
+		block: {
+			type: Object as PropType<SectionBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+		h: {
+			required: true,
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.sdgxphyu {
+	margin: 1.5em 0;
+
+	> h2 {
+		font-size: 1.35em;
+		margin: 0 0 0.5em 0;
+	}
+
+	> h3 {
+		font-size: 1em;
+		margin: 0 0 0.5em 0;
+	}
+
+	> h4 {
+		font-size: 1em;
+		margin: 0 0 0.5em 0;
+	}
+
+	> .children {
+		//padding 16px
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.switch.vue b/packages/frontend/src/components/page/page.switch.vue
new file mode 100644
index 0000000000..64dc4ff8aa
--- /dev/null
+++ b/packages/frontend/src/components/page/page.switch.vue
@@ -0,0 +1,55 @@
+<template>
+<div class="hkcxmtwj">
+	<MkSwitch :model-value="value" @update:model-value="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkSwitch from '../form/switch.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { SwitchVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		MkSwitch,
+	},
+	props: {
+		block: {
+			type: Object as PropType<SwitchVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function updateValue(newValue: boolean) {
+			props.hpml.updatePageVar(props.block.name, newValue);
+			props.hpml.eval();
+		}
+
+		return {
+			value,
+			updateValue,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.hkcxmtwj {
+	display: inline-block;
+	margin: 16px auto;
+
+	& + .hkcxmtwj {
+		margin-left: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.text-input.vue b/packages/frontend/src/components/page/page.text-input.vue
new file mode 100644
index 0000000000..840649ece6
--- /dev/null
+++ b/packages/frontend/src/components/page/page.text-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+	<MkInput class="kudkigyw" :model-value="value" type="text" @update:model-value="updateValue($event)">
+		<template #label>{{ hpml.interpolate(block.text) }}</template>
+	</MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		MkInput,
+	},
+	props: {
+		block: {
+			type: Object as PropType<TextInputVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function updateValue(newValue) {
+			props.hpml.updatePageVar(props.block.name, newValue);
+			props.hpml.eval();
+		}
+
+		return {
+			value,
+			updateValue,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+	display: inline-block;
+	min-width: 300px;
+	max-width: 450px;
+	margin: 8px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
new file mode 100644
index 0000000000..689c484521
--- /dev/null
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="mrdgzndn">
+	<Mfm :key="text" :text="text" :is-note="false" :i="$i"/>
+	<MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url"/>
+</div>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineAsyncComponent, defineComponent, PropType } from 'vue';
+import * as mfm from 'mfm-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+	components: {
+		MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')),
+	},
+	props: {
+		block: {
+			type: Object as PropType<TextBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	data() {
+		return {
+			text: this.hpml.interpolate(this.block.text),
+		};
+	},
+	computed: {
+		urls(): string[] {
+			if (this.text) {
+				return extractUrlFromMfm(mfm.parse(this.text));
+			} else {
+				return [];
+			}
+		},
+	},
+	watch: {
+		'hpml.vars': {
+			handler() {
+				this.text = this.hpml.interpolate(this.block.text);
+			},
+			deep: true,
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.mrdgzndn {
+	&:not(:first-child) {
+		margin-top: 0.5em;
+	}
+
+	&:not(:last-child) {
+		margin-bottom: 0.5em;
+	}
+
+	> .url {
+		margin: 0.5em 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.textarea-input.vue b/packages/frontend/src/components/page/page.textarea-input.vue
new file mode 100644
index 0000000000..507e1bd97b
--- /dev/null
+++ b/packages/frontend/src/components/page/page.textarea-input.vue
@@ -0,0 +1,47 @@
+<template>
+<div>
+	<MkTextarea :model-value="value" @update:model-value="updateValue($event)">
+		<template #label>{{ hpml.interpolate(block.text) }}</template>
+	</MkTextarea>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { HpmlTextInput } from '@/scripts/hpml';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+	components: {
+		MkTextarea,
+	},
+	props: {
+		block: {
+			type: Object as PropType<TextInputVarBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const value = computed(() => {
+			return props.hpml.vars.value[props.block.name];
+		});
+
+		function updateValue(newValue) {
+			props.hpml.updatePageVar(props.block.name, newValue);
+			props.hpml.eval();
+		}
+
+		return {
+			value,
+			updateValue,
+		};
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/page/page.textarea.vue b/packages/frontend/src/components/page/page.textarea.vue
new file mode 100644
index 0000000000..f809925081
--- /dev/null
+++ b/packages/frontend/src/components/page/page.textarea.vue
@@ -0,0 +1,39 @@
+<template>
+<MkTextarea :model-value="text" readonly></MkTextarea>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+
+export default defineComponent({
+	components: {
+		MkTextarea,
+	},
+	props: {
+		block: {
+			type: Object as PropType<TextBlock>,
+			required: true,
+		},
+		hpml: {
+			type: Object as PropType<Hpml>,
+			required: true,
+		},
+	},
+	data() {
+		return {
+			text: this.hpml.interpolate(this.block.text),
+		};
+	},
+	watch: {
+		'hpml.vars': {
+			handler() {
+				this.text = this.hpml.interpolate(this.block.text);
+			},
+			deep: true,
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue
new file mode 100644
index 0000000000..b5cb73c009
--- /dev/null
+++ b/packages/frontend/src/components/page/page.vue
@@ -0,0 +1,85 @@
+<template>
+<div v-if="hpml" class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
+	<XBlock v-for="child in page.content" :key="child.id" :block="child" :hpml="hpml" :h="2"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue';
+import { parse } from '@syuilo/aiscript';
+import XBlock from './page.block.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { url } from '@/config';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+	components: {
+		XBlock,
+	},
+	props: {
+		page: {
+			type: Object as PropType<Record<string, any>>,
+			required: true,
+		},
+	},
+	setup(props, ctx) {
+		const hpml = new Hpml(props.page, {
+			randomSeed: Math.random(),
+			visitor: $i,
+			url: url,
+			enableAiScript: !defaultStore.state.disablePagesScript,
+		});
+
+		onMounted(() => {
+			nextTick(() => {
+				if (props.page.script && hpml.aiscript) {
+					let ast;
+					try {
+						ast = parse(props.page.script);
+					} catch (err) {
+						console.error(err);
+						/*os.alert({
+							type: 'error',
+							text: 'Syntax error :('
+						});*/
+						return;
+					}
+					hpml.aiscript.exec(ast).then(() => {
+						hpml.eval();
+					}).catch(err => {
+						console.error(err);
+						/*os.alert({
+							type: 'error',
+							text: err
+						});*/
+					});
+				} else {
+					hpml.eval();
+				}
+			});
+			onUnmounted(() => {
+				if (hpml.aiscript) hpml.aiscript.abort();
+			});
+		});
+
+		return {
+			hpml,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.iroscrza {
+	&.serif {
+		> div {
+			font-family: serif;
+		}
+	}
+
+	&.center {
+		text-align: center;
+	}
+}
+</style>
diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts
new file mode 100644
index 0000000000..f2022b0f02
--- /dev/null
+++ b/packages/frontend/src/config.ts
@@ -0,0 +1,15 @@
+const address = new URL(location.href);
+const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content;
+
+export const host = address.host;
+export const hostname = address.hostname;
+export const url = address.origin;
+export const apiUrl = url + '/api';
+export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
+export const lang = localStorage.getItem('lang');
+export const langs = _LANGS_;
+export const locale = JSON.parse(localStorage.getItem('locale'));
+export const version = _VERSION_;
+export const instanceName = siteName === 'Misskey' ? host : siteName;
+export const ui = localStorage.getItem('ui');
+export const debug = localStorage.getItem('debug') === 'true';
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
new file mode 100644
index 0000000000..77366cf07b
--- /dev/null
+++ b/packages/frontend/src/const.ts
@@ -0,0 +1,45 @@
+// ブラウザで直接表示することを許可するファイルの種類のリスト
+// ここに含まれないものは application/octet-stream としてレスポンスされる
+// SVGはXSSを生むので許可しない
+export const FILE_TYPE_BROWSERSAFE = [
+	// Images
+	'image/png',
+	'image/gif',
+	'image/jpeg',
+	'image/webp',
+	'image/avif',
+	'image/apng',
+	'image/bmp',
+	'image/tiff',
+	'image/x-icon',
+
+	// OggS
+	'audio/opus',
+	'video/ogg',
+	'audio/ogg',
+	'application/ogg',
+
+	// ISO/IEC base media file format
+	'video/quicktime',
+	'video/mp4',
+	'audio/mp4',
+	'video/x-m4v',
+	'audio/x-m4a',
+	'video/3gpp',
+	'video/3gpp2',
+
+	'video/mpeg',
+	'audio/mpeg',
+
+	'video/webm',
+	'audio/webm',
+
+	'audio/aac',
+	'audio/x-flac',
+	'audio/vnd.wave',
+];
+/*
+https://github.com/sindresorhus/file-type/blob/main/supported.js
+https://github.com/sindresorhus/file-type/blob/main/core.js
+https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
+*/
diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts
new file mode 100644
index 0000000000..619c9f0b6d
--- /dev/null
+++ b/packages/frontend/src/directives/adaptive-border.ts
@@ -0,0 +1,24 @@
+import { Directive } from 'vue';
+
+export default {
+	mounted(src, binding, vn) {
+		const getBgColor = (el: HTMLElement) => {
+			const style = window.getComputedStyle(el);
+			if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
+				return style.backgroundColor;
+			} else {
+				return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
+			}
+		};
+	
+		const parentBg = getBgColor(src.parentElement);
+
+		const myBg = window.getComputedStyle(src).backgroundColor;
+
+		if (parentBg === myBg) {
+			src.style.borderColor = 'var(--divider)';
+		} else {
+			src.style.borderColor = myBg;
+		}
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts
new file mode 100644
index 0000000000..04e1c6a404
--- /dev/null
+++ b/packages/frontend/src/directives/anim.ts
@@ -0,0 +1,18 @@
+import { Directive } from 'vue';
+
+export default {
+	beforeMount(src, binding, vn) {
+		src.style.opacity = '0';
+		src.style.transform = 'scale(0.9)';
+		// ページネーションと相性が悪いので
+		//if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`;
+		src.classList.add('_zoom');
+	},
+
+	mounted(src, binding, vn) {
+		window.setTimeout(() => {
+			src.style.opacity = '1';
+			src.style.transform = 'none';
+		}, 1);
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts
new file mode 100644
index 0000000000..7fa43fc34a
--- /dev/null
+++ b/packages/frontend/src/directives/appear.ts
@@ -0,0 +1,22 @@
+import { Directive } from 'vue';
+
+export default {
+	mounted(src, binding, vn) {
+		const fn = binding.value;
+		if (fn == null) return;
+
+		const observer = new IntersectionObserver(entries => {
+			if (entries.some(entry => entry.isIntersecting)) {
+				fn();
+			}
+		});
+
+		observer.observe(src);
+
+		src._observer_ = observer;
+	},
+
+	unmounted(src, binding, vn) {
+		if (src._observer_) src._observer_.disconnect();
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts
new file mode 100644
index 0000000000..e2f514b7ca
--- /dev/null
+++ b/packages/frontend/src/directives/click-anime.ts
@@ -0,0 +1,31 @@
+import { Directive } from 'vue';
+import { defaultStore } from '@/store';
+
+export default {
+	mounted(el, binding, vn) {
+		/*
+		if (!defaultStore.state.animation) return;
+
+		el.classList.add('_anime_bounce_standBy');
+
+		el.addEventListener('mousedown', () => {
+			el.classList.add('_anime_bounce_standBy');
+			el.classList.add('_anime_bounce_ready');
+
+			el.addEventListener('mouseleave', () => {
+				el.classList.remove('_anime_bounce_ready');
+			});
+		});
+
+		el.addEventListener('click', () => {
+			el.classList.add('_anime_bounce');
+		});
+
+		el.addEventListener('animationend', () => {
+			el.classList.remove('_anime_bounce_ready');
+			el.classList.remove('_anime_bounce');
+			el.classList.add('_anime_bounce_standBy');
+		});
+		*/
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts
new file mode 100644
index 0000000000..62e0ac3b94
--- /dev/null
+++ b/packages/frontend/src/directives/follow-append.ts
@@ -0,0 +1,35 @@
+import { Directive } from 'vue';
+import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
+
+export default {
+	mounted(src, binding, vn) {
+		if (binding.value === false) return;
+
+		let isBottom = true;
+
+		const container = getScrollContainer(src)!;
+		container.addEventListener('scroll', () => {
+			const pos = getScrollPosition(container);
+			const viewHeight = container.clientHeight;
+			const height = container.scrollHeight;
+			isBottom = (pos + viewHeight > height - 32);
+		}, { passive: true });
+		container.scrollTop = container.scrollHeight;
+
+		const ro = new ResizeObserver((entries, observer) => {
+			if (isBottom) {
+				const height = container.scrollHeight;
+				container.scrollTop = height;
+			}
+		});
+
+		ro.observe(src);
+
+		// TODO: 新たにプロパティを作るのをやめMapを使う
+		src._ro_ = ro;
+	},
+
+	unmounted(src, binding, vn) {
+		if (src._ro_) src._ro_.unobserve(src);
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts
new file mode 100644
index 0000000000..ff3bdd78ac
--- /dev/null
+++ b/packages/frontend/src/directives/get-size.ts
@@ -0,0 +1,54 @@
+import { Directive } from 'vue';
+
+const mountings = new Map<Element, {
+	resize: ResizeObserver;
+	intersection?: IntersectionObserver;
+	fn: (w: number, h: number) => void;
+}>();
+
+function calc(src: Element) {
+	const info = mountings.get(src);
+	const height = src.clientHeight;
+	const width = src.clientWidth;
+
+	if (!info) return;
+
+	// アクティベート前などでsrcが描画されていない場合
+	if (!height) {
+		// IntersectionObserverで表示検出する
+		if (!info.intersection) {
+			info.intersection = new IntersectionObserver(entries => {
+				if (entries.some(entry => entry.isIntersecting)) calc(src);
+			});
+		}
+		info.intersection.observe(src);
+		return;
+	}
+	if (info.intersection) {
+		info.intersection.disconnect();
+		delete info.intersection;
+	}
+
+	info.fn(width, height);
+}
+
+export default {
+	mounted(src, binding, vn) {
+		const resize = new ResizeObserver((entries, observer) => {
+			calc(src);
+		});
+		resize.observe(src);
+
+		mountings.set(src, { resize, fn: binding.value });
+		calc(src);
+	},
+
+	unmounted(src, binding, vn) {
+		binding.value(0, 0);
+		const info = mountings.get(src);
+		if (!info) return;
+		info.resize.disconnect();
+		if (info.intersection) info.intersection.disconnect();
+		mountings.delete(src);
+	},
+} as Directive<Element, (w: number, h: number) => void>;
diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts
new file mode 100644
index 0000000000..dfc5f646a4
--- /dev/null
+++ b/packages/frontend/src/directives/hotkey.ts
@@ -0,0 +1,24 @@
+import { Directive } from 'vue';
+import { makeHotkey } from '../scripts/hotkey';
+
+export default {
+	mounted(el, binding) {
+		el._hotkey_global = binding.modifiers.global === true;
+
+		el._keyHandler = makeHotkey(binding.value);
+
+		if (el._hotkey_global) {
+			document.addEventListener('keydown', el._keyHandler);
+		} else {
+			el.addEventListener('keydown', el._keyHandler);
+		}
+	},
+
+	unmounted(el) {
+		if (el._hotkey_global) {
+			document.removeEventListener('keydown', el._keyHandler);
+		} else {
+			el.removeEventListener('keydown', el._keyHandler);
+		}
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts
new file mode 100644
index 0000000000..401a917cba
--- /dev/null
+++ b/packages/frontend/src/directives/index.ts
@@ -0,0 +1,28 @@
+import { App } from 'vue';
+
+import userPreview from './user-preview';
+import size from './size';
+import getSize from './get-size';
+import ripple from './ripple';
+import tooltip from './tooltip';
+import hotkey from './hotkey';
+import appear from './appear';
+import anim from './anim';
+import clickAnime from './click-anime';
+import panel from './panel';
+import adaptiveBorder from './adaptive-border';
+
+export default function(app: App) {
+	app.directive('userPreview', userPreview);
+	app.directive('user-preview', userPreview);
+	app.directive('size', size);
+	app.directive('get-size', getSize);
+	app.directive('ripple', ripple);
+	app.directive('tooltip', tooltip);
+	app.directive('hotkey', hotkey);
+	app.directive('appear', appear);
+	app.directive('anim', anim);
+	app.directive('click-anime', clickAnime);
+	app.directive('panel', panel);
+	app.directive('adaptive-border', adaptiveBorder);
+}
diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts
new file mode 100644
index 0000000000..d31dc41ed4
--- /dev/null
+++ b/packages/frontend/src/directives/panel.ts
@@ -0,0 +1,24 @@
+import { Directive } from 'vue';
+
+export default {
+	mounted(src, binding, vn) {
+		const getBgColor = (el: HTMLElement) => {
+			const style = window.getComputedStyle(el);
+			if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
+				return style.backgroundColor;
+			} else {
+				return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
+			}
+		};
+	
+		const parentBg = getBgColor(src.parentElement);
+
+		const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel');
+
+		if (parentBg === myBg) {
+			src.style.backgroundColor = 'var(--bg)';
+		} else {
+			src.style.backgroundColor = 'var(--panel)';
+		}
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts
new file mode 100644
index 0000000000..d32f7ab441
--- /dev/null
+++ b/packages/frontend/src/directives/ripple.ts
@@ -0,0 +1,18 @@
+import Ripple from '@/components/MkRipple.vue';
+import { popup } from '@/os';
+
+export default {
+	mounted(el, binding, vn) {
+		// 明示的に false であればバインドしない
+		if (binding.value === false) return;
+
+		el.addEventListener('click', () => {
+			const rect = el.getBoundingClientRect();
+
+			const x = rect.left + (el.offsetWidth / 2);
+			const y = rect.top + (el.offsetHeight / 2);
+
+			popup(Ripple, { x, y }, {}, 'end');
+		});
+	},
+};
diff --git a/packages/frontend/src/directives/size.ts b/packages/frontend/src/directives/size.ts
new file mode 100644
index 0000000000..da8bd78ea1
--- /dev/null
+++ b/packages/frontend/src/directives/size.ts
@@ -0,0 +1,123 @@
+import { Directive } from 'vue';
+
+type Value = { max?: number[]; min?: number[]; };
+
+//const observers = new Map<Element, ResizeObserver>();
+const mountings = new Map<Element, {
+	value: Value;
+	resize: ResizeObserver;
+	intersection?: IntersectionObserver;
+	previousWidth: number;
+	twoPreviousWidth: number;
+}>();
+
+type ClassOrder = {
+	add: string[];
+	remove: string[];
+};
+
+const isContainerQueriesSupported = ('container' in document.documentElement.style);
+
+const cache = new Map<string, ClassOrder>();
+
+function getClassOrder(width: number, queue: Value): ClassOrder {
+	const getMaxClass = (v: number) => `max-width_${v}px`;
+	const getMinClass = (v: number) => `min-width_${v}px`;
+
+	return {
+		add: [
+			...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []),
+			...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []),
+		],
+		remove: [
+			...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []),
+			...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []),
+		],
+	};
+}
+
+function applyClassOrder(el: Element, order: ClassOrder) {
+	el.classList.add(...order.add);
+	el.classList.remove(...order.remove);
+}
+
+function getOrderName(width: number, queue: Value): string {
+	return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`;
+}
+
+function calc(el: Element) {
+	const info = mountings.get(el);
+	const width = el.clientWidth;
+
+	if (!info || info.previousWidth === width) return;
+
+	// アクティベート前などでsrcが描画されていない場合
+	if (!width) {
+		// IntersectionObserverで表示検出する
+		if (!info.intersection) {
+			info.intersection = new IntersectionObserver(entries => {
+				if (entries.some(entry => entry.isIntersecting)) calc(el);
+			});
+		}
+		info.intersection.observe(el);
+		return;
+	}
+	if (info.intersection) {
+		info.intersection.disconnect();
+		delete info.intersection;
+	}
+
+	mountings.set(el, { ...info, ...{ previousWidth: width, twoPreviousWidth: info.previousWidth }});
+
+	// Prevent infinite resizing
+	// https://github.com/misskey-dev/misskey/issues/9076
+	if (info.twoPreviousWidth === width) {
+		return;
+	}
+
+	const cached = cache.get(getOrderName(width, info.value));
+	if (cached) {
+		applyClassOrder(el, cached);
+	} else {
+		const order = getClassOrder(width, info.value);
+		cache.set(getOrderName(width, info.value), order);
+		applyClassOrder(el, order);
+	}
+}
+
+export default {
+	mounted(src, binding, vn) {
+		if (isContainerQueriesSupported) return;
+
+		const resize = new ResizeObserver((entries, observer) => {
+			calc(src);
+		});
+
+		mountings.set(src, {
+			value: binding.value,
+			resize,
+			previousWidth: 0,
+			twoPreviousWidth: 0,
+		});
+
+		calc(src);
+		resize.observe(src);
+	},
+
+	updated(src, binding, vn) {
+		if (isContainerQueriesSupported) return;
+
+		mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value }));
+		calc(src);
+	},
+
+	unmounted(src, binding, vn) {
+		if (isContainerQueriesSupported) return;
+
+		const info = mountings.get(src);
+		if (!info) return;
+		info.resize.disconnect();
+		if (info.intersection) info.intersection.disconnect();
+		mountings.delete(src);
+	},
+} as Directive<Element, Value>;
diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts
new file mode 100644
index 0000000000..5d13497b5f
--- /dev/null
+++ b/packages/frontend/src/directives/tooltip.ts
@@ -0,0 +1,93 @@
+// TODO: useTooltip関数使うようにしたい
+// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明
+
+import { defineAsyncComponent, Directive, ref } from 'vue';
+import { isTouchUsing } from '@/scripts/touch';
+import { popup, alert } from '@/os';
+
+const start = isTouchUsing ? 'touchstart' : 'mouseover';
+const end = isTouchUsing ? 'touchend' : 'mouseleave';
+
+export default {
+	mounted(el: HTMLElement, binding, vn) {
+		const delay = binding.modifiers.noDelay ? 0 : 100;
+
+		const self = (el as any)._tooltipDirective_ = {} as any;
+
+		self.text = binding.value as string;
+		self._close = null;
+		self.showTimer = null;
+		self.hideTimer = null;
+		self.checkTimer = null;
+
+		self.close = () => {
+			if (self._close) {
+				window.clearInterval(self.checkTimer);
+				self._close();
+				self._close = null;
+			}
+		};
+
+		if (binding.arg === 'dialog') {
+			el.addEventListener('click', (ev) => {
+				ev.preventDefault();
+				ev.stopPropagation();
+				alert({
+					type: 'info',
+					text: binding.value,
+				});
+				return false;
+			});
+		}
+
+		self.show = () => {
+			if (!document.body.contains(el)) return;
+			if (self._close) return;
+			if (self.text == null) return;
+
+			const showing = ref(true);
+			popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
+				showing,
+				text: self.text,
+				asMfm: binding.modifiers.mfm,
+				direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top',
+				targetElement: el,
+			}, {}, 'closed');
+
+			self._close = () => {
+				showing.value = false;
+			};
+		};
+
+		el.addEventListener('selectstart', ev => {
+			ev.preventDefault();
+		});
+
+		el.addEventListener(start, () => {
+			window.clearTimeout(self.showTimer);
+			window.clearTimeout(self.hideTimer);
+			self.showTimer = window.setTimeout(self.show, delay);
+		}, { passive: true });
+
+		el.addEventListener(end, () => {
+			window.clearTimeout(self.showTimer);
+			window.clearTimeout(self.hideTimer);
+			self.hideTimer = window.setTimeout(self.close, delay);
+		}, { passive: true });
+
+		el.addEventListener('click', () => {
+			window.clearTimeout(self.showTimer);
+			self.close();
+		});
+	},
+
+	updated(el, binding) {
+		const self = el._tooltipDirective_;
+		self.text = binding.value as string;
+	},
+
+	unmounted(el, binding, vn) {
+		const self = el._tooltipDirective_;
+		window.clearInterval(self.checkTimer);
+	},
+} as Directive;
diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts
new file mode 100644
index 0000000000..ed5f00ca65
--- /dev/null
+++ b/packages/frontend/src/directives/user-preview.ts
@@ -0,0 +1,118 @@
+import { defineAsyncComponent, Directive, ref } from 'vue';
+import autobind from 'autobind-decorator';
+import { popup } from '@/os';
+
+export class UserPreview {
+	private el;
+	private user;
+	private showTimer;
+	private hideTimer;
+	private checkTimer;
+	private promise;
+
+	constructor(el, user) {
+		this.el = el;
+		this.user = user;
+
+		this.attach();
+	}
+
+	@autobind
+	private show() {
+		if (!document.body.contains(this.el)) return;
+		if (this.promise) return;
+
+		const showing = ref(true);
+
+		popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), {
+			showing,
+			q: this.user,
+			source: this.el,
+		}, {
+			mouseover: () => {
+				window.clearTimeout(this.hideTimer);
+			},
+			mouseleave: () => {
+				window.clearTimeout(this.showTimer);
+				this.hideTimer = window.setTimeout(this.close, 500);
+			},
+		}, 'closed');
+
+		this.promise = {
+			cancel: () => {
+				showing.value = false;
+			},
+		};
+
+		this.checkTimer = window.setInterval(() => {
+			if (!document.body.contains(this.el)) {
+				window.clearTimeout(this.showTimer);
+				window.clearTimeout(this.hideTimer);
+				this.close();
+			}
+		}, 1000);
+	}
+
+	@autobind
+	private close() {
+		if (this.promise) {
+			window.clearInterval(this.checkTimer);
+			this.promise.cancel();
+			this.promise = null;
+		}
+	}
+
+	@autobind
+	private onMouseover() {
+		window.clearTimeout(this.showTimer);
+		window.clearTimeout(this.hideTimer);
+		this.showTimer = window.setTimeout(this.show, 500);
+	}
+
+	@autobind
+	private onMouseleave() {
+		window.clearTimeout(this.showTimer);
+		window.clearTimeout(this.hideTimer);
+		this.hideTimer = window.setTimeout(this.close, 500);
+	}
+
+	@autobind
+	private onClick() {
+		window.clearTimeout(this.showTimer);
+		this.close();
+	}
+
+	@autobind
+	public attach() {
+		this.el.addEventListener('mouseover', this.onMouseover);
+		this.el.addEventListener('mouseleave', this.onMouseleave);
+		this.el.addEventListener('click', this.onClick);
+	}
+
+	@autobind
+	public detach() {
+		this.el.removeEventListener('mouseover', this.onMouseover);
+		this.el.removeEventListener('mouseleave', this.onMouseleave);
+		this.el.removeEventListener('click', this.onClick);
+		window.clearInterval(this.checkTimer);
+	}
+}
+
+export default {
+	mounted(el: HTMLElement, binding, vn) {
+		if (binding.value == null) return;
+
+		// TODO: 新たにプロパティを作るのをやめMapを使う
+		// ただメモリ的には↓の方が省メモリかもしれないので検討中
+		const self = (el as any)._userPreviewDirective_ = {} as any;
+
+		self.preview = new UserPreview(el, binding.value);
+	},
+
+	unmounted(el, binding, vn) {
+		if (binding.value == null) return;
+
+		const self = el._userPreviewDirective_;
+		self.preview.detach();
+	},
+} as Directive;
diff --git a/packages/frontend/src/emojilist.json b/packages/frontend/src/emojilist.json
new file mode 100644
index 0000000000..402e82e33b
--- /dev/null
+++ b/packages/frontend/src/emojilist.json
@@ -0,0 +1,1785 @@
+[
+	{ "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] },
+	{ "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] },
+	{ "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] },
+	{ "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] },
+	{ "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] },
+	{ "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] },
+	{ "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] },
+	{ "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] },
+	{ "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] },
+	{ "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] },
+	{ "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] },
+	{ "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] },
+	{ "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] },
+	{ "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] },
+	{ "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] },
+	{ "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] },
+	{ "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] },
+	{ "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] },
+	{ "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] },
+	{ "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] },
+	{ "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] },
+	{ "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
+	{ "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] },
+	{ "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] },
+	{ "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
+	{ "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] },
+	{ "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] },
+	{ "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] },
+	{ "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] },
+	{ "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] },
+	{ "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] },
+	{ "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] },
+	{ "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] },
+	{ "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] },
+	{ "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] },
+	{ "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] },
+	{ "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] },
+	{ "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] },
+	{ "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] },
+	{ "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] },
+	{ "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] },
+	{ "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] },
+	{ "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] },
+	{ "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] },
+	{ "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] },
+	{ "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] },
+	{ "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] },
+	{ "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] },
+	{ "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] },
+	{ "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] },
+	{ "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] },
+	{ "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] },
+	{ "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] },
+	{ "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] },
+	{ "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] },
+	{ "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] },
+	{ "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] },
+	{ "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] },
+	{ "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] },
+	{ "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] },
+	{ "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] },
+	{ "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] },
+	{ "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] },
+	{ "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] },
+	{ "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] },
+	{ "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] },
+	{ "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] },
+	{ "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] },
+	{ "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] },
+	{ "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] },
+	{ "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] },
+	{ "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] },
+	{ "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] },
+	{ "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] },
+	{ "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] },
+	{ "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] },
+	{ "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] },
+	{ "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] },
+	{ "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] },
+	{ "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] },
+	{ "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] },
+	{ "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] },
+	{ "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] },
+	{ "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] },
+	{ "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] },
+	{ "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] },
+	{ "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] },
+	{ "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] },
+	{ "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] },
+	{ "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] },
+	{ "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] },
+	{ "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] },
+	{ "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] },
+	{ "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] },
+	{ "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] },
+	{ "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] },
+	{ "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] },
+	{ "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] },
+	{ "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] },
+	{ "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] },
+	{ "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] },
+	{ "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] },
+	{ "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] },
+	{ "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] },
+	{ "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] },
+	{ "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] },
+	{ "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] },
+	{ "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] },
+	{ "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] },
+	{ "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] },
+	{ "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] },
+	{ "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] },
+	{ "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] },
+	{ "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] },
+	{ "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] },
+	{ "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] },
+	{ "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] },
+	{ "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] },
+	{ "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] },
+	{ "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] },
+	{ "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] },
+	{ "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] },
+	{ "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] },
+	{ "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] },
+	{ "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] },
+	{ "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] },
+	{ "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] },
+	{ "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] },
+	{ "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] },
+	{ "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] },
+	{ "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] },
+	{ "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] },
+	{ "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] },
+	{ "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] },
+	{ "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] },
+	{ "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] },
+	{ "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] },
+	{ "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] },
+	{ "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] },
+	{ "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] },
+	{ "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] },
+	{ "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] },
+	{ "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] },
+	{ "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] },
+	{ "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] },
+	{ "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] },
+	{ "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] },
+	{ "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] },
+	{ "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] },
+	{ "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] },
+	{ "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] },
+	{ "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] },
+	{ "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] },
+	{ "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] },
+	{ "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] },
+	{ "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] },
+	{ "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] },
+	{ "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] },
+	{ "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] },
+	{ "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] },
+	{ "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] },
+	{ "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] },
+	{ "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] },
+	{ "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] },
+	{ "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] },
+	{ "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] },
+	{ "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] },
+	{ "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] },
+	{ "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] },
+	{ "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] },
+	{ "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] },
+	{ "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] },
+	{ "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] },
+	{ "category": "people", "char": "🫁", "name": "lungs", "keywords": [] },
+	{ "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] },
+	{ "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] },
+	{ "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] },
+	{ "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] },
+	{ "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] },
+	{ "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] },
+	{ "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] },
+	{ "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] },
+	{ "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] },
+	{ "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] },
+	{ "category": "people", "char": "🧑‍🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] },
+	{ "category": "people", "char": "👩‍🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] },
+	{ "category": "people", "char": "👨‍🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] },
+	{ "category": "people", "char": "🧑‍🦰", "name": "red_hair", "keywords": ["redhead"] },
+	{ "category": "people", "char": "👩‍🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] },
+	{ "category": "people", "char": "👨‍🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] },
+	{ "category": "people", "char": "👱‍♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] },
+	{ "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] },
+	{ "category": "people", "char": "🧑‍🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] },
+	{ "category": "people", "char": "👩‍🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] },
+	{ "category": "people", "char": "👨‍🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] },
+	{ "category": "people", "char": "🧑‍🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] },
+	{ "category": "people", "char": "👩‍🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] },
+	{ "category": "people", "char": "👨‍🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] },
+	{ "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] },
+	{ "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] },
+	{ "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] },
+	{ "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] },
+	{ "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] },
+	{ "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] },
+	{ "category": "people", "char": "👳‍♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] },
+	{ "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] },
+	{ "category": "people", "char": "👮‍♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] },
+	{ "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] },
+	{ "category": "people", "char": "👷‍♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] },
+	{ "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] },
+	{ "category": "people", "char": "💂‍♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] },
+	{ "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] },
+	{ "category": "people", "char": "🕵️‍♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] },
+	{ "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] },
+	{ "category": "people", "char": "🧑‍⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] },
+	{ "category": "people", "char": "👩‍⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] },
+	{ "category": "people", "char": "👨‍⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] },
+	{ "category": "people", "char": "👩‍🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🍳", "name": "cook", "keywords": ["chef", "human"] },
+	{ "category": "people", "char": "👩‍🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🎓", "name": "student", "keywords": ["graduate", "human"] },
+	{ "category": "people", "char": "👩‍🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] },
+	{ "category": "people", "char": "👩‍🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] },
+	{ "category": "people", "char": "👩‍🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] },
+	{ "category": "people", "char": "👩‍🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] },
+	{ "category": "people", "char": "🧑‍💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] },
+	{ "category": "people", "char": "👩‍💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] },
+	{ "category": "people", "char": "👨‍💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] },
+	{ "category": "people", "char": "🧑‍💼", "name": "office_worker", "keywords": ["business", "manager", "human"] },
+	{ "category": "people", "char": "👩‍💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] },
+	{ "category": "people", "char": "👨‍💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] },
+	{ "category": "people", "char": "👩‍🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] },
+	{ "category": "people", "char": "👨‍🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] },
+	{ "category": "people", "char": "🧑‍🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] },
+	{ "category": "people", "char": "👩‍🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🎨", "name": "artist", "keywords": ["painter", "human"] },
+	{ "category": "people", "char": "👩‍🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🚒", "name": "firefighter", "keywords": ["fireman", "human"] },
+	{ "category": "people", "char": "👩‍🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] },
+	{ "category": "people", "char": "🧑‍✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] },
+	{ "category": "people", "char": "👩‍✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] },
+	{ "category": "people", "char": "👨‍✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] },
+	{ "category": "people", "char": "🧑‍🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] },
+	{ "category": "people", "char": "👩‍🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] },
+	{ "category": "people", "char": "👨‍🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] },
+	{ "category": "people", "char": "🧑‍⚖️", "name": "judge", "keywords": ["justice", "court", "human"] },
+	{ "category": "people", "char": "👩‍⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] },
+	{ "category": "people", "char": "👨‍⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] },
+	{ "category": "people", "char": "🦸‍♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] },
+	{ "category": "people", "char": "🦸‍♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] },
+	{ "category": "people", "char": "🦹‍♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] },
+	{ "category": "people", "char": "🦹‍♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] },
+	{ "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] },
+	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] },
+	{ "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] },
+	{ "category": "people", "char": "🥷", "name": "ninja", "keywords": [] },
+	{ "category": "people", "char": "🧙‍♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] },
+	{ "category": "people", "char": "🧙‍♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] },
+	{ "category": "people", "char": "🧝‍♀️", "name": "woman_elf", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧝‍♂️", "name": "man_elf", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "🧛‍♀️", "name": "woman_vampire", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧛‍♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] },
+	{ "category": "people", "char": "🧟‍♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] },
+	{ "category": "people", "char": "🧟‍♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] },
+	{ "category": "people", "char": "🧞‍♀️", "name": "woman_genie", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧞‍♂️", "name": "man_genie", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "🧜‍♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] },
+	{ "category": "people", "char": "🧜‍♂️", "name": "merman", "keywords": ["man", "male", "triton"] },
+	{ "category": "people", "char": "🧚‍♀️", "name": "woman_fairy", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧚‍♂️", "name": "man_fairy", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] },
+	{ "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] },
+	{ "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] },
+	{ "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] },
+	{ "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] },
+	{ "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] },
+	{ "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] },
+	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] },
+	{ "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] },
+	{ "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] },
+	{ "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
+	{ "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
+	{ "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
+	{ "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
+	{ "category": "people", "char": "🏃‍♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] },
+	{ "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] },
+	{ "category": "people", "char": "🚶‍♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] },
+	{ "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] },
+	{ "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] },
+	{ "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] },
+	{ "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] },
+	{ "category": "people", "char": "👯‍♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] },
+	{ "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] },
+	{ "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] },
+	{ "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] },
+	{ "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] },
+	{ "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] },
+	{ "category": "people", "char": "🙇‍♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] },
+	{ "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] },
+	{ "category": "people", "char": "🤦‍♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] },
+	{ "category": "people", "char": "🤦‍♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] },
+	{ "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] },
+	{ "category": "people", "char": "🤷‍♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] },
+	{ "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] },
+	{ "category": "people", "char": "💁‍♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] },
+	{ "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] },
+	{ "category": "people", "char": "🙅‍♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] },
+	{ "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] },
+	{ "category": "people", "char": "🙆‍♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] },
+	{ "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] },
+	{ "category": "people", "char": "🙋‍♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] },
+	{ "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] },
+	{ "category": "people", "char": "🙎‍♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] },
+	{ "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] },
+	{ "category": "people", "char": "🙍‍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] },
+	{ "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] },
+	{ "category": "people", "char": "💇‍♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] },
+	{ "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] },
+	{ "category": "people", "char": "💆‍♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] },
+	{ "category": "people", "char": "🧖‍♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] },
+	{ "category": "people", "char": "🧖‍♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] },
+	{ "category": "people", "char": "🧏‍♀️", "name": "woman_deaf", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧏‍♂️", "name": "man_deaf", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "🧍‍♀️", "name": "woman_standing", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧍‍♂️", "name": "man_standing", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "🧎‍♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] },
+	{ "category": "people", "char": "🧎‍♂️", "name": "man_kneeling", "keywords": ["man", "male"] },
+	{ "category": "people", "char": "🧑‍🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] },
+	{ "category": "people", "char": "👩‍🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] },
+	{ "category": "people", "char": "👨‍🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] },
+	{ "category": "people", "char": "🧑‍🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] },
+	{ "category": "people", "char": "👩‍🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] },
+	{ "category": "people", "char": "👨‍🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] },
+	{ "category": "people", "char": "🧑‍🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] },
+	{ "category": "people", "char": "👩‍🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] },
+	{ "category": "people", "char": "👨‍🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] },
+	{ "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
+	{ "category": "people", "char": "👩‍❤️‍👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
+	{ "category": "people", "char": "👨‍❤️‍👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
+	{ "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
+	{ "category": "people", "char": "👩‍❤️‍💋‍👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
+	{ "category": "people", "char": "👨‍❤️‍💋‍👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
+	{ "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] },
+	{ "category": "people", "char": "👨‍👩‍👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] },
+	{ "category": "people", "char": "👨‍👩‍👧‍👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👩‍👦‍👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👩‍👧‍👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👩‍👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👩‍👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👩‍👧‍👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👩‍👦‍👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👩‍👧‍👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👨‍👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👨‍👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👨‍👧‍👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👨‍👦‍👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👨‍👧‍👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] },
+	{ "category": "people", "char": "👩‍👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] },
+	{ "category": "people", "char": "👩‍👧‍👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👦‍👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "👩‍👧‍👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] },
+	{ "category": "people", "char": "👨‍👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] },
+	{ "category": "people", "char": "👨‍👧‍👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👦‍👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "👨‍👧‍👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
+	{ "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] },
+	{ "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] },
+	{ "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] },
+	{ "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] },
+	{ "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] },
+	{ "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] },
+	{ "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] },
+	{ "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] },
+	{ "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] },
+	{ "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
+	{ "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
+	{ "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] },
+	{ "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] },
+	{ "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] },
+	{ "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] },
+	{ "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] },
+	{ "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] },
+	{ "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] },
+	{ "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] },
+	{ "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] },
+	{ "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] },
+	{ "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] },
+	{ "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] },
+	{ "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] },
+	{ "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] },
+	{ "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] },
+	{ "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] },
+	{ "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] },
+	{ "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] },
+	{ "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] },
+	{ "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] },
+	{ "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] },
+	{ "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] },
+	{ "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] },
+	{ "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] },
+	{ "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] },
+	{ "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] },
+	{ "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] },
+	{ "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] },
+	{ "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] },
+	{ "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] },
+	{ "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] },
+	{ "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] },
+	{ "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] },
+	{ "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] },
+	{ "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] },
+	{ "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] },
+	{ "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] },
+	{ "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
+	{ "category": "animals_and_nature", "char": "🐈‍⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
+	{ "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] },
+	{ "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] },
+	{ "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] },
+	{ "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] },
+	{ "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] },
+	{ "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] },
+	{ "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
+	{ "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] },
+	{ "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] },
+	{ "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] },
+	{ "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] },
+	{ "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] },
+	{ "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] },
+	{ "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] },
+	{ "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] },
+	{ "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] },
+	{ "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] },
+	{ "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] },
+	{ "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] },
+	{ "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] },
+	{ "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] },
+	{ "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] },
+	{ "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] },
+	{ "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] },
+	{ "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] },
+	{ "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] },
+	{ "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] },
+	{ "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] },
+	{ "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] },
+	{ "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] },
+	{ "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] },
+	{ "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] },
+	{ "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] },
+	{ "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] },
+	{ "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] },
+	{ "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] },
+	{ "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] },
+	{ "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] },
+	{ "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] },
+	{ "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] },
+	{ "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] },
+	{ "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] },
+	{ "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] },
+	{ "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] },
+	{ "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] },
+	{ "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] },
+	{ "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] },
+	{ "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] },
+	{ "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] },
+	{ "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] },
+	{ "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] },
+	{ "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] },
+	{ "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] },
+	{ "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] },
+	{ "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] },
+	{ "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] },
+	{ "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] },
+	{ "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] },
+	{ "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] },
+	{ "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
+	{ "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] },
+	{ "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] },
+	{ "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] },
+	{ "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] },
+	{ "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] },
+	{ "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] },
+	{ "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] },
+	{ "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] },
+	{ "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] },
+	{ "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] },
+	{ "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] },
+	{ "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] },
+	{ "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] },
+	{ "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] },
+	{ "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] },
+	{ "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] },
+	{ "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] },
+	{ "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] },
+	{ "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] },
+	{ "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] },
+	{ "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] },
+	{ "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] },
+	{ "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] },
+	{ "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] },
+	{ "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] },
+	{ "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] },
+	{ "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] },
+	{ "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] },
+	{ "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐻‍❄️", "name": "polar_bear", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] },
+	{ "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
+	{ "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
+	{ "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🐕‍🦺", "name": "service_dog", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] },
+	{ "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] },
+	{ "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] },
+	{ "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] },
+	{ "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] },
+	{ "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] },
+	{ "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] },
+	{ "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] },
+	{ "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] },
+	{ "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] },
+	{ "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] },
+	{ "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] },
+	{ "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] },
+	{ "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] },
+	{ "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] },
+	{ "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] },
+	{ "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] },
+	{ "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] },
+	{ "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] },
+	{ "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] },
+	{ "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] },
+	{ "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] },
+	{ "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] },
+	{ "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] },
+	{ "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] },
+	{ "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] },
+	{ "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] },
+	{ "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] },
+	{ "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] },
+	{ "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] },
+	{ "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] },
+	{ "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] },
+	{ "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] },
+	{ "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] },
+	{ "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] },
+	{ "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
+	{ "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] },
+	{ "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] },
+	{ "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] },
+	{ "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] },
+	{ "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] },
+	{ "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] },
+	{ "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] },
+	{ "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] },
+	{ "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] },
+	{ "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] },
+	{ "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] },
+	{ "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] },
+	{ "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] },
+	{ "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] },
+	{ "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] },
+	{ "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] },
+	{ "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] },
+	{ "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] },
+	{ "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] },
+	{ "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] },
+	{ "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] },
+	{ "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] },
+	{ "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] },
+	{ "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] },
+	{ "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] },
+	{ "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] },
+	{ "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] },
+	{ "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] },
+	{ "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] },
+	{ "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] },
+	{ "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] },
+	{ "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] },
+	{ "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] },
+	{ "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] },
+	{ "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] },
+	{ "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] },
+	{ "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] },
+	{ "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] },
+	{ "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] },
+	{ "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] },
+	{ "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] },
+	{ "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] },
+	{ "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] },
+	{ "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] },
+	{ "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] },
+	{ "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] },
+	{ "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] },
+	{ "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] },
+	{ "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] },
+	{ "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] },
+	{ "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] },
+	{ "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] },
+	{ "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] },
+	{ "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] },
+	{ "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] },
+	{ "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] },
+	{ "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] },
+	{ "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] },
+	{ "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] },
+	{ "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] },
+	{ "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] },
+	{ "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] },
+	{ "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] },
+	{ "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] },
+	{ "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] },
+	{ "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] },
+	{ "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] },
+	{ "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] },
+	{ "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] },
+	{ "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] },
+	{ "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] },
+	{ "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] },
+	{ "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] },
+	{ "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] },
+	{ "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] },
+	{ "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] },
+	{ "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] },
+	{ "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] },
+	{ "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] },
+	{ "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] },
+	{ "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] },
+	{ "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] },
+	{ "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] },
+	{ "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] },
+	{ "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] },
+	{ "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] },
+	{ "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] },
+	{ "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] },
+	{ "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] },
+	{ "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] },
+	{ "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] },
+	{ "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] },
+	{ "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] },
+	{ "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] },
+	{ "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] },
+	{ "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] },
+	{ "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] },
+	{ "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] },
+	{ "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] },
+	{ "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] },
+	{ "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] },
+	{ "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] },
+	{ "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] },
+	{ "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] },
+	{ "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] },
+	{ "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] },
+	{ "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] },
+	{ "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] },
+	{ "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] },
+	{ "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] },
+	{ "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] },
+	{ "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] },
+	{ "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] },
+	{ "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] },
+	{ "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] },
+	{ "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] },
+	{ "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] },
+	{ "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] },
+	{ "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] },
+	{ "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
+	{ "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
+	{ "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] },
+	{ "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] },
+	{ "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] },
+	{ "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] },
+	{ "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] },
+	{ "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] },
+	{ "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] },
+	{ "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] },
+	{ "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] },
+	{ "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] },
+	{ "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] },
+	{ "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] },
+	{ "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] },
+	{ "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] },
+	{ "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] },
+	{ "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] },
+	{ "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] },
+	{ "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] },
+	{ "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] },
+	{ "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] },
+	{ "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] },
+	{ "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] },
+	{ "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] },
+	{ "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] },
+	{ "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] },
+	{ "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] },
+	{ "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] },
+	{ "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] },
+	{ "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] },
+	{ "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] },
+	{ "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] },
+	{ "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] },
+	{ "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] },
+	{ "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] },
+	{ "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] },
+	{ "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] },
+	{ "category": "activity", "char": "🏌️‍♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] },
+	{ "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] },
+	{ "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] },
+	{ "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] },
+	{ "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] },
+	{ "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] },
+	{ "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] },
+	{ "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] },
+	{ "category": "activity", "char": "🤼‍♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] },
+	{ "category": "activity", "char": "🤼‍♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] },
+	{ "category": "activity", "char": "🤸‍♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] },
+	{ "category": "activity", "char": "🤸‍♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] },
+	{ "category": "activity", "char": "🤾‍♀️", "name": "woman_playing_handball", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🤾‍♂️", "name": "man_playing_handball", "keywords": ["sports"] },
+	{ "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] },
+	{ "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] },
+	{ "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] },
+	{ "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] },
+	{ "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] },
+	{ "category": "activity", "char": "🚣‍♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] },
+	{ "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] },
+	{ "category": "activity", "char": "🧗‍♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] },
+	{ "category": "activity", "char": "🧗‍♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] },
+	{ "category": "activity", "char": "🏊‍♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] },
+	{ "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] },
+	{ "category": "activity", "char": "🤽‍♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] },
+	{ "category": "activity", "char": "🤽‍♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] },
+	{ "category": "activity", "char": "🧘‍♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
+	{ "category": "activity", "char": "🧘‍♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
+	{ "category": "activity", "char": "🏄‍♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] },
+	{ "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] },
+	{ "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] },
+	{ "category": "activity", "char": "⛹️‍♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] },
+	{ "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] },
+	{ "category": "activity", "char": "🏋️‍♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] },
+	{ "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] },
+	{ "category": "activity", "char": "🚴‍♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] },
+	{ "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] },
+	{ "category": "activity", "char": "🚵‍♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] },
+	{ "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] },
+	{ "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] },
+	{ "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] },
+	{ "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] },
+	{ "category": "activity", "char": "🪢", "name": "knot", "keywords": [] },
+	{ "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] },
+	{ "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] },
+	{ "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] },
+	{ "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] },
+	{ "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] },
+	{ "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] },
+	{ "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] },
+	{ "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] },
+	{ "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] },
+	{ "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] },
+	{ "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] },
+	{ "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] },
+	{ "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] },
+	{ "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] },
+	{ "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] },
+	{ "category": "activity", "char": "🤹‍♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
+	{ "category": "activity", "char": "🤹‍♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
+	{ "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] },
+	{ "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] },
+	{ "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] },
+	{ "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] },
+	{ "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] },
+	{ "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] },
+	{ "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] },
+	{ "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] },
+	{ "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] },
+	{ "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] },
+	{ "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] },
+	{ "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] },
+	{ "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] },
+	{ "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] },
+	{ "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] },
+	{ "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] },
+	{ "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] },
+	{ "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] },
+	{ "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] },
+	{ "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] },
+	{ "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] },
+	{ "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] },
+	{ "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] },
+	{ "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] },
+	{ "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] },
+	{ "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] },
+	{ "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] },
+	{ "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] },
+	{ "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] },
+	{ "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] },
+	{ "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] },
+	{ "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] },
+	{ "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] },
+	{ "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] },
+	{ "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] },
+	{ "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] },
+	{ "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] },
+	{ "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] },
+	{ "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] },
+	{ "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] },
+	{ "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] },
+	{ "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] },
+	{ "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] },
+	{ "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] },
+	{ "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] },
+	{ "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] },
+	{ "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] },
+	{ "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] },
+	{ "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] },
+	{ "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] },
+	{ "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] },
+	{ "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] },
+	{ "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] },
+	{ "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] },
+	{ "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] },
+	{ "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] },
+	{ "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] },
+	{ "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] },
+	{ "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] },
+	{ "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] },
+	{ "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] },
+	{ "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] },
+	{ "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] },
+	{ "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] },
+	{ "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] },
+	{ "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] },
+	{ "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] },
+	{ "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] },
+	{ "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] },
+	{ "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] },
+	{ "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] },
+	{ "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] },
+	{ "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] },
+	{ "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] },
+	{ "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] },
+	{ "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] },
+	{ "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] },
+	{ "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] },
+	{ "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] },
+	{ "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] },
+	{ "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] },
+	{ "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] },
+	{ "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] },
+	{ "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] },
+	{ "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] },
+	{ "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] },
+	{ "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] },
+	{ "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] },
+	{ "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] },
+	{ "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] },
+	{ "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] },
+	{ "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] },
+	{ "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] },
+	{ "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] },
+	{ "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] },
+	{ "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] },
+	{ "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] },
+	{ "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] },
+	{ "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] },
+	{ "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] },
+	{ "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] },
+	{ "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] },
+	{ "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] },
+	{ "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] },
+	{ "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] },
+	{ "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] },
+	{ "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] },
+	{ "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] },
+	{ "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] },
+	{ "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] },
+	{ "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] },
+	{ "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] },
+	{ "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] },
+	{ "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] },
+	{ "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] },
+	{ "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] },
+	{ "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] },
+	{ "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] },
+	{ "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] },
+	{ "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] },
+	{ "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] },
+	{ "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] },
+	{ "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] },
+	{ "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] },
+	{ "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] },
+	{ "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] },
+	{ "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] },
+	{ "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] },
+	{ "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] },
+	{ "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] },
+	{ "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] },
+	{ "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] },
+	{ "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] },
+	{ "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] },
+	{ "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] },
+	{ "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] },
+	{ "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] },
+	{ "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] },
+	{ "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] },
+	{ "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] },
+	{ "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] },
+	{ "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] },
+	{ "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] },
+	{ "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] },
+	{ "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] },
+	{ "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] },
+	{ "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] },
+	{ "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] },
+	{ "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] },
+	{ "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] },
+	{ "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] },
+	{ "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] },
+	{ "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] },
+	{ "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] },
+	{ "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] },
+	{ "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] },
+	{ "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] },
+	{ "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] },
+	{ "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] },
+	{ "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] },
+	{ "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] },
+	{ "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] },
+	{ "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] },
+	{ "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] },
+	{ "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] },
+	{ "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] },
+	{ "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] },
+	{ "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] },
+	{ "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] },
+	{ "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] },
+	{ "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] },
+	{ "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] },
+	{ "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] },
+	{ "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] },
+	{ "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] },
+	{ "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] },
+	{ "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] },
+	{ "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] },
+	{ "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] },
+	{ "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] },
+	{ "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] },
+	{ "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] },
+	{ "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] },
+	{ "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] },
+	{ "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] },
+	{ "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] },
+	{ "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] },
+	{ "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] },
+	{ "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] },
+	{ "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] },
+	{ "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] },
+	{ "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] },
+	{ "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] },
+	{ "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] },
+	{ "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] },
+	{ "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] },
+	{ "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] },
+	{ "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] },
+	{ "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] },
+	{ "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] },
+	{ "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] },
+	{ "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] },
+	{ "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] },
+	{ "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] },
+	{ "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] },
+	{ "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] },
+	{ "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] },
+	{ "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] },
+	{ "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] },
+	{ "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] },
+	{ "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] },
+	{ "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] },
+	{ "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] },
+	{ "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] },
+	{ "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] },
+	{ "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] },
+	{ "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] },
+	{ "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] },
+	{ "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] },
+	{ "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] },
+	{ "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] },
+	{ "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] },
+	{ "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] },
+	{ "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] },
+	{ "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] },
+	{ "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] },
+	{ "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] },
+	{ "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] },
+	{ "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] },
+	{ "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] },
+	{ "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] },
+	{ "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] },
+	{ "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] },
+	{ "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] },
+	{ "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] },
+	{ "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] },
+	{ "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] },
+	{ "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] },
+	{ "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] },
+	{ "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] },
+	{ "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] },
+	{ "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] },
+	{ "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] },
+	{ "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
+	{ "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
+	{ "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
+	{ "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] },
+	{ "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] },
+	{ "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] },
+	{ "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] },
+	{ "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] },
+	{ "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] },
+	{ "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] },
+	{ "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] },
+	{ "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] },
+	{ "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] },
+	{ "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] },
+	{ "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] },
+	{ "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] },
+	{ "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] },
+	{ "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] },
+	{ "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] },
+	{ "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] },
+	{ "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] },
+	{ "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] },
+	{ "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] },
+	{ "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] },
+	{ "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] },
+	{ "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] },
+	{ "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] },
+	{ "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] },
+	{ "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] },
+	{ "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] },
+	{ "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] },
+	{ "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] },
+	{ "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] },
+	{ "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] },
+	{ "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] },
+	{ "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] },
+	{ "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] },
+	{ "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] },
+	{ "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] },
+	{ "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] },
+	{ "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] },
+	{ "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] },
+	{ "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] },
+	{ "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] },
+	{ "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] },
+	{ "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] },
+	{ "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] },
+	{ "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] },
+	{ "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] },
+	{ "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] },
+	{ "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] },
+	{ "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] },
+	{ "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] },
+	{ "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] },
+	{ "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] },
+	{ "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] },
+	{ "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] },
+	{ "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] },
+	{ "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] },
+	{ "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] },
+	{ "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] },
+	{ "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] },
+	{ "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] },
+	{ "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] },
+	{ "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] },
+	{ "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] },
+	{ "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] },
+	{ "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] },
+	{ "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] },
+	{ "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] },
+	{ "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] },
+	{ "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] },
+	{ "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] },
+	{ "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] },
+	{ "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] },
+	{ "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] },
+	{ "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] },
+	{ "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] },
+	{ "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] },
+	{ "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] },
+	{ "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] },
+	{ "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] },
+	{ "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] },
+	{ "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] },
+	{ "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] },
+	{ "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] },
+	{ "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] },
+	{ "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] },
+	{ "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] },
+	{ "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] },
+	{ "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] },
+	{ "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] },
+	{ "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] },
+	{ "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] },
+	{ "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] },
+	{ "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] },
+	{ "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] },
+	{ "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] },
+	{ "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] },
+	{ "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] },
+	{ "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] },
+	{ "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] },
+	{ "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] },
+	{ "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] },
+	{ "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] },
+	{ "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] },
+	{ "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] },
+	{ "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] },
+	{ "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] },
+	{ "category": "objects", "char": "🏳️‍🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] },
+	{ "category": "objects", "char": "🏳️‍⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] },
+	{ "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] },
+	{ "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] },
+	{ "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] },
+	{ "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] },
+	{ "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] },
+	{ "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] },
+	{ "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] },
+	{ "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] },
+	{ "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] },
+	{ "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] },
+	{ "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] },
+	{ "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] },
+	{ "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] },
+	{ "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] },
+	{ "category": "objects", "char": "🪧", "name": "placard", "keywords": [] },
+	{ "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] },
+	{ "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] },
+	{ "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] },
+	{ "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] },
+	{ "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] },
+	{ "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] },
+	{ "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] },
+	{ "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] },
+	{ "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] },
+	{ "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] },
+	{ "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] },
+	{ "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] },
+	{ "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] },
+	{ "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] },
+	{ "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] },
+	{ "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
+	{ "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
+	{ "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] },
+	{ "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] },
+	{ "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] },
+	{ "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] },
+	{ "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] },
+	{ "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] },
+	{ "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] },
+	{ "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
+	{ "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
+	{ "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
+	{ "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] },
+	{ "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
+	{ "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
+	{ "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
+	{ "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] },
+	{ "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] },
+	{ "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
+	{ "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] },
+	{ "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] },
+	{ "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] },
+	{ "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] },
+	{ "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] },
+	{ "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] },
+	{ "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] },
+	{ "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] },
+	{ "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] },
+	{ "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] },
+	{ "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] },
+	{ "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] },
+	{ "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] },
+	{ "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] },
+	{ "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] },
+	{ "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] },
+	{ "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] },
+	{ "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] },
+	{ "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] },
+	{ "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] },
+	{ "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] },
+	{ "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] },
+	{ "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] },
+	{ "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] },
+	{ "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] },
+	{ "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] },
+	{ "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] },
+	{ "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] },
+	{ "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] },
+	{ "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] },
+	{ "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] },
+	{ "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] },
+	{ "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] },
+	{ "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] },
+	{ "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] },
+	{ "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] },
+	{ "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] },
+	{ "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] },
+	{ "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] },
+	{ "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] },
+	{ "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] },
+	{ "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] },
+	{ "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] },
+	{ "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] },
+	{ "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] },
+	{ "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] },
+	{ "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] },
+	{ "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] },
+	{ "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] },
+	{ "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] },
+	{ "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] },
+	{ "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] },
+	{ "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] },
+	{ "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] },
+	{ "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] },
+	{ "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] },
+	{ "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] },
+	{ "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] },
+	{ "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] },
+	{ "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] },
+	{ "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] },
+	{ "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] },
+	{ "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] },
+	{ "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] },
+	{ "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] },
+	{ "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] },
+	{ "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] },
+	{ "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] },
+	{ "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] },
+	{ "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] },
+	{ "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] },
+	{ "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] },
+	{ "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] },
+	{ "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] },
+	{ "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] },
+	{ "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] },
+	{ "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] },
+	{ "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] },
+	{ "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] },
+	{ "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
+	{ "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] },
+	{ "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] },
+	{ "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] },
+	{ "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] },
+	{ "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] },
+	{ "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] },
+	{ "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] },
+	{ "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] },
+	{ "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] },
+	{ "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] },
+	{ "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] },
+	{ "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] },
+	{ "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] },
+	{ "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] },
+	{ "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] },
+	{ "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] },
+	{ "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] },
+	{ "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] },
+	{ "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] },
+	{ "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] },
+	{ "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] },
+	{ "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] },
+	{ "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] },
+	{ "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] },
+	{ "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] },
+	{ "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] },
+	{ "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] },
+	{ "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] },
+	{ "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] },
+	{ "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] },
+	{ "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] },
+	{ "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] },
+	{ "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] },
+	{ "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] },
+	{ "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] },
+	{ "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] },
+	{ "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] },
+	{ "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] },
+	{ "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] },
+	{ "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] },
+	{ "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] },
+	{ "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] },
+	{ "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] },
+	{ "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] },
+	{ "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] },
+	{ "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] },
+	{ "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] },
+	{ "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] },
+	{ "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] },
+	{ "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] },
+	{ "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] },
+	{ "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] },
+	{ "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] },
+	{ "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] },
+	{ "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] },
+	{ "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] },
+	{ "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] },
+	{ "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] },
+	{ "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] },
+	{ "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] },
+	{ "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] },
+	{ "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] },
+	{ "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] },
+	{ "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] },
+	{ "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] },
+	{ "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] },
+	{ "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] },
+	{ "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] },
+	{ "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] },
+	{ "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] },
+	{ "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] },
+	{ "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] },
+	{ "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] },
+	{ "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] },
+	{ "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] },
+	{ "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] },
+	{ "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] },
+	{ "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] },
+	{ "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] },
+	{ "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] },
+	{ "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] },
+	{ "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] },
+	{ "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] },
+	{ "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] },
+	{ "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] },
+	{ "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] },
+	{ "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] },
+	{ "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] },
+	{ "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] },
+	{ "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] },
+	{ "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] },
+	{ "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
+	{ "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
+	{ "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
+	{ "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
+	{ "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] },
+	{ "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] },
+	{ "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] },
+	{ "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] },
+	{ "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] },
+	{ "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] },
+	{ "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] },
+	{ "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] },
+	{ "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] },
+	{ "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] },
+	{ "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] },
+	{ "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] },
+	{ "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] },
+	{ "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] },
+	{ "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] },
+	{ "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] },
+	{ "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] },
+	{ "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] },
+	{ "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] },
+	{ "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] },
+	{ "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] },
+	{ "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] },
+	{ "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] },
+	{ "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] },
+	{ "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] },
+	{ "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] },
+	{ "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] },
+	{ "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] },
+	{ "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] },
+	{ "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] },
+	{ "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] },
+	{ "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] },
+	{ "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] },
+	{ "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] },
+	{ "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] },
+	{ "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] },
+	{ "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] },
+	{ "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] },
+	{ "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] },
+	{ "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] },
+	{ "category": "flags", "char": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "name": "england", "keywords": ["flag", "english"] },
+	{ "category": "flags", "char": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "name": "scotland", "keywords": ["flag", "scottish"] },
+	{ "category": "flags", "char": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", "name": "wales", "keywords": ["flag", "welsh"] },
+	{ "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] },
+	{ "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] },
+	{ "category": "flags", "char": "🏴‍☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] }
+]
+
diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts
new file mode 100644
index 0000000000..dbbd908b8f
--- /dev/null
+++ b/packages/frontend/src/events.ts
@@ -0,0 +1,4 @@
+import { EventEmitter } from 'eventemitter3';
+
+// TODO: 型付け
+export const globalEvents = new EventEmitter();
diff --git a/packages/frontend/src/filters/bytes.ts b/packages/frontend/src/filters/bytes.ts
new file mode 100644
index 0000000000..c80f2f0ed2
--- /dev/null
+++ b/packages/frontend/src/filters/bytes.ts
@@ -0,0 +1,9 @@
+export default (v, digits = 0) => {
+	if (v == null) return '?';
+	const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+	if (v === 0) return '0';
+	const isMinus = v < 0;
+	if (isMinus) v = -v;
+	const i = Math.floor(Math.log(v) / Math.log(1024));
+	return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i];
+};
diff --git a/packages/frontend/src/filters/note.ts b/packages/frontend/src/filters/note.ts
new file mode 100644
index 0000000000..cd9b7d98d2
--- /dev/null
+++ b/packages/frontend/src/filters/note.ts
@@ -0,0 +1,3 @@
+export const notePage = note => {
+	return `/notes/${note.id}`;
+};
diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts
new file mode 100644
index 0000000000..880a848ca4
--- /dev/null
+++ b/packages/frontend/src/filters/number.ts
@@ -0,0 +1 @@
+export default n => n == null ? 'N/A' : n.toLocaleString();
diff --git a/packages/frontend/src/filters/user.ts b/packages/frontend/src/filters/user.ts
new file mode 100644
index 0000000000..ff2f7e2dae
--- /dev/null
+++ b/packages/frontend/src/filters/user.ts
@@ -0,0 +1,15 @@
+import * as misskey from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import { url } from '@/config';
+
+export const acct = (user: misskey.Acct) => {
+	return Acct.toString(user);
+};
+
+export const userName = (user: misskey.entities.User) => {
+	return user.name || user.username;
+};
+
+export const userPage = (user: misskey.Acct, path?, absolute = false) => {
+	return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
+};
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts
new file mode 100644
index 0000000000..31e066960d
--- /dev/null
+++ b/packages/frontend/src/i18n.ts
@@ -0,0 +1,5 @@
+import { markRaw } from 'vue';
+import { locale } from '@/config';
+import { I18n } from '@/scripts/i18n';
+
+export const i18n = markRaw(new I18n(locale));
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
new file mode 100644
index 0000000000..508d3262b3
--- /dev/null
+++ b/packages/frontend/src/init.ts
@@ -0,0 +1,433 @@
+/**
+ * Client entry point
+ */
+// https://vitejs.dev/config/build-options.html#build-modulepreload
+import 'vite/modulepreload-polyfill';
+
+import '@/style.scss';
+
+//#region account indexedDB migration
+import { set } from '@/scripts/idb-proxy';
+
+if (localStorage.getItem('accounts') != null) {
+	set('accounts', JSON.parse(localStorage.getItem('accounts')));
+	localStorage.removeItem('accounts');
+}
+//#endregion
+
+import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
+import { compareVersions } from 'compare-versions';
+import JSON5 from 'json5';
+
+import widgets from '@/widgets';
+import directives from '@/directives';
+import components from '@/components';
+import { version, ui, lang, host } from '@/config';
+import { applyTheme } from '@/scripts/theme';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { i18n } from '@/i18n';
+import { confirm, alert, post, popup, toast } from '@/os';
+import { stream } from '@/stream';
+import * as sound from '@/scripts/sound';
+import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
+import { defaultStore, ColdDeviceStorage } from '@/store';
+import { fetchInstance, instance } from '@/instance';
+import { makeHotkey } from '@/scripts/hotkey';
+import { search } from '@/scripts/search';
+import { deviceKind } from '@/scripts/device-kind';
+import { initializeSw } from '@/scripts/initialize-sw';
+import { reloadChannel } from '@/scripts/unison-reload';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { getUrlWithoutLoginId } from '@/scripts/login-id';
+import { getAccountFromId } from '@/scripts/get-account-from-id';
+
+(async () => {
+	console.info(`Misskey v${version}`);
+
+	if (_DEV_) {
+		console.warn('Development mode!!!');
+
+		console.info(`vue ${vueVersion}`);
+
+		(window as any).$i = $i;
+		(window as any).$store = defaultStore;
+
+		window.addEventListener('error', event => {
+			console.error(event);
+			/*
+			alert({
+				type: 'error',
+				title: 'DEV: Unhandled error',
+				text: event.message
+			});
+			*/
+		});
+
+		window.addEventListener('unhandledrejection', event => {
+			console.error(event);
+			/*
+			alert({
+				type: 'error',
+				title: 'DEV: Unhandled promise rejection',
+				text: event.reason
+			});
+			*/
+		});
+	}
+
+	// タッチデバイスでCSSの:hoverを機能させる
+	document.addEventListener('touchend', () => {}, { passive: true });
+
+	// 一斉リロード
+	reloadChannel.addEventListener('message', path => {
+		if (path !== null) location.href = path;
+		else location.reload();
+	});
+
+	// If mobile, insert the viewport meta tag
+	if (['smartphone', 'tablet'].includes(deviceKind)) {
+		const viewport = document.getElementsByName('viewport').item(0);
+		viewport.setAttribute('content',
+			`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
+	}
+
+	//#region Set lang attr
+	const html = document.documentElement;
+	html.setAttribute('lang', lang);
+	//#endregion
+
+	//#region loginId
+	const params = new URLSearchParams(location.search);
+	const loginId = params.get('loginId');
+
+	if (loginId) {
+		const target = getUrlWithoutLoginId(location.href);
+
+		if (!$i || $i.id !== loginId) {
+			const account = await getAccountFromId(loginId);
+			if (account) {
+				await login(account.token, target);
+			}
+		}
+
+		history.replaceState({ misskey: 'loginId' }, '', target);
+	}
+
+	//#endregion
+
+	//#region Fetch user
+	if ($i && $i.token) {
+		if (_DEV_) {
+			console.log('account cache found. refreshing...');
+		}
+
+		refreshAccount();
+	} else {
+		if (_DEV_) {
+			console.log('no account cache found.');
+		}
+
+		// 連携ログインの場合用にCookieを参照する
+		const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1];
+
+		if (i != null && i !== 'null') {
+			if (_DEV_) {
+				console.log('signing...');
+			}
+
+			try {
+				document.body.innerHTML = '<div>Please wait...</div>';
+				await login(i);
+			} catch (err) {
+				// Render the error screen
+				// TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな)
+				document.body.innerHTML = '<div id="err">Oops!</div>';
+			}
+		} else {
+			if (_DEV_) {
+				console.log('not signed in');
+			}
+		}
+	}
+	//#endregion
+
+	const fetchInstanceMetaPromise = fetchInstance();
+
+	fetchInstanceMetaPromise.then(() => {
+		localStorage.setItem('v', instance.version);
+
+		// Init service worker
+		initializeSw();
+	});
+
+	const app = createApp(
+		window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
+		!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
+		ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
+		ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
+		defineAsyncComponent(() => import('@/ui/universal.vue')),
+	);
+
+	if (_DEV_) {
+		app.config.performance = true;
+	}
+
+	app.config.globalProperties = {
+		$i,
+		$store: defaultStore,
+		$instance: instance,
+		$t: i18n.t,
+		$ts: i18n.ts,
+	};
+
+	widgets(app);
+	directives(app);
+	components(app);
+
+	const splash = document.getElementById('splash');
+	// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
+	if (splash) splash.addEventListener('transitionend', () => {
+		splash.remove();
+	});
+
+	// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
+	// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する
+	const rootEl = (() => {
+		const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
+
+		const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+
+		if (currentEl) {
+			console.warn('multiple import detected');
+			return currentEl;
+		}
+
+		const rootEl = document.createElement('div');
+		rootEl.id = MISSKEY_MOUNT_DIV_ID;
+		document.body.appendChild(rootEl);
+		return rootEl;
+	})();
+
+	app.mount(rootEl);
+
+	// boot.jsのやつを解除
+	window.onerror = null;
+	window.onunhandledrejection = null;
+
+	reactionPicker.init();
+
+	if (splash) {
+		splash.style.opacity = '0';
+		splash.style.pointerEvents = 'none';
+	}
+
+	// クライアントが更新されたか？
+	const lastVersion = localStorage.getItem('lastVersion');
+	if (lastVersion !== version) {
+		localStorage.setItem('lastVersion', version);
+
+		// テーマリビルドするため
+		localStorage.removeItem('theme');
+
+		try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
+			if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
+				// ログインしてる場合だけ
+				if ($i) {
+					popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
+				}
+			}
+		} catch (err) {
+		}
+	}
+
+	// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
+	watch(defaultStore.reactiveState.darkMode, (darkMode) => {
+		applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
+	}, { immediate: localStorage.theme == null });
+
+	const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
+	const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
+
+	watch(darkTheme, (theme) => {
+		if (defaultStore.state.darkMode) {
+			applyTheme(theme);
+		}
+	});
+
+	watch(lightTheme, (theme) => {
+		if (!defaultStore.state.darkMode) {
+			applyTheme(theme);
+		}
+	});
+
+	//#region Sync dark mode
+	if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
+		defaultStore.set('darkMode', isDeviceDarkmode());
+	}
+
+	window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
+		if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
+			defaultStore.set('darkMode', mql.matches);
+		}
+	});
+	//#endregion
+
+	fetchInstanceMetaPromise.then(() => {
+		if (defaultStore.state.themeInitial) {
+			if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
+			if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
+			defaultStore.set('themeInitial', false);
+		}
+	});
+
+	watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
+		document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
+	}, { immediate: true });
+
+	watch(defaultStore.reactiveState.useBlurEffect, v => {
+		if (v) {
+			document.documentElement.style.removeProperty('--blur');
+		} else {
+			document.documentElement.style.setProperty('--blur', 'none');
+		}
+	}, { immediate: true });
+
+	let reloadDialogShowing = false;
+	stream.on('_disconnected_', async () => {
+		if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
+			location.reload();
+		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
+			if (reloadDialogShowing) return;
+			reloadDialogShowing = true;
+			const { canceled } = await confirm({
+				type: 'warning',
+				title: i18n.ts.disconnectedFromServer,
+				text: i18n.ts.reloadConfirm,
+			});
+			reloadDialogShowing = false;
+			if (!canceled) {
+				location.reload();
+			}
+		}
+	});
+
+	stream.on('emojiAdded', emojiData => {
+		// TODO
+		//store.commit('instance/set', );
+	});
+
+	for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
+		import('./plugin').then(({ install }) => {
+			install(plugin);
+		});
+	}
+
+	const hotkeys = {
+		'd': (): void => {
+			defaultStore.set('darkMode', !defaultStore.state.darkMode);
+		},
+		's': search,
+	};
+
+	if ($i) {
+		// only add post shortcuts if logged in
+		hotkeys['p|n'] = post;
+
+		if ($i.isDeleted) {
+			alert({
+				type: 'warning',
+				text: i18n.ts.accountDeletionInProgress,
+			});
+		}
+
+		const lastUsed = localStorage.getItem('lastUsed');
+		if (lastUsed) {
+			const lastUsedDate = parseInt(lastUsed, 10);
+			// 二時間以上前なら
+			if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
+				toast(i18n.t('welcomeBackWithName', {
+					name: $i.name || $i.username,
+				}));
+			}
+		}
+		localStorage.setItem('lastUsed', Date.now().toString());
+
+		if ('Notification' in window) {
+			// 許可を得ていなかったらリクエスト
+			if (Notification.permission === 'default') {
+				Notification.requestPermission();
+			}
+		}
+
+		const main = markRaw(stream.useChannel('main', null, 'System'));
+
+		// 自分の情報が更新されたとき
+		main.on('meUpdated', i => {
+			updateAccount(i);
+		});
+
+		main.on('readAllNotifications', () => {
+			updateAccount({ hasUnreadNotification: false });
+		});
+
+		main.on('unreadNotification', () => {
+			updateAccount({ hasUnreadNotification: true });
+		});
+
+		main.on('unreadMention', () => {
+			updateAccount({ hasUnreadMentions: true });
+		});
+
+		main.on('readAllUnreadMentions', () => {
+			updateAccount({ hasUnreadMentions: false });
+		});
+
+		main.on('unreadSpecifiedNote', () => {
+			updateAccount({ hasUnreadSpecifiedNotes: true });
+		});
+
+		main.on('readAllUnreadSpecifiedNotes', () => {
+			updateAccount({ hasUnreadSpecifiedNotes: false });
+		});
+
+		main.on('readAllMessagingMessages', () => {
+			updateAccount({ hasUnreadMessagingMessage: false });
+		});
+
+		main.on('unreadMessagingMessage', () => {
+			updateAccount({ hasUnreadMessagingMessage: true });
+			sound.play('chatBg');
+		});
+
+		main.on('readAllAntennas', () => {
+			updateAccount({ hasUnreadAntenna: false });
+		});
+
+		main.on('unreadAntenna', () => {
+			updateAccount({ hasUnreadAntenna: true });
+			sound.play('antenna');
+		});
+
+		main.on('readAllAnnouncements', () => {
+			updateAccount({ hasUnreadAnnouncement: false });
+		});
+
+		main.on('readAllChannels', () => {
+			updateAccount({ hasUnreadChannel: false });
+		});
+
+		main.on('unreadChannel', () => {
+			updateAccount({ hasUnreadChannel: true });
+			sound.play('channel');
+		});
+
+		// トークンが再生成されたとき
+		// このままではMisskeyが利用できないので強制的にサインアウトさせる
+		main.on('myTokenRegenerated', () => {
+			signout();
+		});
+	}
+
+	// shortcut
+	document.addEventListener('keydown', makeHotkey(hotkeys));
+})();
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
new file mode 100644
index 0000000000..51464f32fb
--- /dev/null
+++ b/packages/frontend/src/instance.ts
@@ -0,0 +1,45 @@
+import { computed, reactive } from 'vue';
+import * as Misskey from 'misskey-js';
+import { api } from './os';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+const instanceData = localStorage.getItem('instance');
+
+// TODO: instanceをリアクティブにするかは再考の余地あり
+
+export const instance: Misskey.entities.InstanceMetadata = reactive(instanceData ? JSON.parse(instanceData) : {
+	// TODO: set default values
+});
+
+export async function fetchInstance() {
+	const meta = await api('meta', {
+		detail: false,
+	});
+
+	for (const [k, v] of Object.entries(meta)) {
+		instance[k] = v;
+	}
+
+	localStorage.setItem('instance', JSON.stringify(instance));
+}
+
+export const emojiCategories = computed(() => {
+	if (instance.emojis == null) return [];
+	const categories = new Set();
+	for (const emoji of instance.emojis) {
+		categories.add(emoji.category);
+	}
+	return Array.from(categories);
+});
+
+export const emojiTags = computed(() => {
+	if (instance.emojis == null) return [];
+	const tags = new Set();
+	for (const emoji of instance.emojis) {
+		for (const tag of emoji.aliases) {
+			tags.add(tag);
+		}
+	}
+	return Array.from(tags);
+});
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
new file mode 100644
index 0000000000..31e6cd64a4
--- /dev/null
+++ b/packages/frontend/src/navbar.ts
@@ -0,0 +1,135 @@
+import { computed, ref, reactive } from 'vue';
+import { $i } from './account';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { ui } from '@/config';
+import { unisonReload } from '@/scripts/unison-reload';
+
+export const navbarItemDef = reactive({
+	notifications: {
+		title: 'notifications',
+		icon: 'ti ti-bell',
+		show: computed(() => $i != null),
+		indicated: computed(() => $i != null && $i.hasUnreadNotification),
+		to: '/my/notifications',
+	},
+	messaging: {
+		title: 'messaging',
+		icon: 'ti ti-messages',
+		show: computed(() => $i != null),
+		indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
+		to: '/my/messaging',
+	},
+	drive: {
+		title: 'drive',
+		icon: 'ti ti-cloud',
+		show: computed(() => $i != null),
+		to: '/my/drive',
+	},
+	followRequests: {
+		title: 'followRequests',
+		icon: 'ti ti-user-plus',
+		show: computed(() => $i != null && $i.isLocked),
+		indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
+		to: '/my/follow-requests',
+	},
+	explore: {
+		title: 'explore',
+		icon: 'ti ti-hash',
+		to: '/explore',
+	},
+	announcements: {
+		title: 'announcements',
+		icon: 'ti ti-speakerphone',
+		indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
+		to: '/announcements',
+	},
+	search: {
+		title: 'search',
+		icon: 'ti ti-search',
+		action: () => search(),
+	},
+	lists: {
+		title: 'lists',
+		icon: 'ti ti-list',
+		show: computed(() => $i != null),
+		to: '/my/lists',
+	},
+	/*
+	groups: {
+		title: 'groups',
+		icon: 'ti ti-users',
+		show: computed(() => $i != null),
+		to: '/my/groups',
+	},
+	*/
+	antennas: {
+		title: 'antennas',
+		icon: 'ti ti-antenna',
+		show: computed(() => $i != null),
+		to: '/my/antennas',
+	},
+	favorites: {
+		title: 'favorites',
+		icon: 'ti ti-star',
+		show: computed(() => $i != null),
+		to: '/my/favorites',
+	},
+	pages: {
+		title: 'pages',
+		icon: 'ti ti-news',
+		to: '/pages',
+	},
+	gallery: {
+		title: 'gallery',
+		icon: 'ti ti-icons',
+		to: '/gallery',
+	},
+	clips: {
+		title: 'clip',
+		icon: 'ti ti-paperclip',
+		show: computed(() => $i != null),
+		to: '/my/clips',
+	},
+	channels: {
+		title: 'channel',
+		icon: 'ti ti-device-tv',
+		to: '/channels',
+	},
+	ui: {
+		title: 'switchUi',
+		icon: 'ti ti-devices',
+		action: (ev) => {
+			os.popupMenu([{
+				text: i18n.ts.default,
+				active: ui === 'default' || ui === null,
+				action: () => {
+					localStorage.setItem('ui', 'default');
+					unisonReload();
+				},
+			}, {
+				text: i18n.ts.deck,
+				active: ui === 'deck',
+				action: () => {
+					localStorage.setItem('ui', 'deck');
+					unisonReload();
+				},
+			}, {
+				text: i18n.ts.classic,
+				active: ui === 'classic',
+				action: () => {
+					localStorage.setItem('ui', 'classic');
+					unisonReload();
+				},
+			}], ev.currentTarget ?? ev.target);
+		},
+	},
+	reload: {
+		title: 'reload',
+		icon: 'ti ti-refresh',
+		action: (ev) => {
+			location.reload();
+		},
+	},
+});
diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts
new file mode 100644
index 0000000000..53e73a8d48
--- /dev/null
+++ b/packages/frontend/src/nirax.ts
@@ -0,0 +1,275 @@
+// NIRAX --- A lightweight router
+
+import { EventEmitter } from 'eventemitter3';
+import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { safeURIDecode } from '@/scripts/safe-uri-decode';
+
+type RouteDef = {
+	path: string;
+	component: Component;
+	query?: Record<string, string>;
+	loginRequired?: boolean;
+	name?: string;
+	hash?: string;
+	globalCacheKey?: string;
+	children?: RouteDef[];
+};
+
+type ParsedPath = (string | {
+	name: string;
+	startsWith?: string;
+	wildcard?: boolean;
+	optional?: boolean;
+})[];
+
+export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; };
+
+function parsePath(path: string): ParsedPath {
+	const res = [] as ParsedPath;
+
+	path = path.substring(1);
+
+	for (const part of path.split('/')) {
+		if (part.includes(':')) {
+			const prefix = part.substring(0, part.indexOf(':'));
+			const placeholder = part.substring(part.indexOf(':') + 1);
+			const wildcard = placeholder.includes('(*)');
+			const optional = placeholder.endsWith('?');
+			res.push({
+				name: placeholder.replace('(*)', '').replace('?', ''),
+				startsWith: prefix !== '' ? prefix : undefined,
+				wildcard,
+				optional,
+			});
+		} else if (part.length !== 0) {
+			res.push(part);
+		}
+	}
+
+	return res;
+}
+
+export class Router extends EventEmitter<{
+	change: (ctx: {
+		beforePath: string;
+		path: string;
+		resolved: Resolved;
+		key: string;
+	}) => void;
+	replace: (ctx: {
+		path: string;
+		key: string;
+	}) => void;
+	push: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+		key: string;
+	}) => void;
+	same: () => void;
+}> {
+	private routes: RouteDef[];
+	public current: Resolved;
+	public currentRef: ShallowRef<Resolved> = shallowRef();
+	public currentRoute: ShallowRef<RouteDef> = shallowRef();
+	private currentPath: string;
+	private currentKey = Date.now().toString();
+
+	public navHook: ((path: string, flag?: any) => boolean) | null = null;
+
+	constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
+		super();
+
+		this.routes = routes;
+		this.currentPath = currentPath;
+		this.navigate(currentPath, null, false);
+	}
+
+	public resolve(path: string): Resolved | null {
+		let queryString: string | null = null;
+		let hash: string | null = null;
+		if (path[0] === '/') path = path.substring(1);
+		if (path.includes('#')) {
+			hash = path.substring(path.indexOf('#') + 1);
+			path = path.substring(0, path.indexOf('#'));
+		}
+		if (path.includes('?')) {
+			queryString = path.substring(path.indexOf('?') + 1);
+			path = path.substring(0, path.indexOf('?'));
+		}
+
+		if (_DEV_) console.log('Routing: ', path, queryString);
+
+		function check(routes: RouteDef[], _parts: string[]): Resolved | null {
+			forEachRouteLoop:
+			for (const route of routes) {
+				let parts = [..._parts];
+				const props = new Map<string, string>();
+
+				pathMatchLoop:
+				for (const p of parsePath(route.path)) {
+					if (typeof p === 'string') {
+						if (p === parts[0]) {
+							parts.shift();
+						} else {
+							continue forEachRouteLoop;
+						}
+					} else {
+						if (parts[0] == null && !p.optional) {
+							continue forEachRouteLoop;
+						}
+						if (p.wildcard) {
+							if (parts.length !== 0) {
+								props.set(p.name, safeURIDecode(parts.join('/')));
+								parts = [];
+							}
+							break pathMatchLoop;
+						} else {
+							if (p.startsWith) {
+								if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
+
+								props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
+								parts.shift();
+							} else {
+								if (parts[0]) {
+									props.set(p.name, safeURIDecode(parts[0]));
+								}
+								parts.shift();
+							}
+						}
+					}
+				}
+
+				if (parts.length === 0) {
+					if (route.children) {
+						const child = check(route.children, []);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					}
+
+					if (route.hash != null && hash != null) {
+						props.set(route.hash, safeURIDecode(hash));
+					}
+	
+					if (route.query != null && queryString != null) {
+						const queryObject = [...new URLSearchParams(queryString).entries()]
+							.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
+	
+						for (const q in route.query) {
+							const as = route.query[q];
+							if (queryObject[q]) {
+								props.set(as, safeURIDecode(queryObject[q]));
+							}
+						}
+					}
+	
+					return {
+						route,
+						props,
+					};
+				} else {
+					if (route.children) {
+						const child = check(route.children, parts);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					} else {
+						continue forEachRouteLoop;
+					}
+				}
+			}
+
+			return null;
+		}
+
+		const _parts = path.split('/').filter(part => part.length !== 0);
+
+		return check(this.routes, _parts);
+	}
+
+	private navigate(path: string, key: string | null | undefined, emitChange = true) {
+		const beforePath = this.currentPath;
+		this.currentPath = path;
+
+		const res = this.resolve(this.currentPath);
+
+		if (res == null) {
+			throw new Error('no route found for: ' + path);
+		}
+
+		if (res.route.loginRequired) {
+			pleaseLogin('/');
+		}
+
+		const isSamePath = beforePath === path;
+		if (isSamePath && key == null) key = this.currentKey;
+		this.current = res;
+		this.currentRef.value = res;
+		this.currentRoute.value = res.route;
+		this.currentKey = res.route.globalCacheKey ?? key ?? path;
+
+		if (emitChange) {
+			this.emit('change', {
+				beforePath,
+				path,
+				resolved: res,
+				key: this.currentKey,
+			});
+		}
+
+		return res;
+	}
+
+	public getCurrentPath() {
+		return this.currentPath;
+	}
+
+	public getCurrentKey() {
+		return this.currentKey;
+	}
+
+	public push(path: string, flag?: any) {
+		const beforePath = this.currentPath;
+		if (path === beforePath) {
+			this.emit('same');
+			return;
+		}
+		if (this.navHook) {
+			const cancel = this.navHook(path, flag);
+			if (cancel) return;
+		}
+		const res = this.navigate(path, null);
+		this.emit('push', {
+			beforePath,
+			path,
+			route: res.route,
+			props: res.props,
+			key: this.currentKey,
+		});
+	}
+
+	public replace(path: string, key?: string | null, emitEvent = true) {
+		this.navigate(path, key);
+		if (emitEvent) {
+			this.emit('replace', {
+				path,
+				key: this.currentKey,
+			});
+		}
+	}
+}
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
new file mode 100644
index 0000000000..7e57dcb4af
--- /dev/null
+++ b/packages/frontend/src/os.ts
@@ -0,0 +1,588 @@
+// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
+
+import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
+import { EventEmitter } from 'eventemitter3';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import * as Misskey from 'misskey-js';
+import { apiUrl, url } from '@/config';
+import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
+import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
+import { MenuItem } from '@/types/menu';
+import { $i } from '@/account';
+
+export const pendingApiRequestsCount = ref(0);
+
+const apiClient = new Misskey.api.APIClient({
+	origin: url,
+});
+
+export const api = ((endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) => {
+	pendingApiRequestsCount.value++;
+
+	const onFinally = () => {
+		pendingApiRequestsCount.value--;
+	};
+
+	const promise = new Promise((resolve, reject) => {
+		// Append a credential
+		if ($i) (data as any).i = $i.token;
+		if (token !== undefined) (data as any).i = token;
+
+		// Send request
+		window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
+			method: 'POST',
+			body: JSON.stringify(data),
+			credentials: 'omit',
+			cache: 'no-cache',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+		}).then(async (res) => {
+			const body = res.status === 204 ? null : await res.json();
+
+			if (res.status === 200) {
+				resolve(body);
+			} else if (res.status === 204) {
+				resolve();
+			} else {
+				reject(body.error);
+			}
+		}).catch(reject);
+	});
+
+	promise.then(onFinally, onFinally);
+
+	return promise;
+}) as typeof apiClient.request;
+
+export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => {
+	pendingApiRequestsCount.value++;
+
+	const onFinally = () => {
+		pendingApiRequestsCount.value--;
+	};
+
+	const query = new URLSearchParams(data);
+
+	const promise = new Promise((resolve, reject) => {
+		// Send request
+		window.fetch(`${apiUrl}/${endpoint}?${query}`, {
+			method: 'GET',
+			credentials: 'omit',
+			cache: 'default',
+		}).then(async (res) => {
+			const body = res.status === 204 ? null : await res.json();
+
+			if (res.status === 200) {
+				resolve(body);
+			} else if (res.status === 204) {
+				resolve();
+			} else {
+				reject(body.error);
+			}
+		}).catch(reject);
+	});
+
+	promise.then(onFinally, onFinally);
+
+	return promise;
+}) as typeof apiClient.request;
+
+export const apiWithDialog = ((
+	endpoint: string,
+	data: Record<string, any> = {},
+	token?: string | null | undefined,
+) => {
+	const promise = api(endpoint, data, token);
+	promiseDialog(promise, null, (err) => {
+		alert({
+			type: 'error',
+			text: err.message + '\n' + (err as any).id,
+		});
+	});
+
+	return promise;
+}) as typeof api;
+
+export function promiseDialog<T extends Promise<any>>(
+	promise: T,
+	onSuccess?: ((res: any) => void) | null,
+	onFailure?: ((err: Error) => void) | null,
+	text?: string,
+): T {
+	const showing = ref(true);
+	const success = ref(false);
+
+	promise.then(res => {
+		if (onSuccess) {
+			showing.value = false;
+			onSuccess(res);
+		} else {
+			success.value = true;
+			window.setTimeout(() => {
+				showing.value = false;
+			}, 1000);
+		}
+	}).catch(err => {
+		showing.value = false;
+		if (onFailure) {
+			onFailure(err);
+		} else {
+			alert({
+				type: 'error',
+				text: err,
+			});
+		}
+	});
+
+	// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
+	popup(MkWaitingDialog, {
+		success: success,
+		showing: showing,
+		text: text,
+	}, {}, 'closed');
+
+	return promise;
+}
+
+let popupIdCount = 0;
+export const popups = ref([]) as Ref<{
+	id: any;
+	component: any;
+	props: Record<string, any>;
+}[]>;
+
+const zIndexes = {
+	low: 1000000,
+	middle: 2000000,
+	high: 3000000,
+};
+export function claimZIndex(priority: 'low' | 'middle' | 'high' = 'low'): number {
+	zIndexes[priority] += 100;
+	return zIndexes[priority];
+}
+
+export async function popup(component: Component, props: Record<string, any>, events = {}, disposeEvent?: string) {
+	markRaw(component);
+
+	const id = ++popupIdCount;
+	const dispose = () => {
+		// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ？
+		window.setTimeout(() => {
+			popups.value = popups.value.filter(popup => popup.id !== id);
+		}, 0);
+	};
+	const state = {
+		component,
+		props,
+		events: disposeEvent ? {
+			...events,
+			[disposeEvent]: dispose,
+		} : events,
+		id,
+	};
+
+	popups.value.push(state);
+
+	return {
+		dispose,
+	};
+}
+
+export function pageWindow(path: string) {
+	popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), {
+		initialPath: path,
+	}, {}, 'closed');
+}
+
+export function modalPageWindow(path: string) {
+	popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), {
+		initialPath: path,
+	}, {}, 'closed');
+}
+
+export function toast(message: string) {
+	popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), {
+		message,
+	}, {}, 'closed');
+}
+
+export function alert(props: {
+	type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
+	title?: string | null;
+	text?: string | null;
+}): Promise<void> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, {
+			done: result => {
+				resolve();
+			},
+		}, 'closed');
+	});
+}
+
+export function confirm(props: {
+	type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
+	title?: string | null;
+	text?: string | null;
+}): Promise<{ canceled: boolean }> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
+			...props,
+			showCancelButton: true,
+		}, {
+			done: result => {
+				resolve(result ? result : { canceled: true });
+			},
+		}, 'closed');
+	});
+}
+
+export function inputText(props: {
+	type?: 'text' | 'email' | 'password' | 'url';
+	title?: string | null;
+	text?: string | null;
+	placeholder?: string | null;
+	default?: string | null;
+}): Promise<{ canceled: true; result: undefined; } | {
+	canceled: false; result: string;
+}> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
+			title: props.title,
+			text: props.text,
+			input: {
+				type: props.type,
+				placeholder: props.placeholder,
+				default: props.default,
+			},
+		}, {
+			done: result => {
+				resolve(result ? result : { canceled: true });
+			},
+		}, 'closed');
+	});
+}
+
+export function inputNumber(props: {
+	title?: string | null;
+	text?: string | null;
+	placeholder?: string | null;
+	default?: number | null;
+}): Promise<{ canceled: true; result: undefined; } | {
+	canceled: false; result: number;
+}> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
+			title: props.title,
+			text: props.text,
+			input: {
+				type: 'number',
+				placeholder: props.placeholder,
+				default: props.default,
+			},
+		}, {
+			done: result => {
+				resolve(result ? result : { canceled: true });
+			},
+		}, 'closed');
+	});
+}
+
+export function inputDate(props: {
+	title?: string | null;
+	text?: string | null;
+	placeholder?: string | null;
+	default?: Date | null;
+}): Promise<{ canceled: true; result: undefined; } | {
+	canceled: false; result: Date;
+}> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
+			title: props.title,
+			text: props.text,
+			input: {
+				type: 'date',
+				placeholder: props.placeholder,
+				default: props.default,
+			},
+		}, {
+			done: result => {
+				resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
+			},
+		}, 'closed');
+	});
+}
+
+export function select<C = any>(props: {
+	title?: string | null;
+	text?: string | null;
+	default?: string | null;
+} & ({
+	items: {
+		value: C;
+		text: string;
+	}[];
+} | {
+	groupedItems: {
+		label: string;
+		items: {
+			value: C;
+			text: string;
+		}[];
+	}[];
+})): Promise<{ canceled: true; result: undefined; } | {
+	canceled: false; result: C;
+}> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
+			title: props.title,
+			text: props.text,
+			select: {
+				items: props.items,
+				groupedItems: props.groupedItems,
+				default: props.default,
+			},
+		}, {
+			done: result => {
+				resolve(result ? result : { canceled: true });
+			},
+		}, 'closed');
+	});
+}
+
+export function success() {
+	return new Promise((resolve, reject) => {
+		const showing = ref(true);
+		window.setTimeout(() => {
+			showing.value = false;
+		}, 1000);
+		popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
+			success: true,
+			showing: showing,
+		}, {
+			done: () => resolve(),
+		}, 'closed');
+	});
+}
+
+export function waiting() {
+	return new Promise((resolve, reject) => {
+		const showing = ref(true);
+		popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
+			success: false,
+			showing: showing,
+		}, {
+			done: () => resolve(),
+		}, 'closed');
+	});
+}
+
+export function form(title, form) {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
+			done: result => {
+				resolve(result);
+			},
+		}, 'closed');
+	});
+}
+
+export async function selectUser() {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, {
+			ok: user => {
+				resolve(user);
+			},
+		}, 'closed');
+	});
+}
+
+export async function selectDriveFile(multiple: boolean) {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
+			type: 'file',
+			multiple,
+		}, {
+			done: files => {
+				if (files) {
+					resolve(multiple ? files : files[0]);
+				}
+			},
+		}, 'closed');
+	});
+}
+
+export async function selectDriveFolder(multiple: boolean) {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
+			type: 'folder',
+			multiple,
+		}, {
+			done: folders => {
+				if (folders) {
+					resolve(multiple ? folders : folders[0]);
+				}
+			},
+		}, 'closed');
+	});
+}
+
+export async function pickEmoji(src: HTMLElement | null, opts) {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+			src,
+			...opts,
+		}, {
+			done: emoji => {
+				resolve(emoji);
+			},
+		}, 'closed');
+	});
+}
+
+export async function cropImage(image: Misskey.entities.DriveFile, options: {
+	aspectRatio: number;
+}): Promise<Misskey.entities.DriveFile> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
+			file: image,
+			aspectRatio: options.aspectRatio,
+		}, {
+			ok: x => {
+				resolve(x);
+			},
+		}, 'closed');
+	});
+}
+
+type AwaitType<T> =
+	T extends Promise<infer U> ? U :
+	T extends (...args: any[]) => Promise<infer V> ? V :
+	T;
+let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
+let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
+export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) {
+	if (openingEmojiPicker) return;
+
+	activeTextarea = initialTextarea;
+
+	const textareas = document.querySelectorAll('textarea, input');
+	for (const textarea of Array.from(textareas)) {
+		textarea.addEventListener('focus', () => {
+			activeTextarea = textarea;
+		});
+	}
+
+	const observer = new MutationObserver(records => {
+		for (const record of records) {
+			for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
+				const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
+				for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
+					if (document.activeElement === textarea) activeTextarea = textarea;
+					textarea.addEventListener('focus', () => {
+						activeTextarea = textarea;
+					});
+				}
+			}
+		}
+	});
+
+	observer.observe(document.body, {
+		childList: true,
+		subtree: true,
+		attributes: false,
+		characterData: false,
+	});
+
+	openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), {
+		src,
+		...opts,
+	}, {
+		chosen: emoji => {
+			insertTextAtCursor(activeTextarea, emoji);
+		},
+		closed: () => {
+			openingEmojiPicker!.dispose();
+			openingEmojiPicker = null;
+			observer.disconnect();
+		},
+	});
+}
+
+export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
+	align?: string;
+	width?: number;
+	viaKeyboard?: boolean;
+}) {
+	return new Promise((resolve, reject) => {
+		let dispose;
+		popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), {
+			items,
+			src,
+			width: options?.width,
+			align: options?.align,
+			viaKeyboard: options?.viaKeyboard,
+		}, {
+			closed: () => {
+				resolve();
+				dispose();
+			},
+		}).then(res => {
+			dispose = res.dispose;
+		});
+	});
+}
+
+export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
+	ev.preventDefault();
+	return new Promise((resolve, reject) => {
+		let dispose;
+		popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), {
+			items,
+			ev,
+		}, {
+			closed: () => {
+				resolve();
+				dispose();
+			},
+		}).then(res => {
+			dispose = res.dispose;
+		});
+	});
+}
+
+export function post(props: Record<string, any> = {}) {
+	return new Promise((resolve, reject) => {
+		// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
+		// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
+		//       Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
+		//       複数のpost formを開いたときに場合によってはエラーになる
+		//       もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
+		let dispose;
+		popup(MkPostFormDialog, props, {
+			closed: () => {
+				resolve();
+				dispose();
+			},
+		}).then(res => {
+			dispose = res.dispose;
+		});
+	});
+}
+
+export const deckGlobalEvents = new EventEmitter();
+
+/*
+export function checkExistence(fileData: ArrayBuffer): Promise<any> {
+	return new Promise((resolve, reject) => {
+		const data = new FormData();
+		data.append('md5', getMD5(fileData));
+
+		os.api('drive/files/find-by-hash', {
+			md5: getMD5(fileData)
+		}).then(resp => {
+			resolve(resp.length > 0 ? resp[0] : null);
+		});
+	});
+}*/
diff --git a/packages/frontend/src/pages/_empty_.vue b/packages/frontend/src/pages/_empty_.vue
new file mode 100644
index 0000000000..000b6decc9
--- /dev/null
+++ b/packages/frontend/src/pages/_empty_.vue
@@ -0,0 +1,7 @@
+<template>
+<div></div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+</script>
diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue
new file mode 100644
index 0000000000..232d525347
--- /dev/null
+++ b/packages/frontend/src/pages/_error_.vue
@@ -0,0 +1,89 @@
+<template>
+<MkLoading v-if="!loaded"/>
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+	<div v-show="loaded" class="mjndxjch">
+		<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+		<p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p>
+		<p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p>
+		<p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p>
+		<template v-else>
+			<p>{{ i18n.ts.newVersionOfClientAvailable }}</p>
+			<p>{{ i18n.ts.youShouldUpgradeClient }}</p>
+			<MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton>
+		</template>
+		<p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p>
+		<p v-if="error" class="error">ERROR: {{ error }}</p>
+	</div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { version } from '@/config';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = withDefaults(defineProps<{
+	error?: Error;
+}>(), {
+});
+
+let loaded = $ref(false);
+let serverIsDead = $ref(false);
+let meta = $ref<misskey.entities.LiteInstanceMetadata | null>(null);
+
+os.api('meta', {
+	detail: false,
+}).then(res => {
+	loaded = true;
+	serverIsDead = false;
+	meta = res;
+	localStorage.setItem('v', res.version);
+}, () => {
+	loaded = true;
+	serverIsDead = true;
+});
+
+function reload() {
+	unisonReload();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.error,
+	icon: 'ti ti-alert-triangle',
+});
+</script>
+
+<style lang="scss" scoped>
+.mjndxjch {
+	padding: 32px;
+	text-align: center;
+
+	> p {
+		margin: 0 0 12px 0;
+	}
+
+	> .button {
+		margin: 8px auto;
+	}
+
+	> img {
+		vertical-align: bottom;
+		height: 128px;
+		margin-bottom: 24px;
+		border-radius: 16px;
+	}
+
+	> .error {
+		opacity: 0.7;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/_loading_.vue b/packages/frontend/src/pages/_loading_.vue
new file mode 100644
index 0000000000..1dd2e46e10
--- /dev/null
+++ b/packages/frontend/src/pages/_loading_.vue
@@ -0,0 +1,6 @@
+<template>
+<MkLoading/>
+</template>
+
+<script lang="ts" setup>
+</script>
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
new file mode 100644
index 0000000000..3ec972bcda
--- /dev/null
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -0,0 +1,264 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div style="overflow: clip;">
+		<MkSpacer :content-max="600" :margin-min="20">
+			<div class="_formRoot znqjceqz">
+				<div id="debug"></div>
+				<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
+					<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
+					<div class="misskey">Misskey</div>
+					<div class="version">v{{ version }}</div>
+					<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
+				</div>
+				<div class="_formBlock" style="text-align: center;">
+					{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
+				</div>
+				<div class="_formBlock" style="text-align: center;">
+					<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
+				</div>
+				<FormSection>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/misskey-dev/misskey" external>
+							<template #icon><i class="ti ti-code"></i></template>
+							{{ i18n.ts._aboutMisskey.source }}
+							<template #suffix>GitHub</template>
+						</FormLink>
+						<FormLink to="https://crowdin.com/project/misskey" external>
+							<template #icon><i class="ti ti-language-hiragana"></i></template>
+							{{ i18n.ts._aboutMisskey.translation }}
+							<template #suffix>Crowdin</template>
+						</FormLink>
+						<FormLink to="https://www.patreon.com/syuilo" external>
+							<template #icon><i class="ti ti-pig-money"></i></template>
+							{{ i18n.ts._aboutMisskey.donate }}
+							<template #suffix>Patreon</template>
+						</FormLink>
+					</div>
+				</FormSection>
+				<FormSection>
+					<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
+						<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
+						<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
+						<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
+						<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
+						<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
+						<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
+						<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
+						<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+					</div>
+					<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
+				</FormSection>
+				<FormSection>
+					<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
+					<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
+					<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
+				</FormSection>
+			</div>
+		</MkSpacer>
+	</div>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onBeforeUnmount } from 'vue';
+import { version } from '@/config';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkLink from '@/components/MkLink.vue';
+import { physics } from '@/scripts/physics';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const patrons = [
+	'まっちゃとーにゅ',
+	'mametsuko',
+	'noellabo',
+	'AureoleArk',
+	'Gargron',
+	'Nokotaro Takeda',
+	'Suji Yan',
+	'oi_yekssim',
+	'regtan',
+	'Hekovic',
+	'nenohi',
+	'Gitmo Life Services',
+	'naga_rus',
+	'Efertone',
+	'Melilot',
+	'motcha',
+	'nanami kan',
+	'sevvie Rose',
+	'Hayato Ishikawa',
+	'Puniko',
+	'skehmatics',
+	'Quinton Macejkovic',
+	'YUKIMOCHI',
+	'dansup',
+	'mewl hayabusa',
+	'Emilis',
+	'Fristi',
+	'makokunsan',
+	'chidori ninokura',
+	'Peter G.',
+	'見当かなみ',
+	'natalie',
+	'Maronu',
+	'Steffen K9',
+	'takimura',
+	'sikyosyounin',
+	'Nesakko',
+	'YuzuRyo61',
+	'blackskye',
+	'sheeta.s',
+	'osapon',
+	'public_yusuke',
+	'CG',
+	'吴浥',
+	't_w',
+	'Jerry',
+	'nafuchoco',
+	'Takumi Sugita',
+	'GLaTAN',
+	'mkatze',
+	'kabo2468y',
+	'mydarkstar',
+	'Roujo',
+	'DignifiedSilence',
+	'uroco @99',
+	'totokoro',
+	'うし',
+	'kiritan',
+	'weepjp',
+	'Liaizon Wakest',
+	'Duponin',
+	'Blue',
+	'Naoki Hirayama',
+	'wara',
+	'Wataru Manji (manji0)',
+	'みなしま',
+	'kanoy',
+	'xianon',
+	'Denshi',
+	'Osushimaru',
+	'にょんへら',
+	'おのだい',
+	'Leni',
+	'oss',
+	'Weeble',
+	'蝉暮せせせ',
+	'ThatOneCalculator',
+	'pixeldesu',
+];
+
+let easterEggReady = false;
+let easterEggEmojis = $ref([]);
+let easterEggEngine = $ref(null);
+const containerEl = $ref<HTMLElement>();
+
+function iconLoaded() {
+	const emojis = defaultStore.state.reactions;
+	const containerWidth = containerEl.offsetWidth;
+	for (let i = 0; i < 32; i++) {
+		easterEggEmojis.push({
+			id: i.toString(),
+			top: -(128 + (Math.random() * 256)),
+			left: (Math.random() * containerWidth),
+			emoji: emojis[Math.floor(Math.random() * emojis.length)],
+		});
+	}
+
+	nextTick(() => {
+		easterEggReady = true;
+	});
+}
+
+function gravity() {
+	if (!easterEggReady) return;
+	easterEggReady = false;
+	easterEggEngine = physics(containerEl);
+}
+
+function iLoveMisskey() {
+	os.post({
+		initialText: 'I $[jelly ❤] #Misskey',
+		instant: true,
+	});
+}
+
+onBeforeUnmount(() => {
+	if (easterEggEngine) {
+		easterEggEngine.stop();
+	}
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.aboutMisskey,
+	icon: null,
+});
+</script>
+
+<style lang="scss" scoped>
+.znqjceqz {
+	> .about {
+		position: relative;
+		text-align: center;
+		padding: 16px;
+		border-radius: var(--radius);
+
+		&.playing {
+			&, * {
+				user-select: none;
+			}
+
+			* {
+				will-change: transform;
+			}
+
+			> .emoji {
+				visibility: visible;
+			}
+		}
+
+		> .icon {
+			display: block;
+			width: 100px;
+			margin: 0 auto;
+			border-radius: 16px;
+		}
+
+		> .misskey {
+			margin: 0.75em auto 0 auto;
+			width: max-content;
+		}
+
+		> .version {
+			margin: 0 auto;
+			width: max-content;
+			opacity: 0.5;
+		}
+
+		> .emoji {
+			position: absolute;
+			top: 0;
+			left: 0;
+			visibility: hidden;
+
+			> .emoji {
+				pointer-events: none;
+				font-size: 24px;
+				width: 24px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue
new file mode 100644
index 0000000000..53ce1e4b75
--- /dev/null
+++ b/packages/frontend/src/pages/about.emojis.vue
@@ -0,0 +1,134 @@
+<template>
+<div class="driuhtrh">
+	<div class="query">
+		<MkInput v-model="q" class="" :placeholder="$ts.search">
+			<template #prefix><i class="ti ti-search"></i></template>
+		</MkInput>
+
+		<!-- たくさんあると邪魔
+		<div class="tags">
+			<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
+		</div>
+		-->
+	</div>
+
+	<MkFolder v-if="searchEmojis" class="emojis">
+		<template #header>{{ $ts.searchResult }}</template>
+		<div class="zuvgdzyt">
+			<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
+		</div>
+	</MkFolder>
+	
+	<MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis">
+		<template #header>{{ category || $ts.other }}</template>
+		<div class="zuvgdzyt">
+			<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
+		</div>
+	</MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, computed } from 'vue';
+import XEmoji from './emojis.emoji.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkTab from '@/components/MkTab.vue';
+import * as os from '@/os';
+import { emojiCategories, emojiTags } from '@/instance';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkInput,
+		MkSelect,
+		MkFolder,
+		MkTab,
+		XEmoji,
+	},
+
+	data() {
+		return {
+			q: '',
+			customEmojiCategories: emojiCategories,
+			customEmojis: this.$instance.emojis,
+			tags: emojiTags,
+			selectedTags: new Set(),
+			searchEmojis: null,
+		};
+	},
+
+	watch: {
+		q() { this.search(); },
+		selectedTags: {
+			handler() {
+				this.search();
+			},
+			deep: true,
+		},
+	},
+
+	methods: {
+		search() {
+			if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
+				this.searchEmojis = null;
+				return;
+			}
+
+			if (this.selectedTags.size === 0) {
+				this.searchEmojis = this.customEmojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q));
+			} else {
+				this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.aliases.includes(t)));
+			}
+		},
+
+		toggleTag(tag) {
+			if (this.selectedTags.has(tag)) {
+				this.selectedTags.delete(tag);
+			} else {
+				this.selectedTags.add(tag);
+			}
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.driuhtrh {
+	background: var(--bg);
+
+	> .query {
+		background: var(--bg);
+		padding: 16px;
+
+		> .tags {
+			> .tag {
+				display: inline-block;
+				margin: 8px 8px 0 0;
+				padding: 4px 8px;
+				font-size: 0.9em;
+				background: var(--accentedBg);
+				border-radius: 5px;
+
+				&.active {
+					background: var(--accent);
+					color: var(--fgOnAccent);
+				}
+			}
+		}
+	}
+
+	> .emojis {
+		--x-padding: 0 16px;
+
+		.zuvgdzyt {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: 0 var(--margin) var(--margin) var(--margin);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
new file mode 100644
index 0000000000..6c92ab1264
--- /dev/null
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="taeiyria">
+	<div class="query">
+		<MkInput v-model="host" :debounce="true" class="">
+			<template #prefix><i class="ti ti-search"></i></template>
+			<template #label>{{ i18n.ts.host }}</template>
+		</MkInput>
+		<FormSplit style="margin-top: var(--margin);">
+			<MkSelect v-model="state">
+				<template #label>{{ i18n.ts.state }}</template>
+				<option value="all">{{ i18n.ts.all }}</option>
+				<option value="federating">{{ i18n.ts.federating }}</option>
+				<option value="subscribing">{{ i18n.ts.subscribing }}</option>
+				<option value="publishing">{{ i18n.ts.publishing }}</option>
+				<option value="suspended">{{ i18n.ts.suspended }}</option>
+				<option value="blocked">{{ i18n.ts.blocked }}</option>
+				<option value="notResponding">{{ i18n.ts.notResponding }}</option>
+			</MkSelect>
+			<MkSelect v-model="sort">
+				<template #label>{{ i18n.ts.sort }}</template>
+				<option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
+				<option value="+lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.descendingOrder }})</option>
+				<option value="-lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.ascendingOrder }})</option>
+			</MkSelect>
+		</FormSplit>
+	</div>
+
+	<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
+		<div class="dqokceoi">
+			<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
+				<MkInstanceCardMini :instance="instance"/>
+			</MkA>
+		</div>
+	</MkPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+let host = $ref('');
+let state = $ref('federating');
+let sort = $ref('+pubSub');
+const pagination = {
+	endpoint: 'federation/instances' as const,
+	limit: 10,
+	offsetMode: true,
+	params: computed(() => ({
+		sort: sort,
+		host: host !== '' ? host : null,
+		...(
+			state === 'federating' ? { federating: true } :
+			state === 'subscribing' ? { subscribing: true } :
+			state === 'publishing' ? { publishing: true } :
+			state === 'suspended' ? { suspended: true } :
+			state === 'blocked' ? { blocked: true } :
+			state === 'notResponding' ? { notResponding: true } :
+			{}),
+	})),
+};
+
+function getStatus(instance) {
+	if (instance.isSuspended) return 'Suspended';
+	if (instance.isBlocked) return 'Blocked';
+	if (instance.isNotResponding) return 'Error';
+	return 'Alive';
+}
+</script>
+
+<style lang="scss" scoped>
+.taeiyria {
+	> .query {
+		background: var(--bg);
+		margin-bottom: 16px;
+	}
+}
+
+.dqokceoi {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+	grid-gap: 12px;
+
+	> .instance:hover {
+		text-decoration: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
new file mode 100644
index 0000000000..0ed692c5c5
--- /dev/null
+++ b/packages/frontend/src/pages/about.vue
@@ -0,0 +1,166 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
+		<div class="_formRoot">
+			<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+				<div class="content">
+					<img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/>
+					<div class="name">
+						<b>{{ $instance.name ?? host }}</b>
+					</div>
+				</div>
+			</div>
+
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.description }}</template>
+				<template #value><div v-html="$instance.description"></div></template>
+			</MkKeyValue>
+
+			<FormSection>
+				<MkKeyValue class="_formBlock" :copy="version">
+					<template #key>Misskey</template>
+					<template #value>{{ version }}</template>
+				</MkKeyValue>
+				<div class="_formBlock" v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })">
+				</div>
+				<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
+			</FormSection>
+
+			<FormSection>
+				<FormSplit>
+					<MkKeyValue class="_formBlock">
+						<template #key>{{ i18n.ts.administrator }}</template>
+						<template #value>{{ $instance.maintainerName }}</template>
+					</MkKeyValue>
+					<MkKeyValue class="_formBlock">
+						<template #key>{{ i18n.ts.contact }}</template>
+						<template #value>{{ $instance.maintainerEmail }}</template>
+					</MkKeyValue>
+				</FormSplit>
+				<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ i18n.ts.tos }}</FormLink>
+			</FormSection>
+
+			<FormSuspense :p="initStats">
+				<FormSection>
+					<template #label>{{ i18n.ts.statistics }}</template>
+					<FormSplit>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ i18n.ts.users }}</template>
+							<template #value>{{ number(stats.originalUsersCount) }}</template>
+						</MkKeyValue>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ i18n.ts.notes }}</template>
+							<template #value>{{ number(stats.originalNotesCount) }}</template>
+						</MkKeyValue>
+					</FormSplit>
+				</FormSection>
+			</FormSuspense>
+
+			<FormSection>
+				<template #label>Well-known resources</template>
+				<div class="_formLinks">
+					<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
+					<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+					<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+					<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
+					<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
+				</div>
+			</FormSection>
+		</div>
+	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20">
+		<XEmojis/>
+	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20">
+		<XFederation/>
+	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20">
+		<MkInstanceStats :chart-limit="500" :detailed="true"/>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import XEmojis from './about.emojis.vue';
+import XFederation from './about.federation.vue';
+import { version, instanceName, host } from '@/config';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSplit from '@/components/form/split.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkInstanceStats from '@/components/MkInstanceStats.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = withDefaults(defineProps<{
+	initialTab?: string;
+}>(), {
+	initialTab: 'overview',
+});
+
+let stats = $ref(null);
+let tab = $ref(props.initialTab);
+
+const initStats = () => os.api('stats', {
+}).then((res) => {
+	stats = res;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	key: 'overview',
+	title: i18n.ts.overview,
+}, {
+	key: 'emojis',
+	title: i18n.ts.customEmojis,
+	icon: 'ti ti-mood-happy',
+}, {
+	key: 'federation',
+	title: i18n.ts.federation,
+	icon: 'ti ti-whirl',
+}, {
+	key: 'charts',
+	title: i18n.ts.charts,
+	icon: 'ti ti-chart-line',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.instanceInfo,
+	icon: 'ti ti-info-circle',
+})));
+</script>
+
+<style lang="scss" scoped>
+.fwhjspax {
+	text-align: center;
+	border-radius: 10px;
+	overflow: clip;
+	background-size: cover;
+	background-position: center center;
+
+	> .content {
+		overflow: hidden;
+
+		> .icon {
+			display: block;
+			margin: 16px auto 0 auto;
+			height: 64px;
+			border-radius: 8px;
+		}
+
+		> .name {
+			display: block;
+			padding: 16px;
+			color: #fff;
+			text-shadow: 0 0 8px #000;
+			background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue
new file mode 100644
index 0000000000..a11249e75d
--- /dev/null
+++ b/packages/frontend/src/pages/admin-file.vue
@@ -0,0 +1,160 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32">
+		<div v-if="tab === 'overview'" class="cxqhhsmd _formRoot">
+			<a class="_formBlock thumbnail" :href="file.url" target="_blank">
+				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+			</a>
+			<div class="_formBlock">
+				<MkKeyValue :copy="file.type" oneline style="margin: 1em 0;">
+					<template #key>MIME Type</template>
+					<template #value><span class="_monospace">{{ file.type }}</span></template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>Size</template>
+					<template #value><span class="_monospace">{{ bytes(file.size) }}</span></template>
+				</MkKeyValue>
+				<MkKeyValue :copy="file.id" oneline style="margin: 1em 0;">
+					<template #key>ID</template>
+					<template #value><span class="_monospace">{{ file.id }}</span></template>
+				</MkKeyValue>
+				<MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;">
+					<template #key>MD5</template>
+					<template #value><span class="_monospace">{{ file.md5 }}</span></template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.createdAt }}</template>
+					<template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template>
+				</MkKeyValue>
+			</div>
+			<MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`">
+				<MkUserCardMini :user="file.user"/>
+			</MkA>
+			<div class="_formBlock">
+				<MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch>
+			</div>
+
+			<div class="_formBlock">
+				<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+			</div>
+		</div>
+		<div v-else-if="tab === 'ip' && info" class="_formRoot">
+			<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
+			<MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline>
+				<template #key>IP</template>
+				<template #value>{{ info.requestIp }}</template>
+			</MkKeyValue>
+			<FormSection v-if="info.requestHeaders">
+				<template #label>Headers</template>
+				<MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace">
+					<template #key>{{ k }}</template>
+					<template #value>{{ v }}</template>
+				</MkKeyValue>
+			</FormSection>
+		</div>
+		<div v-else-if="tab === 'raw'" class="_formRoot">
+			<MkObjectView v-if="info" tall :value="info">
+			</MkObjectView>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkObjectView from '@/components/MkObjectView.vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormSection from '@/components/form/section.vue';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { acct } from '@/filters/user';
+import { iAmAdmin, iAmModerator } from '@/account';
+
+let tab = $ref('overview');
+let file: any = $ref(null);
+let info: any = $ref(null);
+let isSensitive: boolean = $ref(false);
+
+const props = defineProps<{
+	fileId: string,
+}>();
+
+async function fetch() {
+	file = await os.api('drive/files/show', { fileId: props.fileId });
+	info = await os.api('admin/drive/show-file', { fileId: props.fileId });
+	isSensitive = file.isSensitive;
+}
+
+fetch();
+
+async function del() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: file.name }),
+	});
+	if (canceled) return;
+
+	os.apiWithDialog('drive/files/delete', {
+		fileId: file.id,
+	});
+}
+
+async function toggleIsSensitive(v) {
+	await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v });
+	isSensitive = v;
+}
+
+const headerActions = $computed(() => [{
+	text: i18n.ts.openInNewTab,
+	icon: 'ti ti-external-link',
+	handler: () => {
+		window.open(file.url, '_blank');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'overview',
+	title: i18n.ts.overview,
+	icon: 'ti ti-info-circle',
+}, iAmModerator ? {
+	key: 'ip',
+	title: 'IP',
+	icon: 'ti ti-password',
+} : null, {
+	key: 'raw',
+	title: 'Raw data',
+	icon: 'ti ti-code',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
+	icon: 'ti ti-file',
+})));
+</script>
+
+<style lang="scss" scoped>
+.cxqhhsmd {
+	> .thumbnail {
+		display: block;
+
+		> .thumbnail {
+			height: 300px;
+			max-width: 100%;
+		}
+	}
+
+	> .user {
+		&:hover {
+			text-decoration: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..bdb41b2d2c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -0,0 +1,292 @@
+<template>
+<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
+	<template v-if="metadata">
+		<div class="titleContainer" @click="showTabsPopup">
+			<i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<div class="title">{{ metadata.title }}</div>
+			</div>
+		</div>
+		<div class="tabs">
+			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+			<div ref="tabHighlightEl" class="highlight"></div>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-if="actions">
+			<template v-for="action in actions">
+				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+				<button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+			</template>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+
+type Tab = {
+	key?: string | null;
+	title: string;
+	icon?: string;
+	iconOnly?: boolean;
+	onClick?: (ev: MouseEvent) => void;
+};
+
+const props = defineProps<{
+	tabs?: Tab[];
+	tab?: string;
+	actions?: {
+		text: string;
+		icon: string;
+		asFullButton?: boolean;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:tab', key: string);
+}>();
+
+const metadata = injectPageMetadata();
+
+const el = ref<HTMLElement>(null);
+const tabRefs = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
+const bg = ref(null);
+const height = ref(0);
+const hasTabs = computed(() => {
+	return props.tabs && props.tabs.length > 0;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs.value) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		active: tab.key != null && tab.key === props.tab,
+		action: (ev) => {
+			onTabClick(tab, ev);
+		},
+	}));
+	popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	scrollToTop(el.value, { behavior: 'smooth' });
+};
+
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+	if (tab.onClick) {
+		ev.preventDefault();
+		ev.stopPropagation();
+		tab.onClick(ev);
+	}
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+
+	watch(() => [props.tab, props.tabs], () => {
+		nextTick(() => {
+			const tabEl = tabRefs[props.tab];
+			if (tabEl && tabHighlightEl) {
+				// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+				// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+				const parentRect = tabEl.parentElement.getBoundingClientRect();
+				const rect = tabEl.getBoundingClientRect();
+				tabHighlightEl.style.width = rect.width + 'px';
+				tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+			}
+		});
+	}, {
+		immediate: true,
+	});
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkc {
+	--height: 60px;
+	display: flex;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+			width: 16px;
+			text-align: center;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		position: relative;
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+
+		> .highlight {
+			position: absolute;
+			bottom: 0;
+			height: 3px;
+			background: var(--accent);
+			border-radius: 999px;
+			transition: all 0.2s ease;
+			pointer-events: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
new file mode 100644
index 0000000000..973ec871ab
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -0,0 +1,97 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="lcixvhis">
+			<div class="_section reports">
+				<div class="_content">
+					<div class="inputs" style="display: flex;">
+						<MkSelect v-model="state" style="margin: 0; flex: 1;">
+							<template #label>{{ i18n.ts.state }}</template>
+							<option value="all">{{ i18n.ts.all }}</option>
+							<option value="unresolved">{{ i18n.ts.unresolved }}</option>
+							<option value="resolved">{{ i18n.ts.resolved }}</option>
+						</MkSelect>
+						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ i18n.ts.reporteeOrigin }}</template>
+							<option value="combined">{{ i18n.ts.all }}</option>
+							<option value="local">{{ i18n.ts.local }}</option>
+							<option value="remote">{{ i18n.ts.remote }}</option>
+						</MkSelect>
+						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ i18n.ts.reporterOrigin }}</template>
+							<option value="combined">{{ i18n.ts.all }}</option>
+							<option value="local">{{ i18n.ts.local }}</option>
+							<option value="remote">{{ i18n.ts.remote }}</option>
+						</MkSelect>
+					</div>
+					<!-- TODO
+			<div class="inputs" style="display: flex; padding-top: 1.2em;">
+				<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
+					<span>{{ i18n.ts.username }}</span>
+				</MkInput>
+				<MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'">
+					<span>{{ i18n.ts.host }}</span>
+				</MkInput>
+			</div>
+			-->
+
+					<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+						<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+					</MkPagination>
+				</div>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import XHeader from './_header_.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import XAbuseReport from '@/components/MkAbuseReport.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let reports = $ref<InstanceType<typeof MkPagination>>();
+
+let state = $ref('unresolved');
+let reporterOrigin = $ref('combined');
+let targetUserOrigin = $ref('combined');
+let searchUsername = $ref('');
+let searchHost = $ref('');
+
+const pagination = {
+	endpoint: 'admin/abuse-user-reports' as const,
+	limit: 10,
+	params: computed(() => ({
+		state,
+		reporterOrigin,
+		targetUserOrigin,
+	})),
+};
+
+function resolved(reportId) {
+	reports.removeItem(item => item.id === reportId);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.abuseReports,
+	icon: 'ti ti-exclamation-circle',
+});
+</script>
+
+<style lang="scss" scoped>
+.lcixvhis {
+	margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
new file mode 100644
index 0000000000..2ec926c65c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -0,0 +1,132 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="uqshojas">
+			<div v-for="ad in ads" class="_panel _formRoot ad">
+				<MkAd v-if="ad.url" :specify="ad"/>
+				<MkInput v-model="ad.url" type="url" class="_formBlock">
+					<template #label>URL</template>
+				</MkInput>
+				<MkInput v-model="ad.imageUrl" class="_formBlock">
+					<template #label>{{ i18n.ts.imageUrl }}</template>
+				</MkInput>
+				<FormRadios v-model="ad.place" class="_formBlock">
+					<template #label>Form</template>
+					<option value="square">square</option>
+					<option value="horizontal">horizontal</option>
+					<option value="horizontal-big">horizontal-big</option>
+				</FormRadios>
+				<!--
+			<div style="margin: 32px 0;">
+				{{ i18n.ts.priority }}
+				<MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
+				<MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio>
+				<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
+			</div>
+			-->
+				<FormSplit>
+					<MkInput v-model="ad.ratio" type="number">
+						<template #label>{{ i18n.ts.ratio }}</template>
+					</MkInput>
+					<MkInput v-model="ad.expiresAt" type="date">
+						<template #label>{{ i18n.ts.expiration }}</template>
+					</MkInput>
+				</FormSplit>
+				<MkTextarea v-model="ad.memo" class="_formBlock">
+					<template #label>{{ i18n.ts.memo }}</template>
+				</MkTextarea>
+				<div class="buttons _formBlock">
+					<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+					<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+				</div>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let ads: any[] = $ref([]);
+
+os.api('admin/ad/list').then(adsResponse => {
+	ads = adsResponse;
+});
+
+function add() {
+	ads.unshift({
+		id: null,
+		memo: '',
+		place: 'square',
+		priority: 'middle',
+		ratio: 1,
+		url: '',
+		imageUrl: null,
+		expiresAt: null,
+	});
+}
+
+function remove(ad) {
+	os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: ad.url }),
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		ads = ads.filter(x => x !== ad);
+		os.apiWithDialog('admin/ad/delete', {
+			id: ad.id,
+		});
+	});
+}
+
+function save(ad) {
+	if (ad.id == null) {
+		os.apiWithDialog('admin/ad/create', {
+			...ad,
+			expiresAt: new Date(ad.expiresAt).getTime(),
+		});
+	} else {
+		os.apiWithDialog('admin/ad/update', {
+			...ad,
+			expiresAt: new Date(ad.expiresAt).getTime(),
+		});
+	}
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.ads,
+	icon: 'ti ti-ad',
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+	> .ad {
+		padding: 32px;
+
+		&:not(:last-child) {
+			margin-bottom: var(--margin);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
new file mode 100644
index 0000000000..607ad8aa02
--- /dev/null
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -0,0 +1,112 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="ztgjmzrw">
+			<section v-for="announcement in announcements" class="_card _gap announcements">
+				<div class="_content announcement">
+					<MkInput v-model="announcement.title">
+						<template #label>{{ i18n.ts.title }}</template>
+					</MkInput>
+					<MkTextarea v-model="announcement.text">
+						<template #label>{{ i18n.ts.text }}</template>
+					</MkTextarea>
+					<MkInput v-model="announcement.imageUrl">
+						<template #label>{{ i18n.ts.imageUrl }}</template>
+					</MkInput>
+					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
+					<div class="buttons">
+						<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+						<MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+					</div>
+				</div>
+			</section>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let announcements: any[] = $ref([]);
+
+os.api('admin/announcements/list').then(announcementResponse => {
+	announcements = announcementResponse;
+});
+
+function add() {
+	announcements.unshift({
+		id: null,
+		title: '',
+		text: '',
+		imageUrl: null,
+	});
+}
+
+function remove(announcement) {
+	os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: announcement.title }),
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		announcements = announcements.filter(x => x !== announcement);
+		os.api('admin/announcements/delete', announcement);
+	});
+}
+
+function save(announcement) {
+	if (announcement.id == null) {
+		os.api('admin/announcements/create', announcement).then(() => {
+			os.alert({
+				type: 'success',
+				text: i18n.ts.saved,
+			});
+		}).catch(err => {
+			os.alert({
+				type: 'error',
+				text: err,
+			});
+		});
+	} else {
+		os.api('admin/announcements/update', announcement).then(() => {
+			os.alert({
+				type: 'success',
+				text: i18n.ts.saved,
+			});
+		}).catch(err => {
+			os.alert({
+				type: 'error',
+				text: err,
+			});
+		});
+	}
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'ti ti-speakerphone',
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+	margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
new file mode 100644
index 0000000000..d03961cf95
--- /dev/null
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -0,0 +1,109 @@
+<template>
+<div>
+	<FormSuspense :p="init">
+		<div class="_formRoot">
+			<FormRadios v-model="provider" class="_formBlock">
+				<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
+				<option value="hcaptcha">hCaptcha</option>
+				<option value="recaptcha">reCAPTCHA</option>
+				<option value="turnstile">Turnstile</option>
+			</FormRadios>
+
+			<template v-if="provider === 'hcaptcha'">
+				<FormInput v-model="hcaptchaSiteKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
+				</FormInput>
+				<FormInput v-model="hcaptchaSecretKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
+				</FormInput>
+				<FormSlot class="_formBlock">
+					<template #label>{{ i18n.ts.preview }}</template>
+					<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+				</FormSlot>
+			</template>
+			<template v-else-if="provider === 'recaptcha'">
+				<FormInput v-model="recaptchaSiteKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
+				</FormInput>
+				<FormInput v-model="recaptchaSecretKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
+				</FormInput>
+				<FormSlot v-if="recaptchaSiteKey" class="_formBlock">
+					<template #label>{{ i18n.ts.preview }}</template>
+					<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
+				</FormSlot>
+			</template>
+			<template v-else-if="provider === 'turnstile'">
+				<FormInput v-model="turnstileSiteKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
+				</FormInput>
+				<FormInput v-model="turnstileSecretKey" class="_formBlock">
+					<template #prefix><i class="ti ti-key"></i></template>
+					<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
+				</FormInput>
+				<FormSlot class="_formBlock">
+					<template #label>{{ i18n.ts.preview }}</template>
+					<MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/>
+				</FormSlot>
+			</template>
+
+			<FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+		</div>
+	</FormSuspense>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSlot from '@/components/form/slot.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
+
+let provider = $ref(null);
+let hcaptchaSiteKey: string | null = $ref(null);
+let hcaptchaSecretKey: string | null = $ref(null);
+let recaptchaSiteKey: string | null = $ref(null);
+let recaptchaSecretKey: string | null = $ref(null);
+let turnstileSiteKey: string | null = $ref(null);
+let turnstileSecretKey: string | null = $ref(null);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	hcaptchaSiteKey = meta.hcaptchaSiteKey;
+	hcaptchaSecretKey = meta.hcaptchaSecretKey;
+	recaptchaSiteKey = meta.recaptchaSiteKey;
+	recaptchaSecretKey = meta.recaptchaSecretKey;
+	turnstileSiteKey = meta.turnstileSiteKey;
+	turnstileSecretKey = meta.turnstileSecretKey;
+
+	provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		enableHcaptcha: provider === 'hcaptcha',
+		hcaptchaSiteKey,
+		hcaptchaSecretKey,
+		enableRecaptcha: provider === 'recaptcha',
+		recaptchaSiteKey,
+		recaptchaSecretKey,
+		enableTurnstile: provider === 'turnstile',
+		turnstileSiteKey,
+		turnstileSecretKey,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue
new file mode 100644
index 0000000000..5a0d3d5e51
--- /dev/null
+++ b/packages/frontend/src/pages/admin/database.vue
@@ -0,0 +1,35 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+		<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
+			<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
+				<template #key>{{ table[0] }}</template>
+				<template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
+			</MkKeyValue>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.database,
+	icon: 'ti ti-database',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
new file mode 100644
index 0000000000..6c9dee1704
--- /dev/null
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -0,0 +1,126 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="enableEmail" class="_formBlock">
+					<template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template>
+					<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
+				</FormSwitch>
+
+				<template v-if="enableEmail">
+					<FormInput v-model="email" type="email" class="_formBlock">
+						<template #label>{{ i18n.ts.emailAddress }}</template>
+					</FormInput>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.smtpConfig }}</template>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpHost" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpHost }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPort" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPort }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpUser" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpUser }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPass" type="password" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPass }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
+						<FormSwitch v-model="smtpSecure" class="_formBlock">
+							<template #label>{{ i18n.ts.smtpSecure }}</template>
+							<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
+						</FormSwitch>
+					</FormSection>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { fetchInstance, instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let enableEmail: boolean = $ref(false);
+let email: any = $ref(null);
+let smtpSecure: boolean = $ref(false);
+let smtpHost: string = $ref('');
+let smtpPort: number = $ref(0);
+let smtpUser: string = $ref('');
+let smtpPass: string = $ref('');
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	enableEmail = meta.enableEmail;
+	email = meta.email;
+	smtpSecure = meta.smtpSecure;
+	smtpHost = meta.smtpHost;
+	smtpPort = meta.smtpPort;
+	smtpUser = meta.smtpUser;
+	smtpPass = meta.smtpPass;
+}
+
+async function testEmail() {
+	const { canceled, result: destination } = await os.inputText({
+		title: i18n.ts.destination,
+		type: 'email',
+		placeholder: instance.maintainerEmail,
+	});
+	if (canceled) return;
+	os.apiWithDialog('admin/send-email', {
+		to: destination,
+		subject: 'Test email',
+		text: 'Yo',
+	});
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		enableEmail,
+		email,
+		smtpSecure,
+		smtpHost,
+		smtpPort,
+		smtpUser,
+		smtpPass,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	text: i18n.ts.testEmail,
+	handler: testEmail,
+}, {
+	asFullButton: true,
+	icon: 'ti ti-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.emailServer,
+	icon: 'ti ti-mail',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue
new file mode 100644
index 0000000000..bd601cb1de
--- /dev/null
+++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue
@@ -0,0 +1,106 @@
+<template>
+<XModalWindow
+	ref="dialog"
+	:width="370"
+	:with-ok-button="true"
+	@close="$refs.dialog.close()"
+	@closed="$emit('closed')"
+	@ok="ok()"
+>
+	<template #header>:{{ emoji.name }}:</template>
+
+	<div class="_monolithic_">
+		<div class="yigymqpb _section">
+			<img :src="emoji.url" class="img"/>
+			<MkInput v-model="name" class="_formBlock">
+				<template #label>{{ i18n.ts.name }}</template>
+			</MkInput>
+			<MkInput v-model="category" class="_formBlock" :datalist="categories">
+				<template #label>{{ i18n.ts.category }}</template>
+			</MkInput>
+			<MkInput v-model="aliases" class="_formBlock">
+				<template #label>{{ i18n.ts.tags }}</template>
+				<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
+			</MkInput>
+			<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+		</div>
+	</div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { unique } from '@/scripts/array';
+import { i18n } from '@/i18n';
+import { emojiCategories } from '@/instance';
+
+const props = defineProps<{
+	emoji: any,
+}>();
+
+let dialog = $ref(null);
+let name: string = $ref(props.emoji.name);
+let category: string = $ref(props.emoji.category);
+let aliases: string = $ref(props.emoji.aliases.join(' '));
+let categories: string[] = $ref(emojiCategories);
+
+const emit = defineEmits<{
+	(ev: 'done', v: { deleted?: boolean, updated?: any }): void,
+	(ev: 'closed'): void
+}>();
+
+function ok() {
+	update();
+}
+
+async function update() {
+	await os.apiWithDialog('admin/emoji/update', {
+		id: props.emoji.id,
+		name,
+		category,
+		aliases: aliases.split(' '),
+	});
+
+	emit('done', {
+		updated: {
+			id: props.emoji.id,
+			name,
+			category,
+			aliases: aliases.split(' '),
+		},
+	});
+
+	dialog.close();
+}
+
+async function del() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: name }),
+	});
+	if (canceled) return;
+
+	os.api('admin/emoji/delete', {
+		id: props.emoji.id,
+	}).then(() => {
+		emit('done', {
+			deleted: true,
+		});
+		dialog.close();
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.yigymqpb {
+	> .img {
+		display: block;
+		height: 64px;
+		margin: 0 auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue
new file mode 100644
index 0000000000..14c8466d73
--- /dev/null
+++ b/packages/frontend/src/pages/admin/emojis.vue
@@ -0,0 +1,398 @@
+<template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="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" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkButton inline @click="selectAll">Select all</MkButton>
+						<MkButton inline @click="setCategoryBulk">Set category</MkButton>
+						<MkButton inline @click="addTagBulk">Add tag</MkButton>
+						<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
+						<MkButton inline @click="setTagBulk">Set tag</MkButton>
+						<MkButton inline danger @click="delBulk">Delete</MkButton>
+					</div>
+					<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+						<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
+						<template #default="{items}">
+							<div class="ldhfsamy">
+								<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
+									<img :src="emoji.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>
+						</template>
+					</MkPagination>
+				</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>
+				</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkTab from '@/components/MkTab.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormSplit from '@/components/form/split.vue';
+import { selectFile, selectFiles } from '@/scripts/select-file';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
+
+const tab = ref('local');
+const query = ref(null);
+const queryRemote = ref(null);
+const host = ref(null);
+const selectMode = ref(false);
+const selectedEmojis = ref<string[]>([]);
+
+const pagination = {
+	endpoint: 'admin/emoji/list' as const,
+	limit: 30,
+	params: computed(() => ({
+		query: (query.value && query.value !== '') ? query.value : null,
+	})),
+};
+
+const remotePagination = {
+	endpoint: 'admin/emoji/list-remote' as const,
+	limit: 30,
+	params: computed(() => ({
+		query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null,
+		host: (host.value && host.value !== '') ? host.value : null,
+	})),
+};
+
+const selectAll = () => {
+	if (selectedEmojis.value.length > 0) {
+		selectedEmojis.value = [];
+	} else {
+		selectedEmojis.value = 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 add = async (ev: MouseEvent) => {
+	const files = await selectFiles(ev.currentTarget ?? ev.target, null);
+
+	const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
+		fileId: file.id,
+	})));
+	promise.then(() => {
+		emojisPaginationComponent.value.reload();
+	});
+	os.promiseDialog(promise);
+};
+
+const edit = (emoji) => {
+	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+		emoji: emoji,
+	}, {
+		done: result => {
+			if (result.updated) {
+				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
+					...oldEmoji,
+					...result.updated,
+				}));
+			} else if (result.deleted) {
+				emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
+			}
+		},
+	}, 'closed');
+};
+
+const im = (emoji) => {
+	os.apiWithDialog('admin/emoji/copy', {
+		emojiId: emoji.id,
+	});
+};
+
+const remoteMenu = (emoji, ev: MouseEvent) => {
+	os.popupMenu([{
+		type: 'label',
+		text: ':' + emoji.name + ':',
+	}, {
+		text: i18n.ts.import,
+		icon: 'ti ti-plus',
+		action: () => { im(emoji); },
+	}], ev.currentTarget ?? ev.target);
+};
+
+const menu = (ev: MouseEvent) => {
+	os.popupMenu([{
+		icon: 'ti ti-download',
+		text: i18n.ts.export,
+		action: async () => {
+			os.api('export-custom-emojis', {
+			})
+				.then(() => {
+					os.alert({
+						type: 'info',
+						text: i18n.ts.exportRequested,
+					});
+				}).catch((err) => {
+					os.alert({
+						type: 'error',
+						text: err.message,
+					});
+				});
+		},
+	}, {
+		icon: 'ti ti-upload',
+		text: i18n.ts.import,
+		action: async () => {
+			const file = await selectFile(ev.currentTarget ?? ev.target);
+			os.api('admin/emoji/import-zip', {
+				fileId: file.id,
+			})
+				.then(() => {
+					os.alert({
+						type: 'info',
+						text: i18n.ts.importRequested,
+					});
+				}).catch((err) => {
+					os.alert({
+						type: 'error',
+						text: err.message,
+					});
+				});
+		},
+	}], 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 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',
+	text: i18n.ts.addEmoji,
+	handler: add,
+}, {
+	icon: 'ti ti-dots',
+	handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'local',
+	title: i18n.ts.local,
+}, {
+	key: 'remote',
+	title: i18n.ts.remote,
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.customEmojis,
+	icon: 'ti ti-mood-happy',
+})));
+</script>
+
+<style lang="scss" scoped>
+.ogwlenmc {
+	> .local {
+		.empty {
+			margin: var(--margin);
+		}
+		
+		.ldhfsamy {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: var(--margin) 0;
+	
+			> .emoji {
+				display: flex;
+				align-items: center;
+				padding: 11px;
+				text-align: left;
+				border: solid 1px var(--panel);
+
+				&:hover {
+					border-color: var(--inputBorderHover);
+				}
+
+				&.selected {
+					border-color: var(--accent);
+				}
+
+				> .img {
+					width: 42px;
+					height: 42px;
+				}
+
+				> .body {
+					padding: 0 0 0 8px;
+					white-space: nowrap;
+					overflow: hidden;
+
+					> .name {
+						text-overflow: ellipsis;
+						overflow: hidden;
+					}
+
+					> .info {
+						opacity: 0.5;
+						text-overflow: ellipsis;
+						overflow: hidden;
+					}
+				}
+			}
+		}
+	}
+
+	> .remote {
+		.empty {
+			margin: var(--margin);
+		}
+
+		.ldhfsamy {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: var(--margin) 0;
+
+			> .emoji {
+				display: flex;
+				align-items: center;
+				padding: 12px;
+				text-align: left;
+
+				&:hover {
+					color: var(--accent);
+				}
+
+				> .img {
+					width: 32px;
+					height: 32px;
+				}
+
+				> .body {
+					padding: 0 0 0 8px;
+					white-space: nowrap;
+					overflow: hidden;
+
+					> .name {
+						text-overflow: ellipsis;
+						overflow: hidden;
+					}
+
+					> .info {
+						opacity: 0.5;
+						font-size: 90%;
+						text-overflow: ellipsis;
+						overflow: hidden;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
new file mode 100644
index 0000000000..8ad6bd4fc0
--- /dev/null
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -0,0 +1,120 @@
+<template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="900">
+			<div class="xrmjdkdw">
+				<div>
+					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkSelect v-model="origin" style="margin: 0; flex: 1;">
+							<template #label>{{ i18n.ts.instance }}</template>
+							<option value="combined">{{ i18n.ts.all }}</option>
+							<option value="local">{{ i18n.ts.local }}</option>
+							<option value="remote">{{ i18n.ts.remote }}</option>
+						</MkSelect>
+						<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+							<template #label>{{ i18n.ts.host }}</template>
+						</MkInput>
+					</div>
+					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;">
+						<MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
+							<template #label>User ID</template>
+						</MkInput>
+						<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+							<template #label>MIME type</template>
+						</MkInput>
+					</div>
+					<MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/>
+				</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let origin = $ref('local');
+let type = $ref(null);
+let searchHost = $ref('');
+let userId = $ref('');
+let viewMode = $ref('grid');
+const pagination = {
+	endpoint: 'admin/drive/files' as const,
+	limit: 10,
+	params: computed(() => ({
+		type: (type && type !== '') ? type : null,
+		userId: (userId && userId !== '') ? userId : null,
+		origin: origin,
+		hostname: (searchHost && searchHost !== '') ? searchHost : null,
+	})),
+};
+
+function clear() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.ts.clearCachedFilesConfirm,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+
+		os.apiWithDialog('admin/drive/clean-remote-files', {});
+	});
+}
+
+function show(file) {
+	os.pageWindow(`/admin/file/${file.id}`);
+}
+
+async function find() {
+	const { canceled, result: q } = await os.inputText({
+		title: i18n.ts.fileIdOrUrl,
+		allowEmpty: false,
+	});
+	if (canceled) return;
+
+	os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+		show(file);
+	}).catch(err => {
+		if (err.code === 'NO_SUCH_FILE') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.notFound,
+			});
+		}
+	});
+}
+
+const headerActions = $computed(() => [{
+	text: i18n.ts.lookup,
+	icon: 'ti ti-search',
+	handler: find,
+}, {
+	text: i18n.ts.clearCachedFiles,
+	icon: 'ti ti-trash',
+	handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.files,
+	icon: 'ti ti-cloud',
+})));
+</script>
+
+<style lang="scss" scoped>
+.xrmjdkdw {
+	margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
new file mode 100644
index 0000000000..6c07a87eeb
--- /dev/null
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -0,0 +1,316 @@
+<template>
+<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
+	<div v-if="!narrow || currentPage?.route.name == null" class="nav">	
+		<MkSpacer :content-max="700" :margin-min="16">
+			<div class="lxpfedzu">
+				<div class="banner">
+					<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+				</div>
+
+				<MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
+				<MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+				<MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+				<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+
+				<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
+			</div>
+		</MkSpacer>
+	</div>
+	<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
+		<RouterView/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkSuperMenu from '@/components/MkSuperMenu.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { scroll } from '@/scripts/scroll';
+import { instance } from '@/instance';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+const isEmpty = (x: string | null) => x == null || x === '';
+
+const router = useRouter();
+
+const indexInfo = {
+	title: i18n.ts.controlPanel,
+	icon: 'ti ti-settings',
+	hideHeader: true,
+};
+
+provide('shouldOmitHeaderTitle', false);
+
+let INFO = $ref(indexInfo);
+let childInfo = $ref(null);
+let narrow = $ref(false);
+let view = $ref(null);
+let el = $ref(null);
+let pageProps = $ref({});
+let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
+let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
+let noEmailServer = !instance.enableEmail;
+let thereIsUnresolvedAbuseReport = $ref(false);
+let currentPage = $computed(() => router.currentRef.value.child);
+
+os.api('admin/abuse-user-reports', {
+	state: 'unresolved',
+	limit: 1,
+}).then(reports => {
+	if (reports.length > 0) thereIsUnresolvedAbuseReport = true;
+});
+
+const NARROW_THRESHOLD = 600;
+const ro = new ResizeObserver((entries, observer) => {
+	if (entries.length === 0) return;
+	narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
+});
+
+const menuDef = $computed(() => [{
+	title: i18n.ts.quickAction,
+	items: [{
+		type: 'button',
+		icon: 'ti ti-search',
+		text: i18n.ts.lookup,
+		action: lookup,
+	}, ...(instance.disableRegistration ? [{
+		type: 'button',
+		icon: 'ti ti-user',
+		text: i18n.ts.invite,
+		action: invite,
+	}] : [])],
+}, {
+	title: i18n.ts.administration,
+	items: [{
+		icon: 'ti ti-dashboard',
+		text: i18n.ts.dashboard,
+		to: '/admin/overview',
+		active: currentPage?.route.name === 'overview',
+	}, {
+		icon: 'ti ti-users',
+		text: i18n.ts.users,
+		to: '/admin/users',
+		active: currentPage?.route.name === 'users',
+	}, {
+		icon: 'ti ti-mood-happy',
+		text: i18n.ts.customEmojis,
+		to: '/admin/emojis',
+		active: currentPage?.route.name === 'emojis',
+	}, {
+		icon: 'ti ti-whirl',
+		text: i18n.ts.federation,
+		to: '/about#federation',
+		active: currentPage?.route.name === 'federation',
+	}, {
+		icon: 'ti ti-clock-play',
+		text: i18n.ts.jobQueue,
+		to: '/admin/queue',
+		active: currentPage?.route.name === 'queue',
+	}, {
+		icon: 'ti ti-cloud',
+		text: i18n.ts.files,
+		to: '/admin/files',
+		active: currentPage?.route.name === 'files',
+	}, {
+		icon: 'ti ti-speakerphone',
+		text: i18n.ts.announcements,
+		to: '/admin/announcements',
+		active: currentPage?.route.name === 'announcements',
+	}, {
+		icon: 'ti ti-ad',
+		text: i18n.ts.ads,
+		to: '/admin/ads',
+		active: currentPage?.route.name === 'ads',
+	}, {
+		icon: 'ti ti-exclamation-circle',
+		text: i18n.ts.abuseReports,
+		to: '/admin/abuses',
+		active: currentPage?.route.name === 'abuses',
+	}],
+}, {
+	title: i18n.ts.settings,
+	items: [{
+		icon: 'ti ti-settings',
+		text: i18n.ts.general,
+		to: '/admin/settings',
+		active: currentPage?.route.name === 'settings',
+	}, {
+		icon: 'ti ti-mail',
+		text: i18n.ts.emailServer,
+		to: '/admin/email-settings',
+		active: currentPage?.route.name === 'email-settings',
+	}, {
+		icon: 'ti ti-cloud',
+		text: i18n.ts.objectStorage,
+		to: '/admin/object-storage',
+		active: currentPage?.route.name === 'object-storage',
+	}, {
+		icon: 'ti ti-lock',
+		text: i18n.ts.security,
+		to: '/admin/security',
+		active: currentPage?.route.name === 'security',
+	}, {
+		icon: 'ti ti-planet',
+		text: i18n.ts.relays,
+		to: '/admin/relays',
+		active: currentPage?.route.name === 'relays',
+	}, {
+		icon: 'ti ti-share',
+		text: i18n.ts.integration,
+		to: '/admin/integrations',
+		active: currentPage?.route.name === 'integrations',
+	}, {
+		icon: 'ti ti-ban',
+		text: i18n.ts.instanceBlocking,
+		to: '/admin/instance-block',
+		active: currentPage?.route.name === 'instance-block',
+	}, {
+		icon: 'ti ti-ghost',
+		text: i18n.ts.proxyAccount,
+		to: '/admin/proxy-account',
+		active: currentPage?.route.name === 'proxy-account',
+	}, {
+		icon: 'ti ti-adjustments',
+		text: i18n.ts.other,
+		to: '/admin/other-settings',
+		active: currentPage?.route.name === 'other-settings',
+	}],
+}, {
+	title: i18n.ts.info,
+	items: [{
+		icon: 'ti ti-database',
+		text: i18n.ts.database,
+		to: '/admin/database',
+		active: currentPage?.route.name === 'database',
+	}],
+}]);
+
+watch(narrow, () => {
+	if (currentPage?.route.name == null && !narrow) {
+		router.push('/admin/overview');
+	}
+});
+
+onMounted(() => {
+	ro.observe(el);
+
+	narrow = el.offsetWidth < NARROW_THRESHOLD;
+	if (currentPage?.route.name == null && !narrow) {
+		router.push('/admin/overview');
+	}
+});
+
+onUnmounted(() => {
+	ro.disconnect();
+});
+
+provideMetadataReceiver((info) => {
+	if (info == null) {
+		childInfo = null;
+	} else {
+		childInfo = info;
+	}
+});
+
+const invite = () => {
+	os.api('admin/invite').then(x => {
+		os.alert({
+			type: 'info',
+			text: x.code,
+		});
+	}).catch(err => {
+		os.alert({
+			type: 'error',
+			text: err,
+		});
+	});
+};
+
+const lookup = (ev) => {
+	os.popupMenu([{
+		text: i18n.ts.user,
+		icon: 'ti ti-user',
+		action: () => {
+			lookupUser();
+		},
+	}, {
+		text: i18n.ts.note,
+		icon: 'ti ti-pencil',
+		action: () => {
+			alert('TODO');
+		},
+	}, {
+		text: i18n.ts.file,
+		icon: 'ti ti-cloud',
+		action: () => {
+			alert('TODO');
+		},
+	}, {
+		text: i18n.ts.instance,
+		icon: 'ti ti-planet',
+		action: () => {
+			alert('TODO');
+		},
+	}], ev.currentTarget ?? ev.target);
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
+defineExpose({
+	header: {
+		title: i18n.ts.controlPanel,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.hiyeyicy {
+	&.wide {
+		display: flex;
+		margin: 0 auto;
+		height: 100%;
+
+		> .nav {
+			width: 32%;
+			max-width: 280px;
+			box-sizing: border-box;
+			border-right: solid 0.5px var(--divider);
+			overflow: auto;
+			height: 100%;
+		}
+
+		> .main {
+			flex: 1;
+			min-width: 0;
+		}
+	}
+
+	> .nav {
+		.lxpfedzu {
+			> .info {
+				margin: 16px 0;
+			}
+
+			> .banner {
+				margin: 16px;
+
+				> .icon {
+					display: block;
+					margin: auto;
+					height: 42px;
+					border-radius: 8px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue
new file mode 100644
index 0000000000..1bdd174de4
--- /dev/null
+++ b/packages/frontend/src/pages/admin/instance-block.vue
@@ -0,0 +1,51 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<FormTextarea v-model="blockedHosts" class="_formBlock">
+				<span>{{ i18n.ts.blockedInstances }}</span>
+				<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+			</FormTextarea>
+
+			<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let blockedHosts: string = $ref('');
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	blockedHosts = meta.blockedHosts.join('\n');
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		blockedHosts: blockedHosts.split('\n') || [],
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceBlocking,
+	icon: 'ti ti-ban',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue
new file mode 100644
index 0000000000..0a69c44c93
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.discord.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+	<div class="_formRoot">
+		<FormSwitch v-model="enableDiscordIntegration" class="_formBlock">
+			<template #label>{{ i18n.ts.enable }}</template>
+		</FormSwitch>
+
+		<template v-if="enableDiscordIntegration">
+			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo>
+		
+			<FormInput v-model="discordClientId" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Client ID</template>
+			</FormInput>
+
+			<FormInput v-model="discordClientSecret" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Client Secret</template>
+			</FormInput>
+		</template>
+
+		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+	</div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableDiscordIntegration: boolean = $ref(false);
+let discordClientId: string | null = $ref(null);
+let discordClientSecret: string | null = $ref(null);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	uri = meta.uri;
+	enableDiscordIntegration = meta.enableDiscordIntegration;
+	discordClientId = meta.discordClientId;
+	discordClientSecret = meta.discordClientSecret;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		enableDiscordIntegration,
+		discordClientId,
+		discordClientSecret,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue
new file mode 100644
index 0000000000..66419d5891
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.github.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+	<div class="_formRoot">
+		<FormSwitch v-model="enableGithubIntegration" class="_formBlock">
+			<template #label>{{ i18n.ts.enable }}</template>
+		</FormSwitch>
+
+		<template v-if="enableGithubIntegration">
+			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo>
+		
+			<FormInput v-model="githubClientId" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Client ID</template>
+			</FormInput>
+
+			<FormInput v-model="githubClientSecret" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Client Secret</template>
+			</FormInput>
+		</template>
+
+		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+	</div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableGithubIntegration: boolean = $ref(false);
+let githubClientId: string | null = $ref(null);
+let githubClientSecret: string | null = $ref(null);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	uri = meta.uri;
+	enableGithubIntegration = meta.enableGithubIntegration;
+	githubClientId = meta.githubClientId;
+	githubClientSecret = meta.githubClientSecret;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		enableGithubIntegration,
+		githubClientId,
+		githubClientSecret,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue
new file mode 100644
index 0000000000..1e8d882b9c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.twitter.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+	<div class="_formRoot">
+		<FormSwitch v-model="enableTwitterIntegration" class="_formBlock">
+			<template #label>{{ i18n.ts.enable }}</template>
+		</FormSwitch>
+
+		<template v-if="enableTwitterIntegration">
+			<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo>
+		
+			<FormInput v-model="twitterConsumerKey" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Consumer Key</template>
+			</FormInput>
+
+			<FormInput v-model="twitterConsumerSecret" class="_formBlock">
+				<template #prefix><i class="ti ti-key"></i></template>
+				<template #label>Consumer Secret</template>
+			</FormInput>
+		</template>
+
+		<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+	</div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableTwitterIntegration: boolean = $ref(false);
+let twitterConsumerKey: string | null = $ref(null);
+let twitterConsumerSecret: string | null = $ref(null);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	uri = meta.uri;
+	enableTwitterIntegration = meta.enableTwitterIntegration;
+	twitterConsumerKey = meta.twitterConsumerKey;
+	twitterConsumerSecret = meta.twitterConsumerSecret;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		enableTwitterIntegration,
+		twitterConsumerKey,
+		twitterConsumerSecret,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue
new file mode 100644
index 0000000000..9cc35baefd
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.vue
@@ -0,0 +1,57 @@
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+	<FormSuspense :p="init">
+		<FormFolder class="_formBlock">
+			<template #icon><i class="ti ti-brand-twitter"></i></template>
+			<template #label>Twitter</template>
+			<template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+			<XTwitter/>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #icon><i class="ti ti-brand-github"></i></template>
+			<template #label>GitHub</template>
+			<template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+			<XGithub/>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #icon><i class="ti ti-brand-discord"></i></template>
+			<template #label>Discord</template>
+			<template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+			<XDiscord/>
+		</FormFolder>
+	</FormSuspense>
+</MkSpacer></MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XTwitter from './integrations.twitter.vue';
+import XGithub from './integrations.github.vue';
+import XDiscord from './integrations.discord.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let enableTwitterIntegration: boolean = $ref(false);
+let enableGithubIntegration: boolean = $ref(false);
+let enableDiscordIntegration: boolean = $ref(false);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	enableTwitterIntegration = meta.enableTwitterIntegration;
+	enableGithubIntegration = meta.enableGithubIntegration;
+	enableDiscordIntegration = meta.enableDiscordIntegration;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'ti ti-share',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue
new file mode 100644
index 0000000000..db8e448639
--- /dev/null
+++ b/packages/frontend/src/pages/admin/metrics.vue
@@ -0,0 +1,472 @@
+<template>
+<div class="_debobigegoItem">
+	<div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
+	<div class="_debobigegoPanel xhexznfu">
+		<div>
+			<canvas :ref="cpumem"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
+					<div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+					<div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<div class="_debobigegoItem">
+	<div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
+	<div class="_debobigegoPanel xhexznfu">
+		<div>
+			<canvas :ref="disk"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
+					<div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+					<div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<div class="_debobigegoItem">
+	<div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
+	<div class="_debobigegoPanel xhexznfu">
+		<div>
+			<canvas :ref="net"></canvas>
+		</div>
+		<div v-if="serverInfo">
+			<div class="_table">
+				<div class="_row">
+					<div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import {
+  Chart,
+  ArcElement,
+  LineElement,
+  BarElement,
+  PointElement,
+  BarController,
+  LineController,
+  CategoryScale,
+  LinearScale,
+  Legend,
+  Title,
+  Tooltip,
+  SubTitle,
+} from 'chart.js';
+import MkButton from '@/components/MkButton.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+Chart.register(
+  ArcElement,
+  LineElement,
+  BarElement,
+  PointElement,
+  BarController,
+  LineController,
+  CategoryScale,
+  LinearScale,
+  Legend,
+  Title,
+  Tooltip,
+  SubTitle,
+);
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+import * as os from '@/os';
+import { stream } from '@/stream';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkSelect,
+		MkInput,
+		MkContainer,
+		MkFolder,
+		MkwFederation,
+	},
+
+	data() {
+		return {
+			version,
+			url,
+			stats: null,
+			serverInfo: null,
+			connection: null,
+			queueConnection: markRaw(stream.useChannel('queueStats')),
+			memUsage: 0,
+			chartCpuMem: null,
+			chartNet: null,
+			jobs: [],
+			logs: [],
+			logLevel: 'all',
+			logDomain: '',
+			modLogs: [],
+			dbInfo: null,
+			overviewHeight: '1fr',
+			queueHeight: '1fr',
+			paused: false,
+		};
+	},
+
+	computed: {
+		gridColor() {
+			// TODO: var(--panel)の色が暗いか明るいかで判定する
+			return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+		},
+	},
+
+	mounted() {
+		this.fetchJobs();
+
+		Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+		os.api('admin/server-info', {}).then(res => {
+			this.serverInfo = res;
+
+			this.connection = markRaw(stream.useChannel('serverStats'));
+			this.connection.on('stats', this.onStats);
+			this.connection.on('statsLog', this.onStatsLog);
+			this.connection.send('requestLog', {
+				id: Math.random().toString().substr(2, 8),
+				length: 150,
+			});
+
+			this.$nextTick(() => {
+				this.queueConnection.send('requestLog', {
+					id: Math.random().toString().substr(2, 8),
+					length: 200,
+				});
+			});
+		});
+	},
+
+	beforeUnmount() {
+		if (this.connection) {
+			this.connection.off('stats', this.onStats);
+			this.connection.off('statsLog', this.onStatsLog);
+			this.connection.dispose();
+		}
+		this.queueConnection.dispose();
+	},
+
+	methods: {
+		cpumem(el) {
+			if (this.chartCpuMem != null) return;
+			this.chartCpuMem = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'CPU',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#86b300',
+						backgroundColor: alpha('#86b300', 0.1),
+						data: [],
+					}, {
+						label: 'MEM (active)',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#935dbf',
+						backgroundColor: alpha('#935dbf', 0.02),
+						data: [],
+					}, {
+						label: 'MEM (used)',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#935dbf',
+						borderDash: [5, 5],
+						fill: false,
+						data: [],
+					}],
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0,
+						},
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						},
+					},
+					scales: {
+						x: {
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							},
+						},
+						y: {
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+								max: 100,
+							},
+						},
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					},
+				},
+			}));
+		},
+
+		net(el) {
+			if (this.chartNet != null) return;
+			this.chartNet = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'In',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#94a029',
+						backgroundColor: alpha('#94a029', 0.1),
+						data: [],
+					}, {
+						label: 'Out',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#ff9156',
+						backgroundColor: alpha('#ff9156', 0.1),
+						data: [],
+					}],
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0,
+						},
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						},
+					},
+					scales: {
+						x: {
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							},
+						},
+						y: {
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							},
+						},
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					},
+				},
+			}));
+		},
+
+		disk(el) {
+			if (this.chartDisk != null) return;
+			this.chartDisk = markRaw(new Chart(el, {
+				type: 'line',
+				data: {
+					labels: [],
+					datasets: [{
+						label: 'Read',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#94a029',
+						backgroundColor: alpha('#94a029', 0.1),
+						data: [],
+					}, {
+						label: 'Write',
+						pointRadius: 0,
+						tension: 0,
+						borderWidth: 2,
+						borderColor: '#ff9156',
+						backgroundColor: alpha('#ff9156', 0.1),
+						data: [],
+					}],
+				},
+				options: {
+					aspectRatio: 3,
+					layout: {
+						padding: {
+							left: 16,
+							right: 16,
+							top: 16,
+							bottom: 0,
+						},
+					},
+					legend: {
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						},
+					},
+					scales: {
+						x: {
+							gridLines: {
+								display: false,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							},
+						},
+						y: {
+							position: 'right',
+							gridLines: {
+								display: true,
+								color: this.gridColor,
+								zeroLineColor: this.gridColor,
+							},
+							ticks: {
+								display: false,
+							},
+						},
+					},
+					tooltips: {
+						intersect: false,
+						mode: 'index',
+					},
+				},
+			}));
+		},
+
+		fetchJobs() {
+			os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+				this.jobs = jobs;
+			});
+		},
+
+		onStats(stats) {
+			if (this.paused) return;
+
+			const cpu = (stats.cpu * 100).toFixed(0);
+			const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+			const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+			this.memUsage = stats.mem.active;
+
+			this.chartCpuMem.data.labels.push('');
+			this.chartCpuMem.data.datasets[0].data.push(cpu);
+			this.chartCpuMem.data.datasets[1].data.push(memActive);
+			this.chartCpuMem.data.datasets[2].data.push(memUsed);
+			this.chartNet.data.labels.push('');
+			this.chartNet.data.datasets[0].data.push(stats.net.rx);
+			this.chartNet.data.datasets[1].data.push(stats.net.tx);
+			this.chartDisk.data.labels.push('');
+			this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+			this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+			if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+				this.chartCpuMem.data.labels.shift();
+				this.chartCpuMem.data.datasets[0].data.shift();
+				this.chartCpuMem.data.datasets[1].data.shift();
+				this.chartCpuMem.data.datasets[2].data.shift();
+				this.chartNet.data.labels.shift();
+				this.chartNet.data.datasets[0].data.shift();
+				this.chartNet.data.datasets[1].data.shift();
+				this.chartDisk.data.labels.shift();
+				this.chartDisk.data.datasets[0].data.shift();
+				this.chartDisk.data.datasets[1].data.shift();
+			}
+			this.chartCpuMem.update();
+			this.chartNet.update();
+			this.chartDisk.update();
+		},
+
+		onStatsLog(statsLog) {
+			for (const stats of [...statsLog].reverse()) {
+				this.onStats(stats);
+			}
+		},
+
+		bytes,
+
+		number,
+
+		pause() {
+			this.paused = true;
+		},
+
+		resume() {
+			this.paused = false;
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+	> div:nth-child(2) {
+		padding: 16px;
+		border-top: solid 0.5px var(--divider);
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
new file mode 100644
index 0000000000..f2ab30eaa5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -0,0 +1,148 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
+
+				<template v-if="useObjectStorage">
+					<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStorageBucket" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBucket }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStoragePrefix" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+						<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStorageEndpoint" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+						<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStorageRegion" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageRegion }}</template>
+						<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
+					</FormInput>
+
+					<FormSplit :min-width="280">
+						<FormInput v-model="objectStorageAccessKey" class="_formBlock">
+							<template #prefix><i class="ti ti-key"></i></template>
+							<template #label>Access key</template>
+						</FormInput>
+
+						<FormInput v-model="objectStorageSecretKey" class="_formBlock">
+							<template #prefix><i class="ti ti-key"></i></template>
+							<template #label>Secret key</template>
+						</FormInput>
+					</FormSplit>
+
+					<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
+						<template #label>s3ForcePathStyle</template>
+					</FormSwitch>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let useObjectStorage: boolean = $ref(false);
+let objectStorageBaseUrl: string | null = $ref(null);
+let objectStorageBucket: string | null = $ref(null);
+let objectStoragePrefix: string | null = $ref(null);
+let objectStorageEndpoint: string | null = $ref(null);
+let objectStorageRegion: string | null = $ref(null);
+let objectStoragePort: number | null = $ref(null);
+let objectStorageAccessKey: string | null = $ref(null);
+let objectStorageSecretKey: string | null = $ref(null);
+let objectStorageUseSSL: boolean = $ref(false);
+let objectStorageUseProxy: boolean = $ref(false);
+let objectStorageSetPublicRead: boolean = $ref(false);
+let objectStorageS3ForcePathStyle: boolean = $ref(true);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	useObjectStorage = meta.useObjectStorage;
+	objectStorageBaseUrl = meta.objectStorageBaseUrl;
+	objectStorageBucket = meta.objectStorageBucket;
+	objectStoragePrefix = meta.objectStoragePrefix;
+	objectStorageEndpoint = meta.objectStorageEndpoint;
+	objectStorageRegion = meta.objectStorageRegion;
+	objectStoragePort = meta.objectStoragePort;
+	objectStorageAccessKey = meta.objectStorageAccessKey;
+	objectStorageSecretKey = meta.objectStorageSecretKey;
+	objectStorageUseSSL = meta.objectStorageUseSSL;
+	objectStorageUseProxy = meta.objectStorageUseProxy;
+	objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+	objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		useObjectStorage,
+		objectStorageBaseUrl,
+		objectStorageBucket,
+		objectStoragePrefix,
+		objectStorageEndpoint,
+		objectStorageRegion,
+		objectStoragePort,
+		objectStorageAccessKey,
+		objectStorageSecretKey,
+		objectStorageUseSSL,
+		objectStorageUseProxy,
+		objectStorageSetPublicRead,
+		objectStorageS3ForcePathStyle,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.objectStorage,
+	icon: 'ti ti-cloud',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue
new file mode 100644
index 0000000000..62dff6ce7f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/other-settings.vue
@@ -0,0 +1,44 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			none
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+async function init() {
+	await os.api('admin/meta');
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta').then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'ti ti-adjustments',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
new file mode 100644
index 0000000000..c3ce5ac901
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -0,0 +1,217 @@
+<template>
+<div>
+	<MkLoading v-if="fetching"/>
+	<div v-show="!fetching" :class="$style.root" class="_panel">
+		<canvas ref="chartEl"></canvas>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import * as os from '@/os';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import gradient from 'chartjs-plugin-gradient';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	gradient,
+);
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartEl = $ref<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 7;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+async function renderChart() {
+	if (chartInstance) {
+		chartInstance.destroy();
+	}
+
+	const getDate = (ago: number) => {
+		const y = now.getFullYear();
+		const m = now.getMonth();
+		const d = now.getDate();
+
+		return new Date(y, m, d - ago);
+	};
+
+	const format = (arr) => {
+		return arr.map((v, i) => ({
+			x: getDate(i).getTime(),
+			y: v,
+		}));
+	};
+
+	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	const colorRead = '#3498db';
+	const colorWrite = '#2ecc71';
+
+	const max = Math.max(...raw.read);
+
+	chartInstance = new Chart(chartEl, {
+		type: 'bar',
+		data: {
+			datasets: [{
+				parsing: false,
+				label: 'Read',
+				data: format(raw.read).slice().reverse(),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: colorRead,
+				barPercentage: 0.7,
+				categoryPercentage: 0.5,
+				fill: true,
+			}, {
+				parsing: false,
+				label: 'Write',
+				data: format(raw.write).slice().reverse(),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: colorWrite,
+				barPercentage: 0.7,
+				categoryPercentage: 0.5,
+				fill: true,
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					offset: true,
+					time: {
+						stepSize: 1,
+						unit: 'day',
+					},
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						maxRotation: 0,
+						autoSkipPadding: 8,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+				},
+				y: {
+					position: 'left',
+					suggestedMax: 10,
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						//mirror: true,
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+				mode: 'index',
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+				gradient,
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+
+	fetching = false;
+}
+
+onMounted(async () => {
+	renderChart();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
new file mode 100644
index 0000000000..024ffdc245
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -0,0 +1,346 @@
+<template>
+<div>
+	<MkLoading v-if="fetching"/>
+	<div v-show="!fetching" :class="$style.root">
+		<div class="charts _panel">
+			<div class="chart">
+				<canvas ref="chartEl2"></canvas>
+			</div>
+			<div class="chart">
+				<canvas ref="chartEl"></canvas>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+import { defaultStore } from '@/store';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	gradient,
+);
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartLimit = 50;
+const chartEl = $ref<HTMLCanvasElement>();
+const chartEl2 = $ref<HTMLCanvasElement>();
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+const { handler: externalTooltipHandler2 } = useChartTooltip();
+
+onMounted(async () => {
+	const now = new Date();
+
+	const getDate = (ago: number) => {
+		const y = now.getFullYear();
+		const m = now.getMonth();
+		const d = now.getDate();
+
+		return new Date(y, m, d - ago);
+	};
+
+	const format = (arr) => {
+		return arr.map((v, i) => ({
+			x: getDate(i).getTime(),
+			y: v,
+		}));
+	};
+
+	const formatMinus = (arr) => {
+		return arr.map((v, i) => ({
+			x: getDate(i).getTime(),
+			y: -v,
+		}));
+	};
+
+	const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const succColor = '#87e000';
+	const failColor = '#ff4400';
+
+	const succMax = Math.max(...raw.deliverSucceeded);
+	const failMax = Math.max(...raw.deliverFailed);
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	new Chart(chartEl, {
+		type: 'line',
+		data: {
+			datasets: [{
+				stack: 'a',
+				parsing: false,
+				label: 'Out: Succ',
+				data: format(raw.deliverSucceeded).slice().reverse(),
+				tension: 0.3,
+				pointRadius: 0,
+				borderWidth: 2,
+				borderColor: succColor,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: alpha(succColor, 0.35),
+				fill: true,
+				clip: 8,
+			}, {
+				stack: 'a',
+				parsing: false,
+				label: 'Out: Fail',
+				data: formatMinus(raw.deliverFailed).slice().reverse(),
+				tension: 0.3,
+				pointRadius: 0,
+				borderWidth: 2,
+				borderColor: failColor,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: alpha(failColor, 0.35),
+				fill: true,
+				clip: 8,
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					stacked: true,
+					offset: false,
+					time: {
+						stepSize: 1,
+						unit: 'day',
+					},
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						maxRotation: 0,
+						autoSkipPadding: 16,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+					min: getDate(chartLimit).getTime(),
+				},
+				y: {
+					stacked: true,
+					position: 'left',
+					suggestedMax: 10,
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						//mirror: true,
+						callback: (value, index, values) => value < 0 ? -value : value,
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+				mode: 'index',
+			},
+			elements: {
+				point: {
+					hoverRadius: 5,
+					hoverBorderWidth: 2,
+				},
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+				gradient,
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+
+	new Chart(chartEl2, {
+		type: 'bar',
+		data: {
+			datasets: [{
+				parsing: false,
+				label: 'In',
+				data: format(raw.inboxReceived).slice().reverse(),
+				tension: 0.3,
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: '#0cc2d6',
+				barPercentage: 0.8,
+				categoryPercentage: 0.9,
+				fill: true,
+				clip: 8,
+			}],
+		},
+		options: {
+			aspectRatio: 5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					offset: false,
+					time: {
+						stepSize: 1,
+						unit: 'day',
+					},
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: false,
+						maxRotation: 0,
+						autoSkipPadding: 16,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+					min: getDate(chartLimit).getTime(),
+				},
+				y: {
+					position: 'left',
+					suggestedMax: 10,
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+				mode: 'index',
+			},
+			elements: {
+				point: {
+					hoverRadius: 5,
+					hoverBorderWidth: 2,
+				},
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler2,
+				},
+				gradient,
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+	
+	fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	&:global {
+		> .charts {
+			> .chart {
+				padding: 16px;
+
+				&:first-child {
+					border-bottom: solid 0.5px var(--divider);
+				}
+			}
+		}
+	}
+}
+</style>
+
diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue
new file mode 100644
index 0000000000..71f5a054b4
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.federation.vue
@@ -0,0 +1,185 @@
+<template>
+<div>
+	<MkLoading v-if="fetching"/>
+	<div v-show="!fetching" :class="$style.root">
+		<div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies">
+			<div class="pie deliver _panel">
+				<div class="title">Sub</div>
+				<XPie :data="topSubInstancesForPie" class="chart"/>
+				<div class="subTitle">Top 10</div>
+			</div>
+			<div class="pie inbox _panel">
+				<div class="title">Pub</div>
+				<XPie :data="topPubInstancesForPie" class="chart"/>
+				<div class="subTitle">Top 10</div>
+			</div>
+		</div>
+		<div v-if="!fetching" class="items">
+			<div class="item _panel sub">
+				<div class="icon"><i class="ti ti-world-download"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(federationSubActive) }}
+						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff>
+					</div>
+					<div class="label">Sub</div>
+				</div>
+			</div>
+			<div class="item _panel pub">
+				<div class="icon"><i class="ti ti-world-upload"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(federationPubActive) }}
+						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff>
+					</div>
+					<div class="label">Pub</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import XPie from './overview.pie.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
+let federationPubActive = $ref<number | null>(null);
+let federationPubActiveDiff = $ref<number | null>(null);
+let federationSubActive = $ref<number | null>(null);
+let federationSubActiveDiff = $ref<number | null>(null);
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+	
+onMounted(async () => {
+	const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' });
+	federationPubActive = chart.pubActive[0];
+	federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
+	federationSubActive = chart.subActive[0];
+	federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
+
+	os.apiGet('federation/stats', { limit: 10 }).then(res => {
+		topSubInstancesForPie = res.topSubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followersCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
+		topPubInstancesForPie = res.topPubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followingCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
+	});
+
+	fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+
+	&:global {
+		> .pies {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin-bottom: 12px;
+
+			> .pie {
+				position: relative;
+				padding: 12px;
+
+				> .title {
+					position: absolute;
+					top: 20px;
+					left: 20px;
+					font-size: 90%;
+				}
+
+				> .chart {
+					max-height: 150px;
+				}
+
+				> .subTitle {
+					position: absolute;
+					bottom: 20px;
+					right: 20px;
+					font-size: 85%;
+				}
+			}
+		}
+
+		> .items {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+
+			> .item {
+				display: flex;
+				box-sizing: border-box;
+				padding: 12px;
+
+				> .icon {
+					display: grid;
+					place-items: center;
+					height: 100%;
+					aspect-ratio: 1;
+					margin-right: 12px;
+					background: var(--accentedBg);
+					color: var(--accent);
+					border-radius: 10px;
+				}
+
+				&.sub {
+					> .icon {
+						background: #d5ba0026;
+						color: #dfc300;
+					}
+				}
+
+				&.pub {
+					> .icon {
+						background: #00cf2326;
+						color: #00cd5b;
+					}
+				}
+
+				> .body {
+					padding: 2px 0;
+
+					> .value {
+						font-size: 1.25em;
+						font-weight: bold;
+
+						> .diff {
+							font-size: 0.65em;
+							font-weight: normal;
+						}
+					}
+
+					> .label {
+						font-size: 0.8em;
+						opacity: 0.5;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
+
diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue
new file mode 100644
index 0000000000..16d1c83b9f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.heatmap.vue
@@ -0,0 +1,15 @@
+<template>
+<div class="_panel" :class="$style.root">
+	<MkActiveUsersHeatmap/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
new file mode 100644
index 0000000000..29848bf03b
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.instances.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="wbrkwale">
+	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+		<MkLoading v-if="fetching"/>
+		<div v-else class="instances">
+			<MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance">
+				<MkInstanceCardMini :instance="instance"/>
+			</MkA>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
+
+const instances = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+	const fetchedInstances = await os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 6,
+	});
+	instances.value = fetchedInstances;
+	fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwale {
+	> .instances {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+		grid-gap: 12px;
+
+		> .instance:hover {
+			text-decoration: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue
new file mode 100644
index 0000000000..a1f63c8711
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.moderators.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+		<MkLoading v-if="fetching"/>
+		<div v-else :class="$style.root" class="_panel">
+			<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`">
+				<MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/>
+			</MkA>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+let moderators: any = $ref(null);
+let fetching = $ref(true);
+
+onMounted(async () => {
+	moderators = await os.api('admin/show-users', {
+		sort: '+lastActiveDate',
+		state: 'adminOrModerator',
+		limit: 30,
+	});
+
+	fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
+	grid-gap: 12px;
+	place-content: center;
+	padding: 12px;
+
+	&:global {
+		> .user {
+			width: 100%;
+			height: 100%;
+			aspect-ratio: 1;
+
+			> .avatar {
+				width: 100%;
+				height: 100%;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
new file mode 100644
index 0000000000..94509cf006
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -0,0 +1,110 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+	DoughnutController,
+} from 'chart.js';
+import number from '@/filters/number';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	DoughnutController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+);
+
+const props = defineProps<{
+	data: { name: string; value: number; color: string; onClick?: () => void }[];
+}>();
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip({
+	position: 'middle',
+});
+
+let chartInstance: Chart;
+
+onMounted(() => {
+	chartInstance = new Chart(chartEl.value, {
+		type: 'doughnut',
+		data: {
+			labels: props.data.map(x => x.name),
+			datasets: [{
+				backgroundColor: props.data.map(x => x.color),
+				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
+				borderWidth: 2,
+				hoverOffset: 0,
+				data: props.data.map(x => x.value),
+			}],
+		},
+		options: {
+			layout: {
+				padding: {
+					left: 16,
+					right: 16,
+					top: 16,
+					bottom: 16,
+				},
+			},
+			onClick: (ev) => {
+				const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
+				if (hit && props.data[hit.index].onClick) {
+					props.data[hit.index].onClick();
+				}
+			},
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
new file mode 100644
index 0000000000..1e095bddaa
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -0,0 +1,186 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { watch, onMounted, onUnmounted, ref } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+);
+
+const props = defineProps<{
+	type: string;
+}>();
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+function setData(values) {
+	if (chartInstance == null) return;
+	for (const value of values) {
+		chartInstance.data.labels.push('');
+		chartInstance.data.datasets[0].data.push(value);
+		if (chartInstance.data.datasets[0].data.length > 100) {
+			chartInstance.data.labels.shift();
+			chartInstance.data.datasets[0].data.shift();
+		}
+	}
+	chartInstance.update();
+}
+
+function pushData(value) {
+	if (chartInstance == null) return;
+	chartInstance.data.labels.push('');
+	chartInstance.data.datasets[0].data.push(value);
+	if (chartInstance.data.datasets[0].data.length > 100) {
+		chartInstance.data.labels.shift();
+		chartInstance.data.datasets[0].data.shift();
+	}
+	chartInstance.update();
+}
+
+const label =
+	props.type === 'process' ? 'Process' :
+	props.type === 'active' ? 'Active' :
+	props.type === 'delayed' ? 'Delayed' :
+	props.type === 'waiting' ? 'Waiting' :
+	'?' as never;
+
+const color =
+	props.type === 'process' ? '#00E396' :
+	props.type === 'active' ? '#00BCD4' :
+	props.type === 'delayed' ? '#E53935' :
+	props.type === 'waiting' ? '#FFB300' :
+	'?' as never;
+
+onMounted(() => {
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+	chartInstance = new Chart(chartEl.value, {
+		type: 'line',
+		data: {
+			labels: [],
+			datasets: [{
+				label: label,
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: color,
+				backgroundColor: alpha(color, 0.2),
+				fill: true,
+				data: [],
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 0,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: false,
+						maxTicksLimit: 10,
+					},
+				},
+				y: {
+					min: 0,
+					grid: {
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+			},
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+});
+
+defineExpose({
+	setData,
+	pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue
new file mode 100644
index 0000000000..72ebddc72f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.queue.vue
@@ -0,0 +1,127 @@
+<template>
+<div :class="$style.root">
+	<div class="_table status">
+		<div class="_row">
+			<div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+			<div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div>
+			<div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+			<div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+		</div>
+	</div>
+	<div class="charts">
+		<div class="chart">
+			<div class="title">Process</div>
+			<XChart ref="chartProcess" type="process"/>
+		</div>
+		<div class="chart">
+			<div class="title">Active</div>
+			<XChart ref="chartActive" type="active"/>
+		</div>
+		<div class="chart">
+			<div class="title">Delayed</div>
+			<XChart ref="chartDelayed" type="delayed"/>
+		</div>
+		<div class="chart">
+			<div class="title">Waiting</div>
+			<XChart ref="chartWaiting" type="waiting"/>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './overview.queue.chart.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const connection = markRaw(stream.useChannel('queueStats'));
+
+const activeSincePrevTick = ref(0);
+const active = ref(0);
+const delayed = ref(0);
+const waiting = ref(0);
+let chartProcess = $ref<InstanceType<typeof XChart>>();
+let chartActive = $ref<InstanceType<typeof XChart>>();
+let chartDelayed = $ref<InstanceType<typeof XChart>>();
+let chartWaiting = $ref<InstanceType<typeof XChart>>();
+
+const props = defineProps<{
+	domain: string;
+}>();
+
+const onStats = (stats) => {
+	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+	active.value = stats[props.domain].active;
+	delayed.value = stats[props.domain].delayed;
+	waiting.value = stats[props.domain].waiting;
+
+	chartProcess.pushData(stats[props.domain].activeSincePrevTick);
+	chartActive.pushData(stats[props.domain].active);
+	chartDelayed.pushData(stats[props.domain].delayed);
+	chartWaiting.pushData(stats[props.domain].waiting);
+};
+
+const onStatsLog = (statsLog) => {
+	const dataProcess = [];
+	const dataActive = [];
+	const dataDelayed = [];
+	const dataWaiting = [];
+
+	for (const stats of [...statsLog].reverse()) {
+		dataProcess.push(stats[props.domain].activeSincePrevTick);
+		dataActive.push(stats[props.domain].active);
+		dataDelayed.push(stats[props.domain].delayed);
+		dataWaiting.push(stats[props.domain].waiting);
+	}
+
+	chartProcess.setData(dataProcess);
+	chartActive.setData(dataActive);
+	chartDelayed.setData(dataDelayed);
+	chartWaiting.setData(dataWaiting);
+};
+
+onMounted(() => {
+	connection.on('stats', onStats);
+	connection.on('statsLog', onStatsLog);
+	connection.send('requestLog', {
+		id: Math.random().toString().substr(2, 8),
+		length: 100,
+	});
+});
+
+onUnmounted(() => {
+	connection.off('stats', onStats);
+	connection.off('statsLog', onStatsLog);
+	connection.dispose();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	&:global {
+		> .status {
+			padding: 0 0 16px 0;
+		}
+
+		> .charts {
+			display: grid;
+			grid-template-columns: 1fr 1fr;
+			gap: 12px;
+
+			> .chart {
+				min-width: 0;
+				padding: 16px;
+				background: var(--panel);
+				border-radius: var(--radius);
+
+				> .title {
+					font-size: 0.85em;
+				}
+			}
+		}
+	}
+}
+</style>	
diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue
new file mode 100644
index 0000000000..feac6f8118
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.retention.vue
@@ -0,0 +1,49 @@
+<template>
+<div>
+	<MkLoading v-if="fetching"/>
+	<div v-else :class="$style.root">
+		<div v-for="row in retention" class="row">
+			<div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell">
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+let retention: any = $ref(null);
+let fetching = $ref(true);
+
+function getValues(row) {
+	const data = [];
+	for (const key in row.data) {
+		data.push({
+			date: new Date(key),
+			value: number(row.data[key]),
+			percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`,
+		});
+	}
+	data.sort((a, b) => a.date > b.date);
+	return data;
+}
+
+onMounted(async () => {
+	retention = await os.apiGet('retention', {});
+
+	fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+
+	&:global {
+
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue
new file mode 100644
index 0000000000..4dcf7e751a
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.stats.vue
@@ -0,0 +1,155 @@
+<template>
+<div>
+	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+		<MkLoading v-if="fetching"/>
+		<div v-else :class="$style.root">
+			<div class="item _panel users">
+				<div class="icon"><i class="ti ti-users"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(stats.originalUsersCount) }}
+						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff>
+					</div>
+					<div class="label">Users</div>
+				</div>
+			</div>
+			<div class="item _panel notes">
+				<div class="icon"><i class="ti ti-pencil"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(stats.originalNotesCount) }}
+						<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff>
+					</div>
+					<div class="label">Notes</div>
+				</div>
+			</div>
+			<div class="item _panel instances">
+				<div class="icon"><i class="ti ti-planet"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(stats.instances) }}
+					</div>
+					<div class="label">Instances</div>
+				</div>
+			</div>
+			<div class="item _panel online">
+				<div class="icon"><i class="ti ti-access-point"></i></div>
+				<div class="body">
+					<div class="value">
+						{{ number(onlineUsersCount) }}
+					</div>
+					<div class="label">Online</div>
+				</div>
+			</div>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+
+let stats: any = $ref(null);
+let usersComparedToThePrevDay = $ref<number>();
+let notesComparedToThePrevDay = $ref<number>();
+let onlineUsersCount = $ref(0);
+let fetching = $ref(true);
+
+onMounted(async () => {
+	const [_stats, _onlineUsersCount] = await Promise.all([
+		os.api('stats', {}),
+		os.api('get-online-users-count').then(res => res.count),
+	]);
+	stats = _stats;
+	onlineUsersCount = _onlineUsersCount;
+
+	os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
+		usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
+	});
+
+	os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+		notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
+	});
+
+	fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+	grid-gap: 12px;
+
+	&:global {
+		> .item {
+			display: flex;
+			box-sizing: border-box;
+			padding: 12px;
+
+			> .icon {
+				display: grid;
+				place-items: center;
+				height: 100%;
+				aspect-ratio: 1;
+				margin-right: 12px;
+				background: var(--accentedBg);
+				color: var(--accent);
+				border-radius: 10px;
+			}
+
+			&.users {
+				> .icon {
+					background: #0088d726;
+					color: #3d96c1;
+				}
+			}
+
+			&.notes {
+				> .icon {
+					background: #86b30026;
+					color: #86b300;
+				}
+			}
+
+			&.instances {
+				> .icon {
+					background: #e96b0026;
+					color: #d76d00;
+				}
+			}
+
+			&.online {
+				> .icon {
+					background: #8a00d126;
+					color: #c01ac3;
+				}
+			}
+
+			> .body {
+				padding: 2px 0;
+
+				> .value {
+					font-size: 1.25em;
+					font-weight: bold;
+
+					> .diff {
+						font-size: 0.65em;
+						font-weight: normal;
+					}
+				}
+
+				> .label {
+					font-size: 0.8em;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue
new file mode 100644
index 0000000000..5d4be11742
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.users.vue
@@ -0,0 +1,57 @@
+<template>
+<div :class="$style.root">
+	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+		<MkLoading v-if="fetching"/>
+		<div v-else class="users">
+			<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user">
+				<MkUserCardMini :user="user"/>
+			</MkA>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+
+let newUsers = $ref(null);
+let fetching = $ref(true);
+
+const fetch = async () => {
+	const _newUsers = await os.api('admin/show-users', {
+		limit: 5,
+		sort: '+createdAt',
+		origin: 'local',
+	});
+	newUsers = _newUsers;
+	fetching = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	&:global {
+		> .users {
+			.chart-move {
+				transition: transform 1s ease;
+			}
+
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+			grid-gap: 12px;
+
+			> .user:hover {
+				text-decoration: none;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
new file mode 100644
index 0000000000..d656e55200
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -0,0 +1,190 @@
+<template>
+<MkSpacer :content-max="1000">
+	<div ref="rootEl" class="edbbcaef">
+		<MkFolder class="item">
+			<template #header>Stats</template>
+			<XStats/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Active users</template>
+			<XActiveUsers/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Heatmap</template>
+			<XHeatmap/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Retention rate</template>
+			<XRetention/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Moderators</template>
+			<XModerators/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Federation</template>
+			<XFederation/>
+		</MkFolder>
+		
+		<MkFolder class="item">
+			<template #header>Instances</template>
+			<XInstances/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Ap requests</template>
+			<XApRequests/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>New users</template>
+			<XUsers/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Deliver queue</template>
+			<XQueue domain="deliver"/>
+		</MkFolder>
+
+		<MkFolder class="item">
+			<template #header>Inbox queue</template>
+			<XQueue domain="inbox"/>
+		</MkFolder>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XFederation from './overview.federation.vue';
+import XInstances from './overview.instances.vue';
+import XQueue from './overview.queue.vue';
+import XApRequests from './overview.ap-requests.vue';
+import XUsers from './overview.users.vue';
+import XActiveUsers from './overview.active-users.vue';
+import XStats from './overview.stats.vue';
+import XRetention from './overview.retention.vue';
+import XModerators from './overview.moderators.vue';
+import XHeatmap from './overview.heatmap.vue';
+import MkTagCloud from '@/components/MkTagCloud.vue';
+import { version, url } from '@/config';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import MkFolder from '@/components/MkFolder.vue';
+
+const rootEl = $ref<HTMLElement>();
+let serverInfo: any = $ref(null);
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
+let federationPubActive = $ref<number | null>(null);
+let federationPubActiveDiff = $ref<number | null>(null);
+let federationSubActive = $ref<number | null>(null);
+let federationSubActiveDiff = $ref<number | null>(null);
+let newUsers = $ref(null);
+let activeInstances = $shallowRef(null);
+const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
+const now = new Date();
+const filesPagination = {
+	endpoint: 'admin/drive/files' as const,
+	limit: 9,
+	noPaging: true,
+};
+
+function onInstanceClick(i) {
+	os.pageWindow(`/instance-info/${i.host}`);
+}
+
+onMounted(async () => {
+	/*
+	const magicGrid = new MagicGrid({
+		container: rootEl,
+		static: true,
+		animate: true,
+	});
+
+	magicGrid.listen();
+	*/
+
+	os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => {
+		federationPubActive = chart.pubActive[0];
+		federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
+		federationSubActive = chart.subActive[0];
+		federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
+	});
+
+	os.apiGet('federation/stats', { limit: 10 }).then(res => {
+		topSubInstancesForPie = res.topSubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followersCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
+		topPubInstancesForPie = res.topPubInstances.map(x => ({
+			name: x.host,
+			color: x.themeColor,
+			value: x.followingCount,
+			onClick: () => {
+				os.pageWindow(`/instance-info/${x.host}`);
+			},
+		})).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
+	});
+
+	os.api('admin/server-info').then(serverInfoResponse => {
+		serverInfo = serverInfoResponse;
+	});
+
+	os.api('admin/show-users', {
+		limit: 5,
+		sort: '+createdAt',
+	}).then(res => {
+		newUsers = res;
+	});
+
+	os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 25,
+	}).then(res => {
+		activeInstances = res;
+	});
+
+	nextTick(() => {
+		queueStatsConnection.send('requestLog', {
+			id: Math.random().toString().substr(2, 8),
+			length: 100,
+		});
+	});
+});
+
+onBeforeUnmount(() => {
+	queueStatsConnection.dispose();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.dashboard,
+	icon: 'ti ti-dashboard',
+});
+</script>
+
+<style lang="scss" scoped>
+.edbbcaef {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+	grid-gap: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue
new file mode 100644
index 0000000000..5d0d67980e
--- /dev/null
+++ b/packages/frontend/src/pages/admin/proxy-account.vue
@@ -0,0 +1,62 @@
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+	<FormSuspense :p="init">
+		<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
+		<MkKeyValue class="_formBlock">
+			<template #key>{{ i18n.ts.proxyAccount }}</template>
+			<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
+		</MkKeyValue>
+
+		<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
+	</FormSuspense>
+</MkSpacer></MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let proxyAccount: any = $ref(null);
+let proxyAccountId: any = $ref(null);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	proxyAccountId = meta.proxyAccountId;
+	if (proxyAccountId) {
+		proxyAccount = await os.api('users/show', { userId: proxyAccountId });
+	}
+}
+
+function chooseProxyAccount() {
+	os.selectUser().then(user => {
+		proxyAccount = user;
+		proxyAccountId = user.id;
+		save();
+	});
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		proxyAccountId: proxyAccountId,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.proxyAccount,
+	icon: 'ti ti-ghost',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
new file mode 100644
index 0000000000..5777674ae3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -0,0 +1,186 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { watch, onMounted, onUnmounted, ref } from 'vue';
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+);
+
+const props = defineProps<{
+	type: string;
+}>();
+
+const alpha = (hex, a) => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+function setData(values) {
+	if (chartInstance == null) return;
+	for (const value of values) {
+		chartInstance.data.labels.push('');
+		chartInstance.data.datasets[0].data.push(value);
+		if (chartInstance.data.datasets[0].data.length > 200) {
+			chartInstance.data.labels.shift();
+			chartInstance.data.datasets[0].data.shift();
+		}
+	}
+	chartInstance.update();
+}
+
+function pushData(value) {
+	if (chartInstance == null) return;
+	chartInstance.data.labels.push('');
+	chartInstance.data.datasets[0].data.push(value);
+	if (chartInstance.data.datasets[0].data.length > 200) {
+		chartInstance.data.labels.shift();
+		chartInstance.data.datasets[0].data.shift();
+	}
+	chartInstance.update();
+}
+
+const label =
+	props.type === 'process' ? 'Process' :
+	props.type === 'active' ? 'Active' :
+	props.type === 'delayed' ? 'Delayed' :
+	props.type === 'waiting' ? 'Waiting' :
+	'?' as never;
+
+const color =
+	props.type === 'process' ? '#00E396' :
+	props.type === 'active' ? '#00BCD4' :
+	props.type === 'delayed' ? '#E53935' :
+	props.type === 'waiting' ? '#FFB300' :
+	'?' as never;
+
+onMounted(() => {
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+	chartInstance = new Chart(chartEl.value, {
+		type: 'line',
+		data: {
+			labels: [],
+			datasets: [{
+				label: label,
+				pointRadius: 0,
+				tension: 0.3,
+				borderWidth: 2,
+				borderJoinStyle: 'round',
+				borderColor: color,
+				backgroundColor: alpha(color, 0.2),
+				fill: true,
+				data: [],
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 0,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: false,
+						maxTicksLimit: 10,
+					},
+				},
+				y: {
+					min: 0,
+					grid: {
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+			},
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+});
+
+defineExpose({
+	setData,
+	pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
new file mode 100644
index 0000000000..186a22c43e
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -0,0 +1,149 @@
+<template>
+<div class="pumxzjhg">
+	<div class="_table status">
+		<div class="_row">
+			<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+			<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
+			<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+			<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+		</div>
+	</div>
+	<div class="charts">
+		<div class="chart">
+			<div class="title">Process</div>
+			<XChart ref="chartProcess" type="process"/>
+		</div>
+		<div class="chart">
+			<div class="title">Active</div>
+			<XChart ref="chartActive" type="active"/>
+		</div>
+		<div class="chart">
+			<div class="title">Delayed</div>
+			<XChart ref="chartDelayed" type="delayed"/>
+		</div>
+		<div class="chart">
+			<div class="title">Waiting</div>
+			<XChart ref="chartWaiting" type="waiting"/>
+		</div>
+	</div>
+	<div class="jobs">
+		<div v-if="jobs.length > 0">
+			<div v-for="job in jobs" :key="job[0]">
+				<span>{{ job[0] }}</span>
+				<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
+			</div>
+		</div>
+		<span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './queue.chart.chart.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const connection = markRaw(stream.useChannel('queueStats'));
+
+const activeSincePrevTick = ref(0);
+const active = ref(0);
+const delayed = ref(0);
+const waiting = ref(0);
+const jobs = ref([]);
+let chartProcess = $ref<InstanceType<typeof XChart>>();
+let chartActive = $ref<InstanceType<typeof XChart>>();
+let chartDelayed = $ref<InstanceType<typeof XChart>>();
+let chartWaiting = $ref<InstanceType<typeof XChart>>();
+
+const props = defineProps<{
+	domain: string;
+}>();
+
+const onStats = (stats) => {
+	activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+	active.value = stats[props.domain].active;
+	delayed.value = stats[props.domain].delayed;
+	waiting.value = stats[props.domain].waiting;
+
+	chartProcess.pushData(stats[props.domain].activeSincePrevTick);
+	chartActive.pushData(stats[props.domain].active);
+	chartDelayed.pushData(stats[props.domain].delayed);
+	chartWaiting.pushData(stats[props.domain].waiting);
+};
+
+const onStatsLog = (statsLog) => {
+	const dataProcess = [];
+	const dataActive = [];
+	const dataDelayed = [];
+	const dataWaiting = [];
+
+	for (const stats of [...statsLog].reverse()) {
+		dataProcess.push(stats[props.domain].activeSincePrevTick);
+		dataActive.push(stats[props.domain].active);
+		dataDelayed.push(stats[props.domain].delayed);
+		dataWaiting.push(stats[props.domain].waiting);
+	}
+
+	chartProcess.setData(dataProcess);
+	chartActive.setData(dataActive);
+	chartDelayed.setData(dataDelayed);
+	chartWaiting.setData(dataWaiting);
+};
+
+onMounted(() => {
+	os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
+		jobs.value = result;
+	});
+
+	connection.on('stats', onStats);
+	connection.on('statsLog', onStatsLog);
+	connection.send('requestLog', {
+		id: Math.random().toString().substr(2, 8),
+		length: 200,
+	});
+});
+
+onUnmounted(() => {
+	connection.off('stats', onStats);
+	connection.off('statsLog', onStatsLog);
+	connection.dispose();
+});
+</script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+	> .status {
+		padding: 16px;
+	}
+
+	> .charts {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: 16px;
+
+		> .chart {
+			min-width: 0;
+			padding: 16px;
+			background: var(--panel);
+			border-radius: var(--radius);
+
+			> .title {
+				margin-bottom: 8px;
+			}
+		}
+	}
+
+	> .jobs {
+		margin-top: 16px;
+		padding: 16px;
+		max-height: 180px;
+		overflow: auto;
+		background: var(--panel);
+		border-radius: var(--radius);
+	}
+
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue
new file mode 100644
index 0000000000..8d19b49fc5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.vue
@@ -0,0 +1,56 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XQueue v-if="tab === 'deliver'" domain="deliver"/>
+		<XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import * as config from '@/config';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let tab = $ref('deliver');
+
+function clear() {
+	os.confirm({
+		type: 'warning',
+		title: i18n.ts.clearQueueConfirmTitle,
+		text: i18n.ts.clearQueueConfirmText,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+
+		os.apiWithDialog('admin/queue/clear');
+	});
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-external-link',
+	text: i18n.ts.dashboard,
+	handler: () => {
+		window.open(config.url + '/queue', '_blank');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'deliver',
+	title: 'Deliver',
+}, {
+	key: 'inbox',
+	title: 'Inbox',
+}]);
+
+definePageMetadata({
+	title: i18n.ts.jobQueue,
+	icon: 'ti ti-clock-play',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue
new file mode 100644
index 0000000000..4768ae67b1
--- /dev/null
+++ b/packages/frontend/src/pages/admin/relays.vue
@@ -0,0 +1,103 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
+			<div>{{ relay.inbox }}</div>
+			<div class="status">
+				<i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
+				<i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
+				<i v-else class="ti ti-clock icon requesting"></i>
+				<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+			</div>
+			<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let relays: any[] = $ref([]);
+
+async function addRelay() {
+	const { canceled, result: inbox } = await os.inputText({
+		title: i18n.ts.addRelay,
+		type: 'url',
+		placeholder: i18n.ts.inboxUrl,
+	});
+	if (canceled) return;
+	os.api('admin/relays/add', {
+		inbox,
+	}).then((relay: any) => {
+		refresh();
+	}).catch((err: any) => {
+		os.alert({
+			type: 'error',
+			text: err.message || err,
+		});
+	});
+}
+
+function remove(inbox: string) {
+	os.api('admin/relays/remove', {
+		inbox,
+	}).then(() => {
+		refresh();
+	}).catch((err: any) => {
+		os.alert({
+			type: 'error',
+			text: err.message || err,
+		});
+	});
+}
+
+function refresh() {
+	os.api('admin/relays/list').then((relayList: any) => {
+		relays = relayList;
+	});
+}
+
+refresh();
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-plus',
+	text: i18n.ts.addRelay,
+	handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.relays,
+	icon: 'ti ti-planet',
+});
+</script>
+
+<style lang="scss" scoped>
+.relaycxt {
+	> .status {
+		margin: 8px 0;
+
+		> .icon {
+			width: 1em;
+			margin-right: 0.75em;
+
+			&.accepted {
+				color: var(--success);
+			}
+
+			&.rejected {
+				color: var(--error);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
new file mode 100644
index 0000000000..2682bda337
--- /dev/null
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -0,0 +1,179 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormFolder class="_formBlock">
+					<template #icon><i class="ti ti-shield"></i></template>
+					<template #label>{{ i18n.ts.botProtection }}</template>
+					<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
+					<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
+					<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
+					<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
+
+					<XBotProtection/>
+				</FormFolder>
+
+				<FormFolder class="_formBlock">
+					<template #icon><i class="ti ti-eye-off"></i></template>
+					<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
+					<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
+					<template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
+					<template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
+					<template v-else #suffix>{{ i18n.ts.none }}</template>
+
+					<div class="_formRoot">
+						<span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span>
+
+						<FormRadios v-model="sensitiveMediaDetection" class="_formBlock">
+							<option value="none">{{ i18n.ts.none }}</option>
+							<option value="all">{{ i18n.ts.all }}</option>
+							<option value="local">{{ i18n.ts.localOnly }}</option>
+							<option value="remote">{{ i18n.ts.remoteOnly }}</option>
+						</FormRadios>
+
+						<FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock">
+							<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
+							<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
+						</FormRange>
+
+						<FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock">
+							<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+							<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
+						</FormSwitch>
+
+						<FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock">
+							<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
+							<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
+						</FormSwitch>
+
+						<!-- 現状 false positive が多すぎて実用に耐えない
+						<FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock">
+							<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
+						</FormSwitch>
+						-->
+
+						<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+					</div>
+				</FormFolder>
+
+				<FormFolder class="_formBlock">
+					<template #label>Active Email Validation</template>
+					<template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
+					<template v-else #suffix>Disabled</template>
+
+					<div class="_formRoot">
+						<span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span>
+						<FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save">
+							<template #label>Enable</template>
+						</FormSwitch>
+					</div>
+				</FormFolder>
+
+				<FormFolder class="_formBlock">
+					<template #label>Log IP address</template>
+					<template v-if="enableIpLogging" #suffix>Enabled</template>
+					<template v-else #suffix>Disabled</template>
+
+					<div class="_formRoot">
+						<FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save">
+							<template #label>Enable</template>
+						</FormSwitch>
+					</div>
+				</FormFolder>
+
+				<FormFolder class="_formBlock">
+					<template #label>Summaly Proxy</template>
+
+					<div class="_formRoot">
+						<FormInput v-model="summalyProxy" class="_formBlock">
+							<template #prefix><i class="ti ti-link"></i></template>
+							<template #label>Summaly Proxy URL</template>
+						</FormInput>
+
+						<FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+					</div>
+				</FormFolder>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XBotProtection from './bot-protection.vue';
+import XHeader from './_header_.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormRange from '@/components/form/range.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let summalyProxy: string = $ref('');
+let enableHcaptcha: boolean = $ref(false);
+let enableRecaptcha: boolean = $ref(false);
+let enableTurnstile: boolean = $ref(false);
+let sensitiveMediaDetection: string = $ref('none');
+let sensitiveMediaDetectionSensitivity: number = $ref(0);
+let setSensitiveFlagAutomatically: boolean = $ref(false);
+let enableSensitiveMediaDetectionForVideos: boolean = $ref(false);
+let enableIpLogging: boolean = $ref(false);
+let enableActiveEmailValidation: boolean = $ref(false);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	summalyProxy = meta.summalyProxy;
+	enableHcaptcha = meta.enableHcaptcha;
+	enableRecaptcha = meta.enableRecaptcha;
+	enableTurnstile = meta.enableTurnstile;
+	sensitiveMediaDetection = meta.sensitiveMediaDetection;
+	sensitiveMediaDetectionSensitivity =
+		meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
+		meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
+		meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
+		meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
+		meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
+	setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically;
+	enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos;
+	enableIpLogging = meta.enableIpLogging;
+	enableActiveEmailValidation = meta.enableActiveEmailValidation;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		summalyProxy,
+		sensitiveMediaDetection,
+		sensitiveMediaDetectionSensitivity:
+			sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
+			sensitiveMediaDetectionSensitivity === 1 ? 'low' :
+			sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
+			sensitiveMediaDetectionSensitivity === 3 ? 'high' :
+			sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
+			0,
+		setSensitiveFlagAutomatically,
+		enableSensitiveMediaDetectionForVideos,
+		enableIpLogging,
+		enableActiveEmailValidation,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'ti ti-lock',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
new file mode 100644
index 0000000000..460eb92694
--- /dev/null
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -0,0 +1,262 @@
+<template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+			<FormSuspense :p="init">
+				<div class="_formRoot">
+					<FormInput v-model="name" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceName }}</template>
+					</FormInput>
+
+					<FormTextarea v-model="description" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceDescription }}</template>
+					</FormTextarea>
+
+					<FormInput v-model="tosUrl" class="_formBlock">
+						<template #prefix><i class="ti ti-link"></i></template>
+						<template #label>{{ i18n.ts.tosUrl }}</template>
+					</FormInput>
+
+					<FormSplit :min-width="300">
+						<FormInput v-model="maintainerName" class="_formBlock">
+							<template #label>{{ i18n.ts.maintainerName }}</template>
+						</FormInput>
+
+						<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+							<template #prefix><i class="ti ti-mail"></i></template>
+							<template #label>{{ i18n.ts.maintainerEmail }}</template>
+						</FormInput>
+					</FormSplit>
+
+					<FormTextarea v-model="pinnedUsers" class="_formBlock">
+						<template #label>{{ i18n.ts.pinnedUsers }}</template>
+						<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+					</FormTextarea>
+
+					<FormSection>
+						<FormSwitch v-model="enableRegistration" class="_formBlock">
+							<template #label>{{ i18n.ts.enableRegistration }}</template>
+						</FormSwitch>
+
+						<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
+							<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+						</FormSwitch>
+					</FormSection>
+
+					<FormSection>
+						<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
+						<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
+						<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.theme }}</template>
+
+						<FormInput v-model="iconUrl" class="_formBlock">
+							<template #prefix><i class="ti ti-link"></i></template>
+							<template #label>{{ i18n.ts.iconUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="bannerUrl" class="_formBlock">
+							<template #prefix><i class="ti ti-link"></i></template>
+							<template #label>{{ i18n.ts.bannerUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="backgroundImageUrl" class="_formBlock">
+							<template #prefix><i class="ti ti-link"></i></template>
+							<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="themeColor" class="_formBlock">
+							<template #prefix><i class="ti ti-palette"></i></template>
+							<template #label>{{ i18n.ts.themeColor }}</template>
+							<template #caption>#RRGGBB</template>
+						</FormInput>
+
+						<FormTextarea v-model="defaultLightTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+
+						<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.files }}</template>
+
+						<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
+							<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
+							<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
+						</FormSwitch>
+
+						<FormSplit :min-width="280">
+							<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+
+							<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+						</FormSplit>
+					</FormSection>
+
+					<FormSection>
+						<template #label>ServiceWorker</template>
+
+						<FormSwitch v-model="enableServiceWorker" class="_formBlock">
+							<template #label>{{ i18n.ts.enableServiceworker }}</template>
+							<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
+						</FormSwitch>
+
+						<template v-if="enableServiceWorker">
+							<FormInput v-model="swPublicKey" class="_formBlock">
+								<template #prefix><i class="ti ti-key"></i></template>
+								<template #label>Public key</template>
+							</FormInput>
+
+							<FormInput v-model="swPrivateKey" class="_formBlock">
+								<template #prefix><i class="ti ti-key"></i></template>
+								<template #label>Private key</template>
+							</FormInput>
+						</template>
+					</FormSection>
+
+					<FormSection>
+						<template #label>DeepL Translation</template>
+
+						<FormInput v-model="deeplAuthKey" class="_formBlock">
+							<template #prefix><i class="ti ti-key"></i></template>
+							<template #label>DeepL Auth Key</template>
+						</FormInput>
+						<FormSwitch v-model="deeplIsPro" class="_formBlock">
+							<template #label>Pro account</template>
+						</FormSwitch>
+					</FormSection>
+				</div>
+			</FormSuspense>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let name: string | null = $ref(null);
+let description: string | null = $ref(null);
+let tosUrl: string | null = $ref(null);
+let maintainerName: string | null = $ref(null);
+let maintainerEmail: string | null = $ref(null);
+let iconUrl: string | null = $ref(null);
+let bannerUrl: string | null = $ref(null);
+let backgroundImageUrl: string | null = $ref(null);
+let themeColor: any = $ref(null);
+let defaultLightTheme: any = $ref(null);
+let defaultDarkTheme: any = $ref(null);
+let enableLocalTimeline: boolean = $ref(false);
+let enableGlobalTimeline: boolean = $ref(false);
+let pinnedUsers: string = $ref('');
+let cacheRemoteFiles: boolean = $ref(false);
+let localDriveCapacityMb: any = $ref(0);
+let remoteDriveCapacityMb: any = $ref(0);
+let enableRegistration: boolean = $ref(false);
+let emailRequiredForSignup: boolean = $ref(false);
+let enableServiceWorker: boolean = $ref(false);
+let swPublicKey: any = $ref(null);
+let swPrivateKey: any = $ref(null);
+let deeplAuthKey: string = $ref('');
+let deeplIsPro: boolean = $ref(false);
+
+async function init() {
+	const meta = await os.api('admin/meta');
+	name = meta.name;
+	description = meta.description;
+	tosUrl = meta.tosUrl;
+	iconUrl = meta.iconUrl;
+	bannerUrl = meta.bannerUrl;
+	backgroundImageUrl = meta.backgroundImageUrl;
+	themeColor = meta.themeColor;
+	defaultLightTheme = meta.defaultLightTheme;
+	defaultDarkTheme = meta.defaultDarkTheme;
+	maintainerName = meta.maintainerName;
+	maintainerEmail = meta.maintainerEmail;
+	enableLocalTimeline = !meta.disableLocalTimeline;
+	enableGlobalTimeline = !meta.disableGlobalTimeline;
+	pinnedUsers = meta.pinnedUsers.join('\n');
+	cacheRemoteFiles = meta.cacheRemoteFiles;
+	localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+	remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+	enableRegistration = !meta.disableRegistration;
+	emailRequiredForSignup = meta.emailRequiredForSignup;
+	enableServiceWorker = meta.enableServiceWorker;
+	swPublicKey = meta.swPublickey;
+	swPrivateKey = meta.swPrivateKey;
+	deeplAuthKey = meta.deeplAuthKey;
+	deeplIsPro = meta.deeplIsPro;
+}
+
+function save() {
+	os.apiWithDialog('admin/update-meta', {
+		name,
+		description,
+		tosUrl,
+		iconUrl,
+		bannerUrl,
+		backgroundImageUrl,
+		themeColor: themeColor === '' ? null : themeColor,
+		defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme,
+		defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme,
+		maintainerName,
+		maintainerEmail,
+		disableLocalTimeline: !enableLocalTimeline,
+		disableGlobalTimeline: !enableGlobalTimeline,
+		pinnedUsers: pinnedUsers.split('\n'),
+		cacheRemoteFiles,
+		localDriveCapacityMb: parseInt(localDriveCapacityMb, 10),
+		remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10),
+		disableRegistration: !enableRegistration,
+		emailRequiredForSignup,
+		enableServiceWorker,
+		swPublicKey,
+		swPrivateKey,
+		deeplAuthKey,
+		deeplIsPro,
+	}).then(() => {
+		fetchInstance();
+	});
+}
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'ti ti-settings',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
new file mode 100644
index 0000000000..d466e21907
--- /dev/null
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -0,0 +1,170 @@
+<template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="900">
+			<div class="lknzcolw">
+				<div class="users">
+					<div class="inputs">
+						<MkSelect v-model="sort" style="flex: 1;">
+							<template #label>{{ i18n.ts.sort }}</template>
+							<option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
+							<option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
+							<option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
+							<option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
+						</MkSelect>
+						<MkSelect v-model="state" style="flex: 1;">
+							<template #label>{{ i18n.ts.state }}</template>
+							<option value="all">{{ i18n.ts.all }}</option>
+							<option value="available">{{ i18n.ts.normal }}</option>
+							<option value="admin">{{ i18n.ts.administrator }}</option>
+							<option value="moderator">{{ i18n.ts.moderator }}</option>
+							<option value="silenced">{{ i18n.ts.silence }}</option>
+							<option value="suspended">{{ i18n.ts.suspend }}</option>
+						</MkSelect>
+						<MkSelect v-model="origin" style="flex: 1;">
+							<template #label>{{ i18n.ts.instance }}</template>
+							<option value="combined">{{ i18n.ts.all }}</option>
+							<option value="local">{{ i18n.ts.local }}</option>
+							<option value="remote">{{ i18n.ts.remote }}</option>
+						</MkSelect>
+					</div>
+					<div class="inputs">
+						<MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ i18n.ts.username }}</template>
+						</MkInput>
+						<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ i18n.ts.host }}</template>
+						</MkInput>
+					</div>
+
+					<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
+						<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
+							<MkUserCardMini :user="user"/>
+						</MkA>
+					</MkPagination>
+				</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XHeader from './_header_.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+
+let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
+
+let sort = $ref('+createdAt');
+let state = $ref('all');
+let origin = $ref('local');
+let searchUsername = $ref('');
+let searchHost = $ref('');
+const pagination = {
+	endpoint: 'admin/show-users' as const,
+	limit: 10,
+	params: computed(() => ({
+		sort: sort,
+		state: state,
+		origin: origin,
+		username: searchUsername,
+		hostname: searchHost,
+	})),
+	offsetMode: true,
+};
+
+function searchUser() {
+	os.selectUser().then(user => {
+		show(user);
+	});
+}
+
+async function addUser() {
+	const { canceled: canceled1, result: username } = await os.inputText({
+		title: i18n.ts.username,
+	});
+	if (canceled1) return;
+
+	const { canceled: canceled2, result: password } = await os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	});
+	if (canceled2) return;
+
+	os.apiWithDialog('admin/accounts/create', {
+		username: username,
+		password: password,
+	}).then(res => {
+		paginationComponent.reload();
+	});
+}
+
+function show(user) {
+	os.pageWindow(`/user-info/${user.id}`);
+}
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-search',
+	text: i18n.ts.search,
+	handler: searchUser,
+}, {
+	asFullButton: true,
+	icon: 'ti ti-plus',
+	text: i18n.ts.addUser,
+	handler: addUser,
+}, {
+	asFullButton: true,
+	icon: 'ti ti-search',
+	text: i18n.ts.lookup,
+	handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.users,
+	icon: 'ti ti-users',
+})));
+</script>
+
+<style lang="scss" scoped>
+.lknzcolw {
+	> .users {
+
+		> .inputs {
+			display: flex;
+			margin-bottom: 16px;
+
+			> * {
+				margin-right: 16px;
+
+				&:last-child {
+					margin-right: 0;
+				}
+			}
+		}
+	
+		> .users {
+			margin-top: var(--margin);
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+			grid-gap: 12px;
+
+			> .user:hover {
+				text-decoration: none;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
new file mode 100644
index 0000000000..6a93b3b9fa
--- /dev/null
+++ b/packages/frontend/src/pages/announcements.vue
@@ -0,0 +1,69 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
+			<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
+				<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+				<div class="_content">
+					<Mfm :text="announcement.text"/>
+					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+				</div>
+				<div v-if="$i && !announcement.isRead" class="_footer">
+					<MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ $ts.gotIt }}</MkButton>
+				</div>
+			</section>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'announcements' as const,
+	limit: 10,
+};
+
+// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
+function read(items, announcement, i) {
+	items[i] = {
+		...announcement,
+		isRead: true,
+	};
+	os.api('i/read-announcement', { announcementId: announcement.id });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'ti ti-speakerphone',
+});
+</script>
+
+<style lang="scss" scoped>
+.ruryvtyk {
+	> .announcement {
+		&:not(:last-child) {
+			margin-bottom: var(--margin);
+		}
+
+		> ._content {
+			> img {
+				display: block;
+				max-height: 300px;
+				max-width: 100%;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
new file mode 100644
index 0000000000..0b2c284c99
--- /dev/null
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -0,0 +1,128 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
+		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+		<div class="tl _block">
+			<XTimeline
+				ref="tlEl" :key="antennaId"
+				class="tl"
+				src="antenna"
+				:antenna="antennaId"
+				:sound="true"
+				@queue="queueUpdated"
+			/>
+		</div>
+	</div>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	antennaId: string;
+}>();
+
+let antenna = $ref(null);
+let queue = $ref(0);
+let rootEl = $ref<HTMLElement>();
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+const keymap = $computed(() => ({
+	't': focus,
+}));
+
+function queueUpdated(q) {
+	queue = q;
+}
+
+function top() {
+	scroll(rootEl, { top: 0 });
+}
+
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
+
+	tlEl.timetravel(date);
+}
+
+function settings() {
+	router.push(`/my/antennas/${props.antennaId}`);
+}
+
+function focus() {
+	tlEl.focus();
+}
+
+watch(() => props.antennaId, async () => {
+	antenna = await os.api('antennas/show', {
+		antennaId: props.antennaId,
+	});
+}, { immediate: true });
+
+const headerActions = $computed(() => antenna ? [{
+	icon: 'fas fa-calendar-alt',
+	text: i18n.ts.jumpToSpecifiedDate,
+	handler: timetravel,
+}, {
+	icon: 'ti ti-settings',
+	text: i18n.ts.settings,
+	handler: settings,
+}] : []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => antenna ? {
+	title: antenna.name,
+	icon: 'ti ti-antenna',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.tqmomfks {
+	padding: var(--margin);
+
+	> .new {
+		position: sticky;
+		top: calc(var(--stickyTop, 0px) + 16px);
+		z-index: 1000;
+		width: 100%;
+
+		> button {
+			display: block;
+			margin: var(--margin) auto 0 auto;
+			padding: 8px 16px;
+			border-radius: 32px;
+		}
+	}
+
+	> .tl {
+		background: var(--bg);
+		border-radius: var(--radius);
+		overflow: clip;
+	}
+
+	&.min-width_800px {
+		max-width: 800px;
+		margin: 0 auto;
+	}
+}
+
+@container (min-width: 800px) {
+	.tqmomfks {
+		max-width: 800px;
+		margin: 0 auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue
new file mode 100644
index 0000000000..1d5339b44c
--- /dev/null
+++ b/packages/frontend/src/pages/api-console.vue
@@ -0,0 +1,89 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<div class="_formBlock">
+				<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:model-value="onEndpointChange()">
+					<template #label>Endpoint</template>
+				</MkInput>
+				<MkTextarea v-model="body" class="_formBlock" code>
+					<template #label>Params (JSON or JSON5)</template>
+				</MkTextarea>
+				<MkSwitch v-model="withCredential" class="_formBlock">
+					With credential
+				</MkSwitch>
+				<MkButton class="_formBlock" primary :disabled="sending" @click="send">
+					<template v-if="sending"><MkEllipsis/></template>
+					<template v-else><i class="ti ti-send"></i> Send</template>
+				</MkButton>
+			</div>
+			<div v-if="res" class="_formBlock">
+				<MkTextarea v-model="res" code readonly tall>
+					<template #label>Response</template>
+				</MkTextarea>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import JSON5 from 'json5';
+import { Endpoints } from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const body = ref('{}');
+const endpoint = ref('');
+const endpoints = ref<any[]>([]);
+const sending = ref(false);
+const res = ref('');
+const withCredential = ref(true);
+
+os.api('endpoints').then(endpointResponse => {
+	endpoints.value = endpointResponse;
+});
+
+function send() {
+	sending.value = true;
+	const requestBody = JSON5.parse(body.value);
+	os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => {
+		sending.value = false;
+		res.value = JSON5.stringify(resp, null, 2);
+	}, err => {
+		sending.value = false;
+		res.value = JSON5.stringify(err, null, 2);
+	});
+}
+
+function onEndpointChange() {
+	os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
+		const endpointBody = {};
+		for (const p of resp.params) {
+			endpointBody[p.name] =
+				p.type === 'String' ? '' :
+				p.type === 'Number' ? 0 :
+				p.type === 'Boolean' ? false :
+				p.type === 'Array' ? [] :
+				p.type === 'Object' ? {} :
+				null;
+		}
+		body.value = JSON5.stringify(endpointBody, null, 2);
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API console',
+	icon: 'ti ti-terminal-2',
+});
+</script>
diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue
new file mode 100644
index 0000000000..1546735266
--- /dev/null
+++ b/packages/frontend/src/pages/auth.form.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="_section">
+	<div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div>
+	<div class="_content">
+		<h2>{{ app.name }}</h2>
+		<p class="id">{{ app.id }}</p>
+		<p class="description">{{ app.description }}</p>
+	</div>
+	<div class="_content">
+		<h2>{{ $ts._auth.permissionAsk }}</h2>
+		<ul>
+			<li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+		</ul>
+	</div>
+	<div class="_footer">
+		<MkButton inline @click="cancel">{{ $ts.cancel }}</MkButton>
+		<MkButton inline primary @click="accept">{{ $ts.accept }}</MkButton>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+	props: ['session'],
+	computed: {
+		name(): string {
+			const el = document.createElement('div');
+			el.textContent = this.app.name;
+			return el.innerHTML;
+		},
+		app(): any {
+			return this.session.app;
+		},
+	},
+	methods: {
+		cancel() {
+			os.api('auth/deny', {
+				token: this.session.token,
+			}).then(() => {
+				this.$emit('denied');
+			});
+		},
+
+		accept() {
+			os.api('auth/accept', {
+				token: this.session.token,
+			}).then(() => {
+				this.$emit('accepted');
+			});
+		},
+	},
+});
+</script>
diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue
new file mode 100644
index 0000000000..bb55881a22
--- /dev/null
+++ b/packages/frontend/src/pages/auth.vue
@@ -0,0 +1,91 @@
+<template>
+<div v-if="$i && fetching" class="">
+	<MkLoading/>
+</div>
+<div v-else-if="$i">
+	<XForm
+		v-if="state == 'waiting'"
+		ref="form"
+		class="form"
+		:session="session"
+		@denied="state = 'denied'"
+		@accepted="accepted"
+	/>
+	<div v-if="state == 'denied'" class="denied">
+		<h1>{{ $ts._auth.denied }}</h1>
+	</div>
+	<div v-if="state == 'accepted'" class="accepted">
+		<h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1>
+		<p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
+		<p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
+	</div>
+	<div v-if="state == 'fetch-session-error'" class="error">
+		<p>{{ $ts.somethingHappened }}</p>
+	</div>
+</div>
+<div v-else class="signin">
+	<MkSignin @login="onLogin"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XForm from './auth.form.vue';
+import MkSignin from '@/components/MkSignin.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+	components: {
+		XForm,
+		MkSignin,
+	},
+	props: ['token'],
+	data() {
+		return {
+			state: null,
+			session: null,
+			fetching: true,
+		};
+	},
+	mounted() {
+		if (!this.$i) return;
+
+		// Fetch session
+		os.api('auth/session/show', {
+			token: this.token,
+		}).then(session => {
+			this.session = session;
+			this.fetching = false;
+
+			// 既に連携していた場合
+			if (this.session.app.isAuthorized) {
+				os.api('auth/accept', {
+					token: this.session.token,
+				}).then(() => {
+					this.accepted();
+				});
+			} else {
+				this.state = 'waiting';
+			}
+		}).catch(error => {
+			this.state = 'fetch-session-error';
+			this.fetching = false;
+		});
+	},
+	methods: {
+		accepted() {
+			this.state = 'accepted';
+			if (this.session.app.callbackUrl) {
+				location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
+			}
+		}, onLogin(res) {
+			login(res.i);
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
new file mode 100644
index 0000000000..5ae7e63f99
--- /dev/null
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -0,0 +1,122 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<MkInput v-model="name" class="_formBlock">
+				<template #label>{{ i18n.ts.name }}</template>
+			</MkInput>
+
+			<MkTextarea v-model="description" class="_formBlock">
+				<template #label>{{ i18n.ts.description }}</template>
+			</MkTextarea>
+
+			<div class="banner">
+				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
+				<div v-else-if="bannerUrl">
+					<img :src="bannerUrl" style="width: 100%;"/>
+					<MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton>
+				</div>
+			</div>
+			<div class="_formBlock">
+				<MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	channelId?: string;
+}>();
+
+let channel = $ref(null);
+let name = $ref(null);
+let description = $ref(null);
+let bannerUrl = $ref<string | null>(null);
+let bannerId = $ref<string | null>(null);
+
+watch(() => bannerId, async () => {
+	if (bannerId == null) {
+		bannerUrl = null;
+	} else {
+		bannerUrl = (await os.api('drive/files/show', {
+			fileId: bannerId,
+		})).url;
+	}
+});
+
+async function fetchChannel() {
+	if (props.channelId == null) return;
+
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+
+	name = channel.name;
+	description = channel.description;
+	bannerId = channel.bannerId;
+	bannerUrl = channel.bannerUrl;
+}
+
+fetchChannel();
+
+function save() {
+	const params = {
+		name: name,
+		description: description,
+		bannerId: bannerId,
+	};
+
+	if (props.channelId) {
+		params.channelId = props.channelId;
+		os.api('channels/update', params).then(() => {
+			os.success();
+		});
+	} else {
+		os.api('channels/create', params).then(created => {
+			os.success();
+			router.push(`/channels/${created.id}`);
+		});
+	}
+}
+
+function setBannerImage(evt) {
+	selectFile(evt.currentTarget ?? evt.target, null).then(file => {
+		bannerId = file.id;
+	});
+}
+
+function removeBannerImage() {
+	bannerId = null;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.channelId ? {
+	title: i18n.ts._channel.edit,
+	icon: 'ti ti-device-tv',
+} : {
+	title: i18n.ts._channel.create,
+	icon: 'ti ti-device-tv',
+}));
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
new file mode 100644
index 0000000000..f271bb270f
--- /dev/null
+++ b/packages/frontend/src/pages/channel.vue
@@ -0,0 +1,184 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="channel">
+			<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
+				<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
+				<button class="_button toggle" @click="() => showBanner = !showBanner">
+					<template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
+					<template v-else><i class="ti ti-chevron-down"></i></template>
+				</button>
+				<div v-if="!showBanner" class="hideOverlay">
+				</div>
+				<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+					<div class="status">
+						<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+						<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+					</div>
+					<div class="fade"></div>
+				</div>
+				<div v-if="channel.description" class="description">
+					<Mfm :text="channel.description" :is-note="false" :i="$i"/>
+				</div>
+			</div>
+
+			<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
+
+			<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
+import MkContainer from '@/components/MkContainer.vue';
+import XPostForm from '@/components/MkPostForm.vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+const props = defineProps<{
+	channelId: string;
+}>();
+
+let channel = $ref(null);
+let showBanner = $ref(true);
+const pagination = {
+	endpoint: 'channels/timeline' as const,
+	limit: 10,
+	params: computed(() => ({
+		channelId: props.channelId,
+	})),
+};
+
+watch(() => props.channelId, async () => {
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+}, { immediate: true });
+
+function edit() {
+	router.push(`/channels/${channel.id}/edit`);
+}
+
+const headerActions = $computed(() => channel && channel.userId ? [{
+	icon: 'ti ti-settings',
+	text: i18n.ts.edit,
+	handler: edit,
+}] : null);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => channel ? {
+	title: channel.name,
+	icon: 'ti ti-device-tv',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.wpgynlbz {
+	position: relative;
+
+	> .subscribe {
+		position: absolute;
+		z-index: 1;
+		top: 16px;
+		left: 16px;
+	}
+
+	> .toggle {
+		position: absolute;
+		z-index: 2;
+    top: 8px;
+		right: 8px;
+		font-size: 1.2em;
+		width: 48px;
+		height: 48px;
+		color: #fff;
+		background: rgba(0, 0, 0, 0.5);
+		border-radius: 100%;
+		
+		> i {
+			vertical-align: middle;
+		}
+	}
+	
+	> .banner {
+		position: relative;
+		height: 200px;
+		background-position: center;
+		background-size: cover;
+
+		> .fade {
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), var(--X15));
+		}
+
+		> .status {
+			position: absolute;
+			z-index: 1;
+			bottom: 16px;
+			right: 16px;
+			padding: 8px 12px;
+			font-size: 80%;
+			background: rgba(0, 0, 0, 0.7);
+			border-radius: 6px;
+			color: #fff;
+		}
+	}
+
+	> .description {
+		padding: 16px;
+	}
+
+	> .hideOverlay {
+		position: absolute;
+		z-index: 1;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		-webkit-backdrop-filter: var(--blur, blur(16px));
+		backdrop-filter: var(--blur, blur(16px));
+		background: rgba(0, 0, 0, 0.3);
+	}
+
+	&.hide {
+		> .subscribe {
+			display: none;
+		}
+
+		> .toggle {
+			top: 0;
+			right: 0;
+			height: 100%;
+			background: transparent;
+		}
+
+		> .banner {
+			height: 42px;
+			filter: blur(8px);
+
+			> * {
+				display: none;
+			}
+		}
+
+		> .description {
+			display: none;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
new file mode 100644
index 0000000000..34e9dac196
--- /dev/null
+++ b/packages/frontend/src/pages/channels.vue
@@ -0,0 +1,79 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="_content grwlizim featured">
+			<MkPagination v-slot="{items}" :pagination="featuredPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'following'" class="_content grwlizim following">
+			<MkPagination v-slot="{items}" :pagination="followingPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
+			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="ownedPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineComponent, inject } from 'vue';
+import MkChannelPreview from '@/components/MkChannelPreview.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagination = {
+	endpoint: 'channels/featured' as const,
+	noPaging: true,
+};
+const followingPagination = {
+	endpoint: 'channels/followed' as const,
+	limit: 5,
+};
+const ownedPagination = {
+	endpoint: 'channels/owned' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/channels/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'featured',
+	title: i18n.ts._channel.featured,
+	icon: 'ti ti-comet',
+}, {
+	key: 'following',
+	title: i18n.ts._channel.following,
+	icon: 'ti ti-heart',
+}, {
+	key: 'owned',
+	title: i18n.ts._channel.owned,
+	icon: 'ti ti-edit',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.channel,
+	icon: 'ti ti-device-tv',
+})));
+</script>
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
new file mode 100644
index 0000000000..e0fbcb6bed
--- /dev/null
+++ b/packages/frontend/src/pages/clip.vue
@@ -0,0 +1,129 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="800">
+		<div v-if="clip">
+			<div class="okzinsic _panel">
+				<div v-if="clip.description" class="description">
+					<Mfm :text="clip.description" :is-note="false" :i="$i"/>
+				</div>
+				<div class="user">
+					<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
+				</div>
+			</div>
+
+			<XNotes :pagination="pagination" :detail="true"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, provide } from 'vue';
+import * as misskey from 'misskey-js';
+import XNotes from '@/components/MkNotes.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	clipId: string,
+}>();
+
+let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>();
+const pagination = {
+	endpoint: 'clips/notes' as const,
+	limit: 10,
+	params: computed(() => ({
+		clipId: props.clipId,
+	})),
+};
+
+const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId));
+
+watch(() => props.clipId, async () => {
+	clip = await os.api('clips/show', {
+		clipId: props.clipId,
+	});
+}, {
+	immediate: true,
+}); 
+
+provide('currentClipPage', $$(clip));
+
+const headerActions = $computed(() => clip && isOwned ? [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.edit,
+	handler: async (): Promise<void> => {
+		const { canceled, result } = await os.form(clip.name, {
+			name: {
+				type: 'string',
+				label: i18n.ts.name,
+				default: clip.name,
+			},
+			description: {
+				type: 'string',
+				required: false,
+				multiline: true,
+				label: i18n.ts.description,
+				default: clip.description,
+			},
+			isPublic: {
+				type: 'boolean',
+				label: i18n.ts.public,
+				default: clip.isPublic,
+			},
+		});
+		if (canceled) return;
+
+		os.apiWithDialog('clips/update', {
+			clipId: clip.id,
+			...result,
+		});
+	},
+}, {
+	icon: 'ti ti-trash',
+	text: i18n.ts.delete,
+	danger: true,
+	handler: async (): Promise<void> => {
+		const { canceled } = await os.confirm({
+			type: 'warning',
+			text: i18n.t('deleteAreYouSure', { x: clip.name }),
+		});
+		if (canceled) return;
+
+		await os.apiWithDialog('clips/delete', {
+			clipId: clip.id,
+		});
+	},
+}] : null);
+
+definePageMetadata(computed(() => clip ? {
+	title: clip.name,
+	icon: 'ti ti-paperclip',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.okzinsic {
+	position: relative;
+	margin-bottom: var(--margin);
+
+	> .description {
+		padding: 16px;
+	}
+
+	> .user {
+		$height: 32px;
+		padding: 16px;
+		border-top: solid 0.5px var(--divider);
+		line-height: $height;
+
+		> .avatar {
+			width: $height;
+			height: $height;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue
new file mode 100644
index 0000000000..04ade5c207
--- /dev/null
+++ b/packages/frontend/src/pages/drive.vue
@@ -0,0 +1,25 @@
+<template>
+<div>
+	<XDrive ref="drive" @cd="x => folder = x"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XDrive from '@/components/MkDrive.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let folder = $ref(null);
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: folder ? folder.name : i18n.ts.drive,
+	icon: 'ti ti-cloud',
+	hideHeader: true,
+})));
+</script>
diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue
new file mode 100644
index 0000000000..40fe496520
--- /dev/null
+++ b/packages/frontend/src/pages/emojis.emoji.vue
@@ -0,0 +1,72 @@
+<template>
+<button class="zuvgdzyu _button" @click="menu">
+	<img :src="emoji.url" class="img" :alt="emoji.name"/>
+	<div class="body">
+		<div class="name _monospace">{{ emoji.name }}</div>
+		<div class="info">{{ emoji.aliases.join(' ') }}</div>
+	</div>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	emoji: Record<string, unknown>; // TODO
+}>();
+
+function menu(ev) {
+	os.popupMenu([{
+		type: 'label',
+		text: ':' + props.emoji.name + ':',
+	}, {
+		text: i18n.ts.copy,
+		icon: 'ti ti-copy',
+		action: () => {
+			copyToClipboard(`:${props.emoji.name}:`);
+			os.success();
+		},
+	}], ev.currentTarget ?? ev.target);
+}
+</script>
+
+<style lang="scss" scoped>
+.zuvgdzyu {
+	display: flex;
+	align-items: center;
+	padding: 12px;
+	text-align: left;
+	background: var(--panel);
+	border-radius: 8px;
+
+	&:hover {
+		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;
+			font-size: 0.9em;
+			text-overflow: ellipsis;
+			overflow: hidden;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue
new file mode 100644
index 0000000000..18a371a086
--- /dev/null
+++ b/packages/frontend/src/pages/explore.featured.vue
@@ -0,0 +1,30 @@
+<template>
+<MkSpacer :content-max="800">
+	<MkTab v-model="tab" style="margin-bottom: var(--margin);">
+		<option value="notes">{{ i18n.ts.notes }}</option>
+		<option value="polls">{{ i18n.ts.poll }}</option>
+	</MkTab>
+	<XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
+	<XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import XNotes from '@/components/MkNotes.vue';
+import MkTab from '@/components/MkTab.vue';
+import { i18n } from '@/i18n';
+
+const paginationForNotes = {
+	endpoint: 'notes/featured' as const,
+	limit: 10,
+	offsetMode: true,
+};
+
+const paginationForPolls = {
+	endpoint: 'notes/polls/recommendation' as const,
+	limit: 10,
+	offsetMode: true,
+};
+
+let tab = $ref('notes');
+</script>
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
new file mode 100644
index 0000000000..bfee0a6c07
--- /dev/null
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -0,0 +1,148 @@
+<template>
+<MkSpacer :content-max="1200">
+	<MkTab v-model="origin" style="margin-bottom: var(--margin);">
+		<option value="local">{{ i18n.ts.local }}</option>
+		<option value="remote">{{ i18n.ts.remote }}</option>
+	</MkTab>
+	<div v-if="origin === 'local'">
+		<template v-if="tag == null">
+			<MkFolder class="_gap" persist-key="explore-pinned-users">
+				<template #header><i class="fas fa-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
+				<XUserList :pagination="pinnedUsers"/>
+			</MkFolder>
+			<MkFolder class="_gap" persist-key="explore-popular-users">
+				<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
+				<XUserList :pagination="popularUsers"/>
+			</MkFolder>
+			<MkFolder class="_gap" persist-key="explore-recently-updated-users">
+				<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
+				<XUserList :pagination="recentlyUpdatedUsers"/>
+			</MkFolder>
+			<MkFolder class="_gap" persist-key="explore-recently-registered-users">
+				<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
+				<XUserList :pagination="recentlyRegisteredUsers"/>
+			</MkFolder>
+		</template>
+	</div>
+	<div v-else>
+		<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
+			<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
+
+			<div class="vxjfqztj">
+				<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
+				<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
+			</div>
+		</MkFolder>
+
+		<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
+			<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
+			<XUserList :pagination="tagUsers"/>
+		</MkFolder>
+
+		<template v-if="tag == null">
+			<MkFolder class="_gap">
+				<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
+				<XUserList :pagination="popularUsersF"/>
+			</MkFolder>
+			<MkFolder class="_gap">
+				<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
+				<XUserList :pagination="recentlyUpdatedUsersF"/>
+			</MkFolder>
+			<MkFolder class="_gap">
+				<template #header><i class="fas fa-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
+				<XUserList :pagination="recentlyRegisteredUsersF"/>
+			</MkFolder>
+		</template>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import XUserList from '@/components/MkUserList.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkTab from '@/components/MkTab.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+
+const props = defineProps<{
+	tag?: string;
+}>();
+
+let origin = $ref('local');
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let tagsLocal = $ref([]);
+let tagsRemote = $ref([]);
+
+watch(() => props.tag, () => {
+	if (tagsEl) tagsEl.toggleContent(props.tag == null);
+});
+
+const tagUsers = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: props.tag,
+		origin: 'combined',
+		sort: '+follower',
+	},
+}));
+
+const pinnedUsers = { endpoint: 'pinned-users' };
+const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'local',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	state: 'alive',
+	sort: '+createdAt',
+} };
+const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'remote',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+createdAt',
+} };
+
+os.api('hashtags/list', {
+	sort: '+attachedLocalUsers',
+	attachedToLocalUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsLocal = tags;
+});
+os.api('hashtags/list', {
+	sort: '+attachedRemoteUsers',
+	attachedToRemoteUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsRemote = tags;
+});
+</script>
+
+<style lang="scss" scoped>
+.vxjfqztj {
+	> * {
+		margin-right: 16px;
+
+		&.local {
+			font-weight: bold;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
new file mode 100644
index 0000000000..6b0bcdaf62
--- /dev/null
+++ b/packages/frontend/src/pages/explore.vue
@@ -0,0 +1,87 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<div class="lznhrdub">
+		<div v-if="tab === 'featured'">
+			<XFeatured/>
+		</div>
+		<div v-else-if="tab === 'users'">
+			<XUsers/>
+		</div>
+		<div v-else-if="tab === 'search'">
+			<MkSpacer :content-max="1200">
+				<div>
+					<MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock">
+						<template #prefix><i class="ti ti-search"></i></template>
+						<template #label>{{ i18n.ts.searchUser }}</template>
+					</MkInput>
+					<MkRadios v-model="searchOrigin" class="_formBlock">
+						<option value="combined">{{ i18n.ts.all }}</option>
+						<option value="local">{{ i18n.ts.local }}</option>
+						<option value="remote">{{ i18n.ts.remote }}</option>
+					</MkRadios>
+				</div>
+
+				<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
+			</MkSpacer>
+		</div>
+	</div>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import XFeatured from './explore.featured.vue';
+import XUsers from './explore.users.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInput from '@/components/form/input.vue';
+import MkRadios from '@/components/form/radios.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import XUserList from '@/components/MkUserList.vue';
+
+const props = defineProps<{
+	tag?: string;
+}>();
+
+let tab = $ref('featured');
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let searchQuery = $ref(null);
+let searchOrigin = $ref('combined');
+
+watch(() => props.tag, () => {
+	if (tagsEl) tagsEl.toggleContent(props.tag == null);
+});
+
+const searchPagination = {
+	endpoint: 'users/search' as const,
+	limit: 10,
+	params: computed(() => (searchQuery && searchQuery !== '') ? {
+		query: searchQuery,
+		origin: searchOrigin,
+	} : null),
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	key: 'featured',
+	icon: 'ti ti-bolt',
+	title: i18n.ts.featured,
+}, {
+	key: 'users',
+	icon: 'ti ti-users',
+	title: i18n.ts.users,
+}, {
+	key: 'search',
+	title: i18n.ts.search,
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.explore,
+	icon: 'ti ti-hash',
+})));
+</script>
diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue
new file mode 100644
index 0000000000..ab47efec71
--- /dev/null
+++ b/packages/frontend/src/pages/favorites.vue
@@ -0,0 +1,49 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination ref="pagingComponent" :pagination="pagination">
+			<template #empty>
+				<div class="_fullinfo">
+					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+					<div>{{ i18n.ts.noNotes }}</div>
+				</div>
+			</template>
+
+			<template #default="{ items }">
+				<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
+					<XNote :key="item.id" :note="item.note" :class="$style.note"/>
+				</XList>
+			</template>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import XNote from '@/components/MkNote.vue';
+import XList from '@/components/MkDateSeparatedList.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'i/favorites' as const,
+	limit: 10,
+};
+
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
+
+definePageMetadata({
+	title: i18n.ts.favorites,
+	icon: 'ti ti-star',
+});
+</script>
+
+<style lang="scss" module>
+.note {
+	background: var(--panel);
+	border-radius: var(--radius);
+}
+</style>
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
new file mode 100644
index 0000000000..b20679ccc1
--- /dev/null
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -0,0 +1,153 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination ref="paginationComponent" :pagination="pagination">
+			<template #empty>
+				<div class="_fullinfo">
+					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+					<div>{{ i18n.ts.noFollowRequests }}</div>
+				</div>
+			</template>
+			<template #default="{items}">
+				<div class="mk-follow-requests">
+					<div v-for="req in items" :key="req.id" class="user _panel">
+						<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
+						<div class="body">
+							<div class="name">
+								<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
+								<p class="acct">@{{ acct(req.follower) }}</p>
+							</div>
+							<div v-if="req.follower.description" class="description" :title="req.follower.description">
+								<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
+							</div>
+							<div class="actions">
+								<button class="_button" @click="accept(req.follower)"><i class="ti ti-check"></i></button>
+								<button class="_button" @click="reject(req.follower)"><i class="ti ti-x"></i></button>
+							</div>
+						</div>
+					</div>
+				</div>
+			</template>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import { userPage, acct } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const paginationComponent = ref<InstanceType<typeof MkPagination>>();
+
+const pagination = {
+	endpoint: 'following/requests/list' as const,
+	limit: 10,
+};
+
+function accept(user) {
+	os.api('following/requests/accept', { userId: user.id }).then(() => {
+		paginationComponent.value.reload();
+	});
+}
+
+function reject(user) {
+	os.api('following/requests/reject', { userId: user.id }).then(() => {
+		paginationComponent.value.reload();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.followRequests,
+	icon: 'ti ti-user-plus',
+})));
+</script>
+
+<style lang="scss" scoped>
+.mk-follow-requests {
+	> .user {
+		display: flex;
+		padding: 16px;
+
+		> .avatar {
+			display: block;
+			flex-shrink: 0;
+			margin: 0 12px 0 0;
+			width: 42px;
+			height: 42px;
+			border-radius: 8px;
+		}
+
+		> .body {
+			display: flex;
+			width: calc(100% - 54px);
+			position: relative;
+
+			> .name {
+				width: 45%;
+
+				@media (max-width: 500px) {
+					width: 100%;
+				}
+
+				> .name,
+				> .acct {
+					display: block;
+					white-space: nowrap;
+					text-overflow: ellipsis;
+					overflow: hidden;
+					margin: 0;
+				}
+
+				> .name {
+					font-size: 16px;
+					line-height: 24px;
+				}
+
+				> .acct {
+					font-size: 15px;
+					line-height: 16px;
+					opacity: 0.7;
+				}
+			}
+
+			> .description {
+				width: 55%;
+				line-height: 42px;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+				opacity: 0.7;
+				font-size: 14px;
+				padding-right: 40px;
+				padding-left: 8px;
+				box-sizing: border-box;
+
+				@media (max-width: 500px) {
+					display: none;
+				}
+			}
+
+			> .actions {
+				position: absolute;
+				top: 0;
+				bottom: 0;
+				right: 0;
+				margin: auto 0;
+
+				> button {
+					padding: 12px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue
new file mode 100644
index 0000000000..828246d678
--- /dev/null
+++ b/packages/frontend/src/pages/follow.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="mk-follow-page">
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { i18n } from '@/i18n';
+
+async function follow(user): Promise<void> {
+	const { canceled } = await os.confirm({
+		type: 'question',
+		text: i18n.t('followConfirm', { name: user.name || user.username }),
+	});
+
+	if (canceled) {
+		window.close();
+		return;
+	}
+	
+	os.apiWithDialog('following/create', {
+		userId: user.id,
+	});
+}
+
+const acct = new URL(location.href).searchParams.get('acct');
+if (acct == null) {
+	throw new Error('acct required');
+}
+
+let promise;
+
+if (acct.startsWith('https://')) {
+	promise = os.api('ap/show', {
+		uri: acct,
+	});
+	promise.then(res => {
+		if (res.type === 'User') {
+			follow(res.object);
+		} else if (res.type === 'Note') {
+			mainRouter.push(`/notes/${res.object.id}`);
+		} else {
+			os.alert({
+				type: 'error',
+				text: 'Not a user',
+			}).then(() => {
+				window.close();
+			});
+		}
+	});
+} else {
+	promise = os.api('users/show', Acct.parse(acct));
+	promise.then(user => {
+		follow(user);
+	});
+}
+
+os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+</script>
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
new file mode 100644
index 0000000000..c8111d7890
--- /dev/null
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -0,0 +1,149 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<FormInput v-model="title">
+				<template #label>{{ i18n.ts.title }}</template>
+			</FormInput>
+
+			<FormTextarea v-model="description" :max="500">
+				<template #label>{{ i18n.ts.description }}</template>
+			</FormTextarea>
+
+			<div class="">
+				<div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
+					<div class="name">{{ file.name }}</div>
+					<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button>
+				</div>
+				<FormButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</FormButton>
+			</div>
+
+			<FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch>
+
+			<FormButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+			<FormButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</FormButton>
+
+			<FormButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</FormButton>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import { selectFiles } from '@/scripts/select-file';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	postId?: string;
+}>();
+
+let init = $ref(null);
+let files = $ref([]);
+let description = $ref(null);
+let title = $ref(null);
+let isSensitive = $ref(false);
+
+function selectFile(evt) {
+	selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
+		files = files.concat(selected);
+	});
+}
+
+function remove(file) {
+	files = files.filter(f => f.id !== file.id);
+}
+
+async function save() {
+	if (props.postId) {
+		await os.apiWithDialog('gallery/posts/update', {
+			postId: props.postId,
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		router.push(`/gallery/${props.postId}`);
+	} else {
+		const created = await os.apiWithDialog('gallery/posts/create', {
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		router.push(`/gallery/${created.id}`);
+	}
+}
+
+async function del() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+	});
+	if (canceled) return;
+	await os.apiWithDialog('gallery/posts/delete', {
+		postId: props.postId,
+	});
+	router.push('/gallery');
+}
+
+watch(() => props.postId, () => {
+	init = () => props.postId ? os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(post => {
+		files = post.files;
+		title = post.title;
+		description = post.description;
+		isSensitive = post.isSensitive;
+	}) : Promise.resolve(null);
+}, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.postId ? {
+	title: i18n.ts.edit,
+	icon: 'ti ti-pencil',
+} : {
+	title: i18n.ts.postToGallery,
+	icon: 'ti ti-pencil',
+}));
+</script>
+
+<style lang="scss" scoped>
+.wqugxsfx {
+	height: 200px;
+	background-size: contain;
+	background-position: center;
+	background-repeat: no-repeat;
+	position: relative;
+
+	> .name {
+		position: absolute;
+		top: 8px;
+		left: 9px;
+		padding: 8px;
+		background: var(--panel);
+	}
+
+	> .remove {
+		position: absolute;
+		top: 8px;
+		right: 9px;
+		padding: 8px;
+		background: var(--panel);
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue
new file mode 100644
index 0000000000..24a634bab5
--- /dev/null
+++ b/packages/frontend/src/pages/gallery/index.vue
@@ -0,0 +1,139 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1400">
+		<div class="_root">
+			<div v-if="tab === 'explore'">
+				<MkFolder class="_gap">
+					<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
+				<MkFolder class="_gap">
+					<template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
+			</div>
+			<div v-else-if="tab === 'liked'">
+				<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+					</div>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'my'">
+				<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA>
+				<MkPagination v-slot="{items}" :pagination="myPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+					</div>
+				</MkPagination>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
+import XUserList from '@/components/MkUserList.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInput from '@/components/form/input.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkTab from '@/components/MkTab.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+
+const router = useRouter();
+
+const props = defineProps<{
+	tag?: string;
+}>();
+
+let tab = $ref('explore');
+let tags = $ref([]);
+let tagsRef = $ref();
+
+const recentPostsPagination = {
+	endpoint: 'gallery/posts' as const,
+	limit: 6,
+};
+const popularPostsPagination = {
+	endpoint: 'gallery/featured' as const,
+	limit: 5,
+};
+const myPostsPagination = {
+	endpoint: 'i/gallery/posts' as const,
+	limit: 5,
+};
+const likedPostsPagination = {
+	endpoint: 'i/gallery/likes' as const,
+	limit: 5,
+};
+
+const tagUsersPagination = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: this.tag,
+		origin: 'combined',
+		sort: '+follower',
+	},
+}));
+
+watch(() => props.tag, () => {
+	if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
+});
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-plus',
+	text: i18n.ts.create,
+	handler: () => {
+		router.push('/gallery/new');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'explore',
+	title: i18n.ts.gallery,
+	icon: 'ti ti-icons',
+}, {
+	key: 'liked',
+	title: i18n.ts._gallery.liked,
+	icon: 'ti ti-heart',
+}, {
+	key: 'my',
+	title: i18n.ts._gallery.my,
+	icon: 'ti ti-edit',
+}]);
+
+definePageMetadata({
+	title: i18n.ts.gallery,
+	icon: 'ti ti-icons',
+});
+</script>
+
+<style lang="scss" scoped>
+.vfpdbgtk {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: 12px;
+	margin: 0 var(--margin);
+
+	> .post {
+
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
new file mode 100644
index 0000000000..85ab1048be
--- /dev/null
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -0,0 +1,265 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
+		<div class="_root">
+			<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+				<div v-if="post" class="rkxwuolj">
+					<div class="files">
+						<div v-for="file in post.files" :key="file.id" class="file">
+							<img :src="file.url"/>
+						</div>
+					</div>
+					<div class="body _block">
+						<div class="title">{{ post.title }}</div>
+						<div class="description"><Mfm :text="post.description"/></div>
+						<div class="info">
+							<i class="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
+						</div>
+						<div class="actions">
+							<div class="like">
+								<MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
+								<MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
+							</div>
+							<div class="other">
+								<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
+								<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
+								<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
+							</div>
+						</div>
+						<div class="user">
+							<MkAvatar :user="post.user" class="avatar"/>
+							<div class="name">
+								<MkUserName :user="post.user" style="display: block;"/>
+								<MkAcct :user="post.user"/>
+							</div>
+							<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+						</div>
+					</div>
+					<MkAd :prefer="['horizontal', 'horizontal-big']"/>
+					<MkContainer :max-height="300" :foldable="true" class="other">
+						<template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template>
+						<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
+							<div class="sdrarzaf">
+								<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+							</div>
+						</MkPagination>
+					</MkContainer>
+				</div>
+				<MkError v-else-if="error" @retry="fetch()"/>
+				<MkLoading v-else/>
+			</transition>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineComponent, inject, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import { url } from '@/config';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+const props = defineProps<{
+	postId: string;
+}>();
+
+let post = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/gallery/posts' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: post.user.id,
+	})),
+};
+
+function fetchPost() {
+	post = null;
+	os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(_post => {
+		post = _post;
+	}).catch(_error => {
+		error = _error;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: post.title,
+		text: post.description,
+		url: `${url}/gallery/${post.id}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${post.title} ${url}/gallery/${post.id}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('gallery/posts/like', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = true;
+		post.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('gallery/posts/unlike', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = false;
+		post.likedCount--;
+	});
+}
+
+function edit() {
+	router.push(`/gallery/${post.id}/edit`);
+}
+
+watch(() => props.postId, fetchPost, { immediate: true });
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.edit,
+	handler: edit,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => post ? {
+	title: post.title,
+	avatar: post.user,
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.rkxwuolj {
+	> .files {
+		> .file {
+			> img {
+				display: block;
+				max-width: 100%;
+				max-height: 500px;
+				margin: 0 auto;
+			}
+
+			& + .file {
+				margin-top: 16px;
+			}
+		}
+	}
+
+	> .body {
+		padding: 32px;
+
+		> .title {
+			font-weight: bold;
+			font-size: 1.2em;
+			margin-bottom: 16px;
+		}
+
+		> .info {
+			margin-top: 16px;
+			font-size: 90%;
+			opacity: 0.7;
+		}
+
+		> .actions {
+			display: flex;
+			align-items: center;
+			margin-top: 16px;
+			padding: 16px 0 0 0;
+			border-top: solid 0.5px var(--divider);
+
+			> .like {
+				> .button {
+					--accent: rgb(241 97 132);
+					--X8: rgb(241 92 128);
+					--buttonBg: rgb(216 71 106 / 5%);
+					--buttonHoverBg: rgb(216 71 106 / 10%);
+					color: #ff002f;
+
+					::v-deep(.count) {
+						margin-left: 0.5em;
+					}
+				}
+			}
+
+			> .other {
+				margin-left: auto;
+
+				> button {
+					padding: 8px;
+					margin: 0 8px;
+
+					&:hover {
+						color: var(--fgHighlighted);
+					}
+				}
+			}
+		}
+
+		> .user {
+			margin-top: 16px;
+			padding: 16px 0 0 0;
+			border-top: solid 0.5px var(--divider);
+			display: flex;
+			align-items: center;
+
+			> .avatar {
+				width: 52px;
+				height: 52px;
+			}
+
+			> .name {
+				margin: 0 0 0 12px;
+				font-size: 90%;
+			}
+
+			> .koudoku {
+				margin-left: auto;
+			}
+		}
+	}
+}
+
+.sdrarzaf {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: 12px;
+	margin: var(--margin);
+
+	> .post {
+
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
new file mode 100644
index 0000000000..a2a1254360
--- /dev/null
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -0,0 +1,258 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32">
+		<div v-if="tab === 'overview'" class="_formRoot">
+			<div class="fnfelxur">
+				<img :src="faviconUrl" alt="" class="icon"/>
+				<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
+			</div>
+			<MkKeyValue :copy="host" oneline style="margin: 1em 0;">
+				<template #key>Host</template>
+				<template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
+			</MkKeyValue>
+			<MkKeyValue oneline style="margin: 1em 0;">
+				<template #key>{{ i18n.ts.software }}</template>
+				<template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template>
+			</MkKeyValue>
+			<MkKeyValue oneline style="margin: 1em 0;">
+				<template #key>{{ i18n.ts.administrator }}</template>
+				<template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template>
+			</MkKeyValue>
+			<MkKeyValue>
+				<template #key>{{ i18n.ts.description }}</template>
+				<template #value>{{ instance.description }}</template>
+			</MkKeyValue>
+
+			<FormSection v-if="iAmModerator">
+				<template #label>Moderation</template>
+				<FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch>
+				<FormSwitch v-model="isBlocked" class="_formBlock" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch>
+				<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
+			</FormSection>
+
+			<FormSection>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.registeredAt }}</template>
+					<template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.updatedAt }}</template>
+					<template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.latestRequestSentAt }}</template>
+					<template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.latestStatus }}</template>
+					<template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>{{ i18n.ts.latestRequestReceivedAt }}</template>
+					<template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+				</MkKeyValue>
+			</FormSection>
+	
+			<FormSection>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>Following (Pub)</template>
+					<template #value>{{ number(instance.followingCount) }}</template>
+				</MkKeyValue>
+				<MkKeyValue oneline style="margin: 1em 0;">
+					<template #key>Followers (Sub)</template>
+					<template #value>{{ number(instance.followersCount) }}</template>
+				</MkKeyValue>
+			</FormSection>
+
+			<FormSection>
+				<template #label>Well-known resources</template>
+				<FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
+				<FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
+				<FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
+				<FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
+				<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
+			</FormSection>
+		</div>
+		<div v-else-if="tab === 'chart'" class="_formRoot">
+			<div class="cmhjzshl">
+				<div class="selects">
+					<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
+						<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
+						<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
+						<option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option>
+						<option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option>
+						<option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option>
+						<option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option>
+						<option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option>
+						<option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option>
+						<option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option>
+						<option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option>
+						<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
+					</MkSelect>
+				</div>
+				<div class="charts">
+					<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+					<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+					<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+					<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+				</div>
+			</div>
+		</div>
+		<div v-else-if="tab === 'users'" class="_formRoot">
+			<MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
+				<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
+					<MkUserCardMini :user="user"/>
+				</MkA>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'raw'" class="_formRoot">
+			<MkObjectView tall :value="instance">
+			</MkObjectView>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import MkChart from '@/components/MkChart.vue';
+import MkObjectView from '@/components/MkObjectView.vue';
+import FormLink from '@/components/form/link.vue';
+import MkLink from '@/components/MkLink.vue';
+import MkButton from '@/components/MkButton.vue';
+import FormSection from '@/components/form/section.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkSelect from '@/components/form/select.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import { iAmModerator } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const props = defineProps<{
+	host: string;
+}>();
+
+let tab = $ref('overview');
+let chartSrc = $ref('instance-requests');
+let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null);
+let instance = $ref<misskey.entities.Instance | null>(null);
+let suspended = $ref(false);
+let isBlocked = $ref(false);
+let faviconUrl = $ref(null);
+
+const usersPagination = {
+	endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
+	limit: 10,
+	params: {
+		sort: '+updatedAt',
+		state: 'all',
+		hostname: props.host,
+	},
+	offsetMode: true,
+};
+
+async function fetch() {
+	instance = await os.api('federation/show-instance', {
+		host: props.host,
+	});
+	suspended = instance.isSuspended;
+	isBlocked = instance.isBlocked;
+	faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
+}
+
+async function toggleBlock(ev) {
+	if (meta == null) return;
+	await os.api('admin/update-meta', {
+		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
+	});
+}
+
+async function toggleSuspend(v) {
+	await os.api('admin/federation/update-instance', {
+		host: instance.host,
+		isSuspended: suspended,
+	});
+}
+
+function refreshMetadata() {
+	os.api('admin/federation/refresh-remote-instance-metadata', {
+		host: instance.host,
+	});
+	os.alert({
+		text: 'Refresh requested',
+	});
+}
+
+fetch();
+
+const headerActions = $computed(() => [{
+	text: `https://${props.host}`,
+	icon: 'ti ti-external-link',
+	handler: () => {
+		window.open(`https://${props.host}`, '_blank');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'overview',
+	title: i18n.ts.overview,
+	icon: 'ti ti-info-circle',
+}, {
+	key: 'chart',
+	title: i18n.ts.charts,
+	icon: 'ti ti-chart-line',
+}, {
+	key: 'users',
+	title: i18n.ts.users,
+	icon: 'ti ti-users',
+}, {
+	key: 'raw',
+	title: 'Raw',
+	icon: 'ti ti-code',
+}]);
+
+definePageMetadata({
+	title: props.host,
+	icon: 'ti ti-server',
+});
+</script>
+
+<style lang="scss" scoped>
+.fnfelxur {
+	display: flex;
+	align-items: center;
+
+	> .icon {
+		display: block;
+		margin: 0 16px 0 0;
+		height: 64px;
+		border-radius: 8px;
+	}
+
+	> .name {
+		word-break: break-all;
+	}
+}
+
+.cmhjzshl {
+	> .selects {
+		display: flex;
+		margin: 0 0 16px 0;
+	}
+
+	> .charts {
+		> .label {
+			margin-bottom: 12px;
+			font-weight: bold;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue
new file mode 100644
index 0000000000..0d30998330
--- /dev/null
+++ b/packages/frontend/src/pages/messaging/index.vue
@@ -0,0 +1,327 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-size="{ max: [400] }" class="yweeujhr">
+			<MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton>
+
+			<div v-if="messages.length > 0" class="history">
+				<MkA
+					v-for="(message, i) in messages"
+					:key="message.id"
+					v-anim="i"
+					class="message _block"
+					:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
+					:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+					:data-index="i"
+				>
+					<div>
+						<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
+						<header v-if="message.groupId">
+							<span class="name">{{ message.group.name }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<header v-else>
+							<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
+							<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<div class="body">
+							<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+						</div>
+					</div>
+				</MkA>
+			</div>
+			<div v-if="!fetching && messages.length == 0" class="_fullinfo">
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+				<div>{{ $ts.noHistory }}</div>
+			</div>
+			<MkLoading v-if="fetching"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkButton from '@/components/MkButton.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+const router = useRouter();
+
+let fetching = $ref(true);
+let moreFetching = $ref(false);
+let messages = $ref([]);
+let connection = $ref(null);
+
+const getAcct = Acct.toString;
+
+function isMe(message) {
+	return message.userId === $i.id;
+}
+
+function onMessage(message) {
+	if (message.recipientId) {
+		messages = messages.filter(m => !(
+			(m.recipientId === message.recipientId && m.userId === message.userId) ||
+			(m.recipientId === message.userId && m.userId === message.recipientId)));
+
+		messages.unshift(message);
+	} else if (message.groupId) {
+		messages = messages.filter(m => m.groupId !== message.groupId);
+		messages.unshift(message);
+	}
+}
+
+function onRead(ids) {
+	for (const id of ids) {
+		const found = messages.find(m => m.id === id);
+		if (found) {
+			if (found.recipientId) {
+				found.isRead = true;
+			} else if (found.groupId) {
+				found.reads.push($i.id);
+			}
+		}
+	}
+}
+
+function start(ev) {
+	os.popupMenu([{
+		text: i18n.ts.messagingWithUser,
+		icon: 'ti ti-user',
+		action: () => { startUser(); },
+	}, {
+		text: i18n.ts.messagingWithGroup,
+		icon: 'ti ti-users',
+		action: () => { startGroup(); },
+	}], ev.currentTarget ?? ev.target);
+}
+
+async function startUser() {
+	os.selectUser().then(user => {
+		router.push(`/my/messaging/${Acct.toString(user)}`);
+	});
+}
+
+async function startGroup() {
+	const groups1 = await os.api('users/groups/owned');
+	const groups2 = await os.api('users/groups/joined');
+	if (groups1.length === 0 && groups2.length === 0) {
+		os.alert({
+			type: 'warning',
+			title: i18n.ts.youHaveNoGroups,
+			text: i18n.ts.joinOrCreateGroup,
+		});
+		return;
+	}
+	const { canceled, result: group } = await os.select({
+		title: i18n.ts.group,
+		items: groups1.concat(groups2).map(group => ({
+			value: group, text: group.name,
+		})),
+	});
+	if (canceled) return;
+	router.push(`/my/messaging/group/${group.id}`);
+}
+
+onMounted(() => {
+	connection = markRaw(stream.useChannel('messagingIndex'));
+
+	connection.on('message', onMessage);
+	connection.on('read', onRead);
+
+	os.api('messaging/history', { group: false }).then(userMessages => {
+		os.api('messaging/history', { group: true }).then(groupMessages => {
+			const _messages = userMessages.concat(groupMessages);
+			_messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+			messages = _messages;
+			fetching = false;
+		});
+	});
+});
+
+onUnmounted(() => {
+	if (connection) connection.dispose();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.messaging,
+	icon: 'ti ti-messages',
+});
+</script>
+
+<style lang="scss" scoped>
+.yweeujhr {
+
+	> .start {
+		margin: 0 auto var(--margin) auto;
+	}
+
+	> .history {
+		> .message {
+			display: block;
+			text-decoration: none;
+			margin-bottom: var(--margin);
+
+			* {
+				pointer-events: none;
+				user-select: none;
+			}
+
+			&:hover {
+				.avatar {
+					filter: saturate(200%);
+				}
+			}
+
+			&:active {
+			}
+
+			&.isRead,
+			&.isMe {
+				opacity: 0.8;
+			}
+
+			&:not(.isMe):not(.isRead) {
+				> div {
+					background-image: url("/client-assets/unread.svg");
+					background-repeat: no-repeat;
+					background-position: 0 center;
+				}
+			}
+
+			&:after {
+				content: "";
+				display: block;
+				clear: both;
+			}
+
+			> div {
+				padding: 20px 30px;
+
+				&:after {
+					content: "";
+					display: block;
+					clear: both;
+				}
+
+				> header {
+					display: flex;
+					align-items: center;
+					margin-bottom: 2px;
+					white-space: nowrap;
+					overflow: hidden;
+
+					> .name {
+						margin: 0;
+						padding: 0;
+						overflow: hidden;
+						text-overflow: ellipsis;
+						font-size: 1em;
+						font-weight: bold;
+						transition: all 0.1s ease;
+					}
+
+					> .username {
+						margin: 0 8px;
+					}
+
+					> .time {
+						margin: 0 0 0 auto;
+					}
+				}
+
+				> .avatar {
+					float: left;
+					width: 54px;
+					height: 54px;
+					margin: 0 16px 0 0;
+					border-radius: 8px;
+					transition: all 0.1s ease;
+				}
+
+				> .body {
+
+					> .text {
+						display: block;
+						margin: 0 0 0 0;
+						padding: 0;
+						overflow: hidden;
+						overflow-wrap: break-word;
+						font-size: 1.1em;
+						color: var(--faceText);
+
+						.me {
+							opacity: 0.7;
+						}
+					}
+
+					> .image {
+						display: block;
+						max-width: 100%;
+						max-height: 512px;
+					}
+				}
+			}
+		}
+	}
+
+	&.max-width_400px {
+		> .history {
+			> .message {
+				&:not(.isMe):not(.isRead) {
+					> div {
+						background-image: none;
+						border-left: solid 4px #3aa2dc;
+					}
+				}
+
+				> div {
+					padding: 16px;
+					font-size: 0.9em;
+
+					> .avatar {
+						margin: 0 12px 0 0;
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 400px) {
+	.yweeujhr {
+		> .history {
+			> .message {
+				&:not(.isMe):not(.isRead) {
+					> div {
+						background-image: none;
+						border-left: solid 4px #3aa2dc;
+					}
+				}
+
+				> div {
+					padding: 16px;
+					font-size: 0.9em;
+
+					> .avatar {
+						margin: 0 12px 0 0;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue
new file mode 100644
index 0000000000..84572815c0
--- /dev/null
+++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue
@@ -0,0 +1,364 @@
+<template>
+<div
+	class="pemppnzi _block"
+	@dragover.stop="onDragover"
+	@drop.stop="onDrop"
+>
+	<textarea
+		ref="textEl"
+		v-model="text"
+		:placeholder="i18n.ts.inputMessageHere"
+		@keydown="onKeydown"
+		@compositionupdate="onCompositionUpdate"
+		@paste="onPaste"
+	></textarea>
+	<footer>
+		<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
+		<div class="buttons">
+			<button class="_button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
+			<button class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
+			<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
+				<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
+			</button>
+		</div>
+	</footer>
+	<input ref="fileEl" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import autosize from 'autosize';
+//import insertTextAtCursor from 'insert-text-at-cursor';
+import { throttle } from 'throttle-debounce';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+//import { Autocomplete } from '@/scripts/autocomplete';
+import { uploadFile } from '@/scripts/upload';
+
+const props = defineProps<{
+	user?: Misskey.entities.UserDetailed | null;
+	group?: Misskey.entities.UserGroup | null;
+}>();
+
+let textEl = $ref<HTMLTextAreaElement>();
+let fileEl = $ref<HTMLInputElement>();
+
+let text = $ref<string>('');
+let file = $ref<Misskey.entities.DriveFile | null>(null);
+let sending = $ref(false);
+const typing = throttle(3000, () => {
+	stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
+});
+
+let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
+let canSend = $computed(() => (text != null && text !== '') || file != null);
+
+watch([$$(text), $$(file)], saveDraft);
+
+async function onPaste(ev: ClipboardEvent) {
+	if (!ev.clipboardData) return;
+
+	const clipboardData = ev.clipboardData;
+	const items = clipboardData.items;
+
+	if (items.length === 1) {
+		if (items[0].kind === 'file') {
+			const pastedFile = items[0].getAsFile();
+			if (!pastedFile) return;
+			const lio = pastedFile.name.lastIndexOf('.');
+			const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
+			const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
+			if (formatted) upload(pastedFile, formatted);
+		}
+	} else {
+		if (items[0].kind === 'file') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.onlyOneFileCanBeAttached,
+			});
+		}
+	}
+}
+
+function onDragover(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	if (isFile || isDriveFile) {
+		ev.preventDefault();
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	}
+}
+
+function onDrop(ev: DragEvent): void {
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length === 1) {
+		ev.preventDefault();
+		upload(ev.dataTransfer.files[0]);
+		return;
+	} else if (ev.dataTransfer.files.length > 1) {
+		ev.preventDefault();
+		os.alert({
+			type: 'error',
+			text: i18n.ts.onlyOneFileCanBeAttached,
+		});
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		file = JSON.parse(driveFile);
+		ev.preventDefault();
+	}
+	//#endregion
+}
+
+function onKeydown(ev: KeyboardEvent) {
+	typing();
+	if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
+		send();
+	}
+}
+
+function onCompositionUpdate() {
+	typing();
+}
+
+function chooseFile(ev: MouseEvent) {
+	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+		file = selectedFile;
+	});
+}
+
+function onChangeFile() {
+	if (fileEl.files![0]) upload(fileEl.files[0]);
+}
+
+function upload(fileToUpload: File, name?: string) {
+	uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
+		file = res;
+	});
+}
+
+function send() {
+	sending = true;
+	os.api('messaging/messages/create', {
+		userId: props.user ? props.user.id : undefined,
+		groupId: props.group ? props.group.id : undefined,
+		text: text ? text : undefined,
+		fileId: file ? file.id : undefined,
+	}).then(message => {
+		clear();
+	}).catch(err => {
+		console.error(err);
+	}).then(() => {
+		sending = false;
+	});
+}
+
+function clear() {
+	text = '';
+	file = null;
+	deleteDraft();
+}
+
+function saveDraft() {
+	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+	drafts[draftKey] = {
+		updatedAt: new Date(),
+		// eslint-disable-next-line id-denylist
+		data: {
+			text: text,
+			file: file,
+		},
+	};
+
+	localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
+
+function deleteDraft() {
+	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+	delete drafts[draftKey];
+
+	localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
+
+async function insertEmoji(ev: MouseEvent) {
+	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
+}
+
+onMounted(() => {
+	autosize(textEl);
+
+	// TODO: detach when unmount
+	// TODO
+	//new Autocomplete(textEl, this, { model: 'text' });
+
+	// 書きかけの投稿を復元
+	const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
+	if (draft) {
+		text = draft.data.text;
+		file = draft.data.file;
+	}
+});
+
+defineExpose({
+	file,
+	upload,
+});
+</script>
+
+<style lang="scss" scoped>
+.pemppnzi {
+	position: relative;
+
+	> textarea {
+		cursor: auto;
+		display: block;
+		width: 100%;
+		min-width: 100%;
+		max-width: 100%;
+		min-height: 80px;
+		margin: 0;
+		padding: 16px 16px 0 16px;
+		resize: none;
+		font-size: 1em;
+		font-family: inherit;
+		outline: none;
+		border: none;
+		border-radius: 0;
+		box-shadow: none;
+		background: transparent;
+		box-sizing: border-box;
+		color: var(--fg);
+	}
+
+	footer {
+		position: sticky;
+		bottom: 0;
+		background: var(--panel);
+
+		> .file {
+			padding: 8px;
+			color: var(--fg);
+			background: transparent;
+			cursor: pointer;
+		}
+	}
+
+	.files {
+		display: block;
+		margin: 0;
+		padding: 0 8px;
+		list-style: none;
+
+		&:after {
+			content: '';
+			display: block;
+			clear: both;
+		}
+
+		> li {
+			display: block;
+			float: left;
+			margin: 4px;
+			padding: 0;
+			width: 64px;
+			height: 64px;
+			background-color: #eee;
+			background-repeat: no-repeat;
+			background-position: center center;
+			background-size: cover;
+			cursor: move;
+
+			&:hover {
+				> .remove {
+					display: block;
+				}
+			}
+
+			> .remove {
+				display: none;
+				position: absolute;
+				right: -6px;
+				top: -6px;
+				margin: 0;
+				padding: 0;
+				background: transparent;
+				outline: none;
+				border: none;
+				border-radius: 0;
+				box-shadow: none;
+				cursor: pointer;
+			}
+		}
+	}
+
+	.buttons {
+		display: flex;
+
+		._button {
+			margin: 0;
+			padding: 16px;
+			font-size: 1em;
+			font-weight: normal;
+			text-decoration: none;
+			transition: color 0.1s ease;
+
+			&:hover {
+				color: var(--accent);
+			}
+
+			&:active {
+				color: var(--accentDarken);
+				transition: color 0s ease;
+			}
+		}
+
+		> .send {
+			margin-left: auto;
+			color: var(--accent);
+
+			&:hover {
+				color: var(--accentLighten);
+			}
+
+			&:active {
+				color: var(--accentDarken);
+				transition: color 0s ease;
+			}
+		}
+	}
+
+	input[type=file] {
+		display: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/messaging/messaging-room.message.vue b/packages/frontend/src/pages/messaging/messaging-room.message.vue
new file mode 100644
index 0000000000..dbf0e37b73
--- /dev/null
+++ b/packages/frontend/src/pages/messaging/messaging-room.message.vue
@@ -0,0 +1,367 @@
+<template>
+<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }">
+	<MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
+	<div class="content">
+		<div class="balloon" :class="{ noText: message.text == null }">
+			<button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del">
+				<img src="/client-assets/remove.png" alt="Delete"/>
+			</button>
+			<div v-if="!message.isDeleted" class="content">
+				<Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/>
+				<div v-if="message.file" class="file">
+					<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
+						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
+						<p v-else>{{ message.file.name }}</p>
+					</a>
+				</div>
+			</div>
+			<div v-else class="content">
+				<p class="is-deleted">{{ $ts.deleted }}</p>
+			</div>
+		</div>
+		<div></div>
+		<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
+		<footer>
+			<template v-if="isGroup">
+				<span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span>
+			</template>
+			<template v-else>
+				<span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span>
+			</template>
+			<MkTime :time="message.createdAt"/>
+			<template v-if="message.is_edited"><i class="ti ti-pencil"></i></template>
+		</footer>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+
+const props = defineProps<{
+	message: Misskey.entities.MessagingMessage;
+	isGroup?: boolean;
+}>();
+
+const isMe = $computed(() => props.message.userId === $i?.id);
+const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
+
+function del(): void {
+	os.api('messaging/messages/delete', {
+		messageId: props.message.id,
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.thvuemwp {
+	$me-balloon-color: var(--accent);
+
+	position: relative;
+	background-color: transparent;
+	display: flex;
+
+	> .avatar {
+		position: sticky;
+		top: calc(var(--stickyTop, 0px) + 16px);
+		display: block;
+		width: 54px;
+		height: 54px;
+		transition: all 0.1s ease;
+	}
+
+	> .content {
+		min-width: 0;
+
+		> .balloon {
+			position: relative;
+			display: inline-flex;
+			align-items: center;
+			padding: 0;
+			min-height: 38px;
+			border-radius: 16px;
+			max-width: 100%;
+
+			&:before {
+				content: "";
+				pointer-events: none;
+				display: block;
+				position: absolute;
+				top: 12px;
+			}
+
+			& + * {
+				clear: both;
+			}
+
+			&:hover {
+				> .delete-button {
+					display: block;
+				}
+			}
+
+			> .delete-button {
+				display: none;
+				position: absolute;
+				z-index: 1;
+				top: -4px;
+				right: -4px;
+				margin: 0;
+				padding: 0;
+				cursor: pointer;
+				outline: none;
+				border: none;
+				border-radius: 0;
+				box-shadow: none;
+				background: transparent;
+
+				> img {
+					vertical-align: bottom;
+					width: 16px;
+					height: 16px;
+					cursor: pointer;
+				}
+			}
+
+			> .content {
+				max-width: 100%;
+
+				> .is-deleted {
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow: hidden;
+					overflow-wrap: break-word;
+					font-size: 1em;
+					color: rgba(#000, 0.5);
+				}
+
+				> .text {
+					display: block;
+					margin: 0;
+					padding: 12px 18px;
+					overflow: hidden;
+					overflow-wrap: break-word;
+					word-break: break-word;
+					font-size: 1em;
+					color: rgba(#000, 0.8);
+
+					& + .file {
+						> a {
+							border-radius: 0 0 16px 16px;
+						}
+					}
+				}
+
+				> .file {
+					> a {
+						display: block;
+						max-width: 100%;
+						border-radius: 16px;
+						overflow: hidden;
+						text-decoration: none;
+
+						&:hover {
+							text-decoration: none;
+
+							> p {
+								background: #ccc;
+							}
+						}
+
+						> * {
+							display: block;
+							margin: 0;
+							width: 100%;
+							max-height: 512px;
+							object-fit: contain;
+							box-sizing: border-box;
+						}
+
+						> p {
+							padding: 30px;
+							text-align: center;
+							color: #555;
+							background: #ddd;
+						}
+					}
+				}
+			}
+		}
+
+		> footer {
+			display: block;
+			margin: 2px 0 0 0;
+			font-size: 0.65em;
+
+			> .read {
+				margin: 0 8px;
+			}
+
+			> i {
+				margin-left: 4px;
+			}
+		}
+	}
+
+	&:not(.isMe) {
+		padding-left: var(--margin);
+
+		> .content {
+			padding-left: 16px;
+			padding-right: 32px;
+
+			> .balloon {
+				$color: var(--messageBg);
+				background: $color;
+
+				&.noText {
+					background: transparent;
+				}
+
+				&:not(.noText):before {
+					left: -14px;
+					border-top: solid 8px transparent;
+					border-right: solid 8px $color;
+					border-bottom: solid 8px transparent;
+					border-left: solid 8px transparent;
+				}
+
+				> .content {
+					> .text {
+						color: var(--fg);
+					}
+				}
+			}
+
+			> footer {
+				text-align: left;
+			}
+		}
+	}
+
+	&.isMe {
+		flex-direction: row-reverse;
+		padding-right: var(--margin);
+		right: var(--margin); // 削除時にposition: absoluteになったときに使う
+
+		> .content {
+			padding-right: 16px;
+			padding-left: 32px;
+			text-align: right;
+
+			> .balloon {
+				background: $me-balloon-color;
+				text-align: left;
+
+				::selection {
+					color: var(--accent);
+					background-color: #fff;
+				} 
+
+				&.noText {
+					background: transparent;
+				}
+
+				&:not(.noText):before {
+					right: -14px;
+					left: auto;
+					border-top: solid 8px transparent;
+					border-right: solid 8px transparent;
+					border-bottom: solid 8px transparent;
+					border-left: solid 8px $me-balloon-color;
+				}
+
+				> .content {
+
+					> p.is-deleted {
+						color: rgba(#fff, 0.5);
+					}
+
+					> .text {
+						&, ::v-deep(*) {
+							color: var(--fgOnAccent) !important;
+						}
+					}
+				}
+			}
+
+			> footer {
+				text-align: right;
+
+				> .read {
+					user-select: none;
+				}
+			}
+		}
+	}
+
+	&.max-width_400px {
+		> .avatar {
+			width: 48px;
+			height: 48px;
+		}
+
+		> .content {
+			> .balloon {
+				> .content {
+					> .text {
+						font-size: 0.9em;
+					}
+				}
+			}
+		}
+	}
+
+	&.max-width_500px {
+		> .content {
+			> .balloon {
+				> .content {
+					> .text {
+						padding: 8px 16px;
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 400px) {
+	.thvuemwp {
+		> .avatar {
+			width: 48px;
+			height: 48px;
+		}
+
+		> .content {
+			> .balloon {
+				> .content {
+					> .text {
+						font-size: 0.9em;
+					}
+				}
+			}
+		}
+	}
+}
+
+@container (max-width: 500px) {
+	.thvuemwp {
+		> .content {
+			> .balloon {
+				> .content {
+					> .text {
+						padding: 8px 16px;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue
new file mode 100644
index 0000000000..b6eeb9260e
--- /dev/null
+++ b/packages/frontend/src/pages/messaging/messaging-room.vue
@@ -0,0 +1,411 @@
+<template>
+<div
+	ref="rootEl"
+	class="_section"
+	@dragover.prevent.stop="onDragover"
+	@drop.prevent.stop="onDrop"
+>
+	<div class="_content mk-messaging-room">
+		<div class="body">
+			<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
+				<template #empty>
+					<div class="_fullinfo">
+						<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+						<div>{{ i18n.ts.noMessagesYet }}</div>
+					</div>
+				</template>
+
+				<template #default="{ items: messages, fetching: pFetching }">
+					<XList
+						v-if="messages.length > 0"
+						v-slot="{ item: message }"
+						:class="{ messages: true, 'deny-move-transition': pFetching }"
+						:items="messages"
+						direction="up"
+						reversed
+					>
+						<XMessage :key="message.id" :message="message" :is-group="group != null"/>
+					</XList>
+				</template>
+			</MkPagination>
+		</div>
+		<footer>
+			<div v-if="typers.length > 0" class="typers">
+				<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
+					<template #users>
+						<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
+					</template>
+				</I18n>
+				<MkEllipsis/>
+			</div>
+			<transition :name="animation ? 'fade' : ''">
+				<div v-show="showIndicator" class="new-message">
+					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
+				</div>
+			</transition>
+			<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
+		</footer>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import XMessage from './messaging-room.message.vue';
+import XForm from './messaging-room.form.vue';
+import XList from '@/components/MkDateSeparatedList.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import * as sound from '@/scripts/sound';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	userAcct?: string;
+	groupId?: string;
+}>();
+
+let rootEl = $ref<HTMLDivElement>();
+let formEl = $ref<InstanceType<typeof XForm>>();
+let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
+
+let fetching = $ref(true);
+let user: Misskey.entities.UserDetailed | null = $ref(null);
+let group: Misskey.entities.UserGroup | null = $ref(null);
+let typers: Misskey.entities.User[] = $ref([]);
+let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
+let showIndicator = $ref(false);
+const {
+	animation,
+} = defaultStore.reactiveState;
+
+let pagination: Paging | null = $ref(null);
+
+watch([() => props.userAcct, () => props.groupId], () => {
+	if (connection) connection.dispose();
+	fetch();
+});
+
+async function fetch() {
+	fetching = true;
+
+	if (props.userAcct) {
+		const acct = Acct.parse(props.userAcct);
+		user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
+		group = null;
+		
+		pagination = {
+			endpoint: 'messaging/messages',
+			limit: 20,
+			params: {
+				userId: user.id,
+			},
+			reversed: true,
+			pageEl: $$(rootEl).value,
+		};
+		connection = stream.useChannel('messaging', {
+			otherparty: user.id,
+		});
+	} else {
+		user = null;
+		group = await os.api('users/groups/show', { groupId: props.groupId });
+
+		pagination = {
+			endpoint: 'messaging/messages',
+			limit: 20,
+			params: {
+				groupId: group?.id,
+			},
+			reversed: true,
+			pageEl: $$(rootEl).value,
+		};
+		connection = stream.useChannel('messaging', {
+			group: group?.id,
+		});
+	}
+
+	connection.on('message', onMessage);
+	connection.on('read', onRead);
+	connection.on('deleted', onDeleted);
+	connection.on('typers', _typers => {
+		typers = _typers.filter(u => u.id !== $i?.id);
+	});
+
+	document.addEventListener('visibilitychange', onVisibilitychange);
+
+	nextTick(() => {
+		thisScrollToBottom();
+		window.setTimeout(() => {
+			fetching = false;
+		}, 300);
+	});
+}
+
+function onDragover(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+
+	if (isFile || isDriveFile) {
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy': 
+			case 'copyLink': 
+			case 'copyMove': 
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	} else {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+}
+
+function onDrop(ev: DragEvent): void {
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length === 1) {
+		formEl.upload(ev.dataTransfer.files[0]);
+		return;
+	} else if (ev.dataTransfer.files.length > 1) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts.onlyOneFileCanBeAttached,
+		});
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		formEl.file = file;
+	}
+	//#endregion
+}
+
+function onMessage(message) {
+	sound.play('chat');
+
+	const _isBottom = isBottomVisible(rootEl, 64);
+
+	pagingComponent.prepend(message);
+	if (message.userId !== $i?.id && !document.hidden) {
+		connection?.send('read', {
+			id: message.id,
+		});
+	}
+
+	if (_isBottom) {
+		// Scroll to bottom
+		nextTick(() => {
+			thisScrollToBottom();
+		});
+	} else if (message.userId !== $i?.id) {
+		// Notify
+		notifyNewMessage();
+	}
+}
+
+function onRead(x) {
+	if (user) {
+		if (!Array.isArray(x)) x = [x];
+		for (const id of x) {
+			if (pagingComponent.items.some(y => y.id === id)) {
+				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+				pagingComponent.items[exist] = {
+					...pagingComponent.items[exist],
+					isRead: true,
+				};
+			}
+		}
+	} else if (group) {
+		for (const id of x.ids) {
+			if (pagingComponent.items.some(y => y.id === id)) {
+				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+				pagingComponent.items[exist] = {
+					...pagingComponent.items[exist],
+					reads: [...pagingComponent.items[exist].reads, x.userId],
+				};
+			}
+		}
+	}
+}
+
+function onDeleted(id) {
+	const msg = pagingComponent.items.find(m => m.id === id);
+	if (msg) {
+		pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
+	}
+}
+
+function thisScrollToBottom() {
+	scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
+}
+
+function onIndicatorClick() {
+	showIndicator = false;
+	thisScrollToBottom();
+}
+
+let scrollRemove: (() => void) | null = $ref(null);
+
+function notifyNewMessage() {
+	showIndicator = true;
+
+	scrollRemove = onScrollBottom(rootEl, () => {
+		showIndicator = false;
+		scrollRemove = null;
+	});
+}
+
+function onVisibilitychange() {
+	if (document.hidden) return;
+	for (const message of pagingComponent.items) {
+		if (message.userId !== $i?.id && !message.isRead) {
+			connection?.send('read', {
+				id: message.id,
+			});
+		}
+	}
+}
+
+onMounted(() => {
+	fetch();
+});
+
+onBeforeUnmount(() => {
+	connection?.dispose();
+	document.removeEventListener('visibilitychange', onVisibilitychange);
+	if (scrollRemove) scrollRemove();
+});
+
+definePageMetadata(computed(() => !fetching ? user ? {
+	userName: user,
+	avatar: user,
+} : {
+	title: group?.name,
+	icon: 'ti ti-users',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging-room {
+	position: relative;
+	overflow: auto;
+
+	> .body {
+		.more {
+			display: block;
+			margin: 16px auto;
+			padding: 0 12px;
+			line-height: 24px;
+			color: #fff;
+			background: rgba(#000, 0.3);
+			border-radius: 12px;
+
+			&:hover {
+				background: rgba(#000, 0.4);
+			}
+
+			&:active {
+				background: rgba(#000, 0.5);
+			}
+
+			&.fetching {
+				cursor: wait;
+			}
+
+			> i {
+				margin-right: 4px;
+			}
+		}
+
+		.messages {
+			padding: 8px 0;
+
+			> ::v-deep(*) {
+				margin-bottom: 16px;
+			}
+		}
+	}
+
+	> footer {
+		width: 100%;
+		position: sticky;
+		z-index: 2;
+		bottom: 0;
+		padding-top: 8px;
+		bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
+
+		> .new-message {
+			width: 100%;
+			padding-bottom: 8px;
+			text-align: center;
+
+			> button {
+				display: inline-block;
+				margin: 0;
+				padding: 0 12px;
+				line-height: 32px;
+				font-size: 12px;
+				border-radius: 16px;
+
+				> i {
+					display: inline-block;
+					margin-right: 8px;
+				}
+			}
+		}
+
+		> .typers {
+			position: absolute;
+			bottom: 100%;
+			padding: 0 8px 0 8px;
+			font-size: 0.9em;
+			color: var(--fgTransparentWeak);
+
+			> .users {
+				> .user + .user:before {
+					content: ", ";
+					font-weight: normal;
+				}
+
+				> .user:last-of-type:after {
+					content: " ";
+				}
+			}
+		}
+
+		> .form {
+			max-height: 12em;
+			overflow-y: scroll;
+			border-top: solid 0.5px var(--divider);
+		}
+	}
+}
+
+.fade-enter-active, .fade-leave-active {
+	transition: opacity 0.1s;
+}
+
+.fade-enter-from, .fade-leave-to {
+	transition: opacity 0.5s;
+	opacity: 0;
+}
+</style>
diff --git a/packages/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/mfm-cheat-sheet.vue
new file mode 100644
index 0000000000..7c85dfb7ad
--- /dev/null
+++ b/packages/frontend/src/pages/mfm-cheat-sheet.vue
@@ -0,0 +1,387 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<div class="mwysmxbg">
+			<div>{{ i18n.ts._mfm.intro }}</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.mention }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.mentionDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_mention"/>
+						<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.hashtag }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.hashtagDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_hashtag"/>
+						<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.url }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.urlDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_url"/>
+						<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.link }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.linkDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_link"/>
+						<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.emoji }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.emojiDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_emoji"/>
+						<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.bold }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.boldDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_bold"/>
+						<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.small }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.smallDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_small"/>
+						<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.quote }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.quoteDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_quote"/>
+						<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.center }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.centerDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_center"/>
+						<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.inlineCode }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.inlineCodeDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_inlineCode"/>
+						<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.blockCode }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.blockCodeDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_blockCode"/>
+						<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.inlineMath }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.inlineMathDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_inlineMath"/>
+						<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<!-- deprecated
+		<div class="section _block">
+			<div class="title">{{ i18n.ts._mfm.search }}</div>
+			<div class="content">
+				<p>{{ i18n.ts._mfm.searchDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_search"/>
+					<MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		-->
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.flip }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.flipDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_flip"/>
+						<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.font }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.fontDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_font"/>
+						<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.x2 }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.x2Description }}</p>
+					<div class="preview">
+						<Mfm :text="preview_x2"/>
+						<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.x3 }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.x3Description }}</p>
+					<div class="preview">
+						<Mfm :text="preview_x3"/>
+						<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.x4 }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.x4Description }}</p>
+					<div class="preview">
+						<Mfm :text="preview_x4"/>
+						<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.blur }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.blurDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_blur"/>
+						<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.jelly }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.jellyDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_jelly"/>
+						<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.tada }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.tadaDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_tada"/>
+						<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.jump }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.jumpDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_jump"/>
+						<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.bounce }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.bounceDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_bounce"/>
+						<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.spin }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.spinDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_spin"/>
+						<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.shake }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.shakeDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_shake"/>
+						<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.twitch }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.twitchDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_twitch"/>
+						<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.rainbow }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.rainbowDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_rainbow"/>
+						<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.sparkle }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.sparkleDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_sparkle"/>
+						<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.rotate }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.rotateDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_rotate"/>
+						<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+					</div>
+				</div>
+			</div>
+			<div class="section _block">
+				<div class="title">{{ i18n.ts._mfm.plain }}</div>
+				<div class="content">
+					<p>{{ i18n.ts._mfm.plainDescription }}</p>
+					<div class="preview">
+						<Mfm :text="preview_plain"/>
+						<MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea>
+					</div>
+				</div>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+
+let preview_mention = $ref('@example');
+let preview_hashtag = $ref('#test');
+let preview_url = $ref('https://example.com');
+let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`);
+let preview_emoji = $ref(instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:');
+let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`);
+let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`);
+let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`);
+let preview_inlineCode = $ref('`<: "Hello, world!"`');
+let preview_blockCode = $ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```');
+let preview_inlineMath = $ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)');
+let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`);
+let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`);
+let preview_jelly = $ref('$[jelly 🍮] $[jelly.speed=5s 🍮]');
+let preview_tada = $ref('$[tada 🍮] $[tada.speed=5s 🍮]');
+let preview_jump = $ref('$[jump 🍮] $[jump.speed=5s 🍮]');
+let preview_bounce = $ref('$[bounce 🍮] $[bounce.speed=5s 🍮]');
+let preview_shake = $ref('$[shake 🍮] $[shake.speed=5s 🍮]');
+let preview_twitch = $ref('$[twitch 🍮] $[twitch.speed=5s 🍮]');
+let preview_spin = $ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]');
+let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`);
+let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`);
+let preview_x2 = $ref('$[x2 🍮]');
+let preview_x3 = $ref('$[x3 🍮]');
+let preview_x4 = $ref('$[x4 🍮]');
+let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`);
+let preview_rainbow = $ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]');
+let preview_sparkle = $ref('$[sparkle 🍮]');
+let preview_rotate = $ref('$[rotate 🍮]');
+let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>');
+
+definePageMetadata({
+	title: i18n.ts._mfm.cheatSheet,
+	icon: 'ti ti-question-circle',
+});
+</script>
+
+<style lang="scss" scoped>
+.mwysmxbg {
+	background: var(--bg);
+
+	> .section {
+		> .title {
+			position: sticky;
+			z-index: 1;
+			top: var(--stickyTop, 0px);
+			padding: 16px;
+			font-weight: bold;
+			-webkit-backdrop-filter: var(--blur, blur(10px));
+			backdrop-filter: var(--blur, blur(10px));
+			background-color: var(--X16);
+		}
+
+		> .content {
+			> p {
+				margin: 0;
+				padding: 16px;
+			}
+
+			> .preview {
+				border-top: solid 0.5px var(--divider);
+				padding: 16px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue
new file mode 100644
index 0000000000..5de072cbfa
--- /dev/null
+++ b/packages/frontend/src/pages/miauth.vue
@@ -0,0 +1,90 @@
+<template>
+<MkSpacer :content-max="800">
+	<div v-if="$i">
+		<div v-if="state == 'waiting'" class="waiting _section">
+			<div class="_content">
+				<MkLoading/>
+			</div>
+		</div>
+		<div v-if="state == 'denied'" class="denied _section">
+			<div class="_content">
+				<p>{{ i18n.ts._auth.denied }}</p>
+			</div>
+		</div>
+		<div v-else-if="state == 'accepted'" class="accepted _section">
+			<div class="_content">
+				<p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
+				<p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p>
+			</div>
+		</div>
+		<div v-else class="_section">
+			<div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div>
+			<div v-else class="_title">{{ i18n.ts._auth.shareAccessAsk }}</div>
+			<div class="_content">
+				<p>{{ i18n.ts._auth.permissionAsk }}</p>
+				<ul>
+					<li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+				</ul>
+			</div>
+			<div class="_footer">
+				<MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton>
+				<MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton>
+			</div>
+		</div>
+	</div>
+	<div v-else class="signin">
+		<MkSignin @login="onLogin"/>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkSignin from '@/components/MkSignin.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { $i, login } from '@/account';
+import { appendQuery, query } from '@/scripts/url';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	session: string;
+	callback?: string;
+	name: string;
+	icon: string;
+	permission: string; // コンマ区切り
+}>();
+
+const _permissions = props.permission.split(',');
+
+let state = $ref<string | null>(null);
+
+async function accept(): Promise<void> {
+	state = 'waiting';
+	await os.api('miauth/gen-token', {
+		session: props.session,
+		name: props.name,
+		iconUrl: props.icon,
+		permission: _permissions,
+	});
+
+	state = 'accepted';
+	if (props.callback) {
+		location.href = appendQuery(props.callback, query({
+			session: props.session,
+		}));
+	}
+}
+
+function deny(): void {
+	state = 'denied';
+}
+
+function onLogin(res): void {
+	login(res.i);
+}
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
new file mode 100644
index 0000000000..005b036696
--- /dev/null
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -0,0 +1,46 @@
+<template>
+<div class="geegznzt">
+	<XAntenna :antenna="draft" @created="onAntennaCreated"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject } from 'vue';
+import XAntenna from './editor.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { useRouter } from '@/router';
+
+const router = useRouter();
+
+let draft = $ref({
+	name: '',
+	src: 'all',
+	userListId: null,
+	userGroupId: null,
+	users: [],
+	keywords: [],
+	excludeKeywords: [],
+	withReplies: false,
+	caseSensitive: false,
+	withFile: false,
+	notify: false,
+});
+
+function onAntennaCreated() {
+	router.push('/my/antennas');
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'ti ti-antenna',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
new file mode 100644
index 0000000000..cb583faaeb
--- /dev/null
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -0,0 +1,43 @@
+<template>
+<div class="">
+	<XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, watch } from 'vue';
+import XAntenna from './editor.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+let antenna: any = $ref(null);
+
+const props = defineProps<{
+	antennaId: string
+}>();
+
+function onAntennaUpdated() {
+	router.push('/my/antennas');
+}
+
+os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => {
+	antenna = antennaResponse;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'ti ti-antenna',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
new file mode 100644
index 0000000000..a409a734b5
--- /dev/null
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -0,0 +1,155 @@
+<template>
+<div class="shaynizk">
+	<div class="form">
+		<MkInput v-model="name" class="_formBlock">
+			<template #label>{{ i18n.ts.name }}</template>
+		</MkInput>
+		<MkSelect v-model="src" class="_formBlock">
+			<template #label>{{ i18n.ts.antennaSource }}</template>
+			<option value="all">{{ i18n.ts._antennaSources.all }}</option>
+			<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
+			<option value="users">{{ i18n.ts._antennaSources.users }}</option>
+			<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
+			<!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>-->
+		</MkSelect>
+		<MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock">
+			<template #label>{{ i18n.ts.userList }}</template>
+			<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
+		</MkSelect>
+		<MkSelect v-else-if="src === 'group'" v-model="userGroupId" class="_formBlock">
+			<template #label>{{ i18n.ts.userGroup }}</template>
+			<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
+		</MkSelect>
+		<MkTextarea v-else-if="src === 'users'" v-model="users" class="_formBlock">
+			<template #label>{{ i18n.ts.users }}</template>
+			<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
+		</MkTextarea>
+		<MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch>
+		<MkTextarea v-model="keywords" class="_formBlock">
+			<template #label>{{ i18n.ts.antennaKeywords }}</template>
+			<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
+		</MkTextarea>
+		<MkTextarea v-model="excludeKeywords" class="_formBlock">
+			<template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
+			<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
+		</MkTextarea>
+		<MkSwitch v-model="caseSensitive" class="_formBlock">{{ i18n.ts.caseSensitive }}</MkSwitch>
+		<MkSwitch v-model="withFile" class="_formBlock">{{ i18n.ts.withFileAntenna }}</MkSwitch>
+		<MkSwitch v-model="notify" class="_formBlock">{{ i18n.ts.notifyAntenna }}</MkSwitch>
+	</div>
+	<div class="actions">
+		<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+		<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	antenna: any
+}>();
+
+const emit = defineEmits<{
+	(ev: 'created'): void,
+	(ev: 'updated'): void,
+	(ev: 'deleted'): void,
+}>();
+
+let name: string = $ref(props.antenna.name);
+let src: string = $ref(props.antenna.src);
+let userListId: any = $ref(props.antenna.userListId);
+let userGroupId: any = $ref(props.antenna.userGroupId);
+let users: string = $ref(props.antenna.users.join('\n'));
+let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
+let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
+let caseSensitive: boolean = $ref(props.antenna.caseSensitive);
+let withReplies: boolean = $ref(props.antenna.withReplies);
+let withFile: boolean = $ref(props.antenna.withFile);
+let notify: boolean = $ref(props.antenna.notify);
+let userLists: any = $ref(null);
+let userGroups: any = $ref(null);
+
+watch(() => src, async () => {
+	if (src === 'list' && userLists === null) {
+		userLists = await os.api('users/lists/list');
+	}
+
+	if (src === 'group' && userGroups === null) {
+		const groups1 = await os.api('users/groups/owned');
+		const groups2 = await os.api('users/groups/joined');
+
+		userGroups = [...groups1, ...groups2];
+	}
+});
+
+async function saveAntenna() {
+	const antennaData = {
+		name,
+		src,
+		userListId,
+		userGroupId,
+		withReplies,
+		withFile,
+		notify,
+		caseSensitive,
+		users: users.trim().split('\n').map(x => x.trim()),
+		keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')),
+		excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
+	};
+
+	if (props.antenna.id == null) {
+		await os.apiWithDialog('antennas/create', antennaData);
+		emit('created');
+	} else {
+		antennaData['antennaId'] = props.antenna.id;
+		await os.apiWithDialog('antennas/update', antennaData);
+		emit('updated');
+	}
+}
+
+async function deleteAntenna() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: props.antenna.name }),
+	});
+	if (canceled) return;
+
+	await os.api('antennas/delete', {
+		antennaId: props.antenna.id,
+	});
+
+	os.success();
+	emit('deleted');
+}
+
+function addUser() {
+	os.selectUser().then(user => {
+		users = users.trim();
+		users += '\n@' + Acct.toString(user as any);
+		users = users.trim();
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.shaynizk {
+	> .form {
+		padding: 32px;
+	}
+
+	> .actions {
+		padding: 24px 32px;
+		border-top: solid 0.5px var(--divider);
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue
new file mode 100644
index 0000000000..9daf23f9b5
--- /dev/null
+++ b/packages/frontend/src/pages/my-antennas/index.vue
@@ -0,0 +1,64 @@
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
+	<div class="ieepwinx">
+		<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+
+		<div class="">
+			<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
+				<MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`">
+					<div class="name">{{ antenna.name }}</div>
+				</MkA>
+			</MkPagination>
+		</div>
+	</div>
+</MkSpacer></MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'antennas/list' as const,
+	limit: 10,
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'ti ti-antenna',
+});
+</script>
+
+<style lang="scss" scoped>
+.ieepwinx {
+
+	> .add {
+		margin: 0 auto 16px auto;
+	}
+
+	.ljoevbzj {
+		display: block;
+		padding: 16px;
+		margin-bottom: 8px;
+		border: solid 1px var(--divider);
+		border-radius: 6px;
+
+		&:hover {
+			border: solid 1px var(--accent);
+			text-decoration: none;
+		}
+
+		> .name {
+			font-weight: bold;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
new file mode 100644
index 0000000000..dd6b5b3a37
--- /dev/null
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -0,0 +1,100 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="qtcaoidl">
+			<MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+
+			<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="list">
+				<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+					<b>{{ item.name }}</b>
+					<div v-if="item.description" class="description">{{ item.description }}</div>
+				</MkA>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'clips/list' as const,
+	limit: 10,
+};
+
+const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
+
+async function create() {
+	const { canceled, result } = await os.form(i18n.ts.createNewClip, {
+		name: {
+			type: 'string',
+			label: i18n.ts.name,
+		},
+		description: {
+			type: 'string',
+			required: false,
+			multiline: true,
+			label: i18n.ts.description,
+		},
+		isPublic: {
+			type: 'boolean',
+			label: i18n.ts.public,
+			default: false,
+		},
+	});
+	if (canceled) return;
+
+	os.apiWithDialog('clips/create', result);
+
+	pagingComponent.reload();
+}
+
+function onClipCreated() {
+	pagingComponent.reload();
+}
+
+function onClipDeleted() {
+	pagingComponent.reload();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.clip,
+	icon: 'ti ti-paperclip',
+	action: {
+		icon: 'ti ti-plus',
+		handler: create,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.qtcaoidl {
+	> .add {
+		margin: 0 auto 16px auto;
+	}
+
+	> .list {
+		> .item {
+			display: block;
+			padding: 16px;
+
+			> .description {
+				margin-top: 8px;
+				padding-top: 8px;
+				border-top: solid 0.5px var(--divider);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
new file mode 100644
index 0000000000..3476436b27
--- /dev/null
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -0,0 +1,82 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="qkcjvfiv">
+			<MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
+
+			<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content">
+				<MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`">
+					<div class="name">{{ list.name }}</div>
+					<MkAvatars :user-ids="list.userIds"/>
+				</MkA>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkAvatars from '@/components/MkAvatars.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
+
+const pagination = {
+	endpoint: 'users/lists/list' as const,
+	limit: 10,
+};
+
+async function create() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts.enterListName,
+	});
+	if (canceled) return;
+	await os.apiWithDialog('users/lists/create', { name: name });
+	pagingComponent.reload();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageLists,
+	icon: 'ti ti-list',
+	action: {
+		icon: 'ti ti-plus',
+		handler: create,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.qkcjvfiv {
+	> .add {
+		margin: 0 auto var(--margin) auto;
+	}
+
+	> .lists {
+		> .list {
+			display: block;
+			padding: 16px;
+			border: solid 1px var(--divider);
+			border-radius: 6px;
+
+			&:hover {
+				border: solid 1px var(--accent);
+				text-decoration: none;
+			}
+
+			> .name {
+				margin-bottom: 4px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
new file mode 100644
index 0000000000..f6234ffe44
--- /dev/null
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -0,0 +1,162 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="mk-list-page">
+			<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+				<div v-if="list" class="_section">
+					<div class="_content">
+						<MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
+						<MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton>
+						<MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+					</div>
+				</div>
+			</transition>
+
+			<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+				<div v-if="list" class="_section members _gap">
+					<div class="_title">{{ i18n.ts.members }}</div>
+					<div class="_content">
+						<div class="users">
+							<div v-for="user in users" :key="user.id" class="user _panel">
+								<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+								<div class="body">
+									<MkUserName :user="user" class="name"/>
+									<MkAcct :user="user" class="acct"/>
+								</div>
+								<div class="action">
+									<button class="_button" @click="removeUser(user)"><i class="ti ti-x"></i></button>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</transition>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	listId: string;
+}>();
+
+let list = $ref(null);
+let users = $ref([]);
+
+function fetchList() {
+	os.api('users/lists/show', {
+		listId: props.listId,
+	}).then(_list => {
+		list = _list;
+		os.api('users/show', {
+			userIds: list.userIds,
+		}).then(_users => {
+			users = _users;
+		});
+	});
+}
+
+function addUser() {
+	os.selectUser().then(user => {
+		os.apiWithDialog('users/lists/push', {
+			listId: list.id,
+			userId: user.id,
+		}).then(() => {
+			users.push(user);
+		});
+	});
+}
+
+function removeUser(user) {
+	os.api('users/lists/pull', {
+		listId: list.id,
+		userId: user.id,
+	}).then(() => {
+		users = users.filter(x => x.id !== user.id);
+	});
+}
+
+async function renameList() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts.enterListName,
+		default: list.name,
+	});
+	if (canceled) return;
+
+	await os.api('users/lists/update', {
+		listId: list.id,
+		name: name,
+	});
+
+	list.name = name;
+}
+
+async function deleteList() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: list.name }),
+	});
+	if (canceled) return;
+
+	await os.api('users/lists/delete', {
+		listId: list.id,
+	});
+	os.success();
+	mainRouter.push('/my/lists');
+}
+
+watch(() => props.listId, fetchList, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'ti ti-list',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.mk-list-page {
+	> .members {
+		> ._content {
+			> .users {
+				> .user {
+					display: flex;
+					align-items: center;
+					padding: 16px;
+
+					> .avatar {
+						width: 50px;
+						height: 50px;
+					}
+
+					> .body {
+						flex: 1;
+						padding: 8px;
+
+						> .name {
+							display: block;
+							font-weight: bold;
+						}
+
+						> .acct {
+							opacity: 0.5;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue
new file mode 100644
index 0000000000..e58e44ef79
--- /dev/null
+++ b/packages/frontend/src/pages/not-found.vue
@@ -0,0 +1,22 @@
+<template>
+<div class="ipledcug">
+	<div class="_fullinfo">
+		<img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
+		<div>{{ i18n.ts.notFoundDescription }}</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notFound,
+	icon: 'ti ti-alert-triangle',
+});
+</script>
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
new file mode 100644
index 0000000000..ba2bb91239
--- /dev/null
+++ b/packages/frontend/src/pages/note.vue
@@ -0,0 +1,206 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div class="fcuexfpr">
+			<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+				<div v-if="note" class="note">
+					<div v-if="showNext" class="_gap">
+						<XNotes class="_content" :pagination="nextPagination" :no-gap="true"/>
+					</div>
+
+					<div class="main _gap">
+						<MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton>
+						<div class="note _gap">
+							<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
+							<XNoteDetailed :key="note.id" v-model:note="note" class="note"/>
+						</div>
+						<div v-if="clips && clips.length > 0" class="_content clips _gap">
+							<div class="title">{{ i18n.ts.clip }}</div>
+							<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+								<b>{{ item.name }}</b>
+								<div v-if="item.description" class="description">{{ item.description }}</div>
+								<div class="user">
+									<MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
+								</div>
+							</MkA>
+						</div>
+						<MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton>
+					</div>
+
+					<div v-if="showPrev" class="_gap">
+						<XNotes class="_content" :pagination="prevPagination" :no-gap="true"/>
+					</div>
+				</div>
+				<MkError v-else-if="error" @retry="fetch()"/>
+				<MkLoading v-else/>
+			</transition>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import XNote from '@/components/MkNote.vue';
+import XNoteDetailed from '@/components/MkNoteDetailed.vue';
+import XNotes from '@/components/MkNotes.vue';
+import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	noteId: string;
+}>();
+
+let note = $ref<null | misskey.entities.Note>();
+let clips = $ref();
+let hasPrev = $ref(false);
+let hasNext = $ref(false);
+let showPrev = $ref(false);
+let showNext = $ref(false);
+let error = $ref();
+
+const prevPagination = {
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		untilId: note.id,
+	}) : null),
+};
+
+const nextPagination = {
+	reversed: true,
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		sinceId: note.id,
+	}) : null),
+};
+
+function fetchNote() {
+	hasPrev = false;
+	hasNext = false;
+	showPrev = false;
+	showNext = false;
+	note = null;
+	os.api('notes/show', {
+		noteId: props.noteId,
+	}).then(res => {
+		note = res;
+		Promise.all([
+			os.api('notes/clips', {
+				noteId: note.id,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				untilId: note.id,
+				limit: 1,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				sinceId: note.id,
+				limit: 1,
+			}),
+		]).then(([_clips, prev, next]) => {
+			clips = _clips;
+			hasPrev = prev.length !== 0;
+			hasNext = next.length !== 0;
+		});
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.noteId, fetchNote, {
+	immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => note ? {
+	title: i18n.ts.note,
+	subtitle: new Date(note.createdAt).toLocaleString(),
+	avatar: note.user,
+	path: `/notes/${note.id}`,
+	share: {
+		title: i18n.t('noteOf', { user: note.user.name }),
+		text: note.text,
+	},
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.fcuexfpr {
+	background: var(--bg);
+
+	> .note {
+		> .main {
+			> .load {
+				min-width: 0;
+				margin: 0 auto;
+				border-radius: 999px;
+
+				&.next {
+					margin-bottom: var(--margin);
+				}
+
+				&.prev {
+					margin-top: var(--margin);
+				}
+			}
+
+			> .note {
+				> .note {
+					border-radius: var(--radius);
+					background: var(--panel);
+				}
+			}
+
+			> .clips {
+				> .title {
+					font-weight: bold;
+					padding: 12px;
+				}
+
+				> .item {
+					display: block;
+					padding: 16px;
+
+					> .description {
+						padding: 8px 0;
+					}
+
+					> .user {
+						$height: 32px;
+						padding-top: 16px;
+						border-top: solid 0.5px var(--divider);
+						line-height: $height;
+
+						> .avatar {
+							width: $height;
+							height: $height;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
new file mode 100644
index 0000000000..7106951de2
--- /dev/null
+++ b/packages/frontend/src/pages/notifications.vue
@@ -0,0 +1,95 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-if="tab === 'all' || tab === 'unread'">
+			<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
+		</div>
+		<div v-else-if="tab === 'mentions'">
+			<XNotes :pagination="mentionsPagination"/>
+		</div>
+		<div v-else-if="tab === 'directNotes'">
+			<XNotes :pagination="directNotesPagination"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import XNotifications from '@/components/MkNotifications.vue';
+import XNotes from '@/components/MkNotes.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let tab = $ref('all');
+let includeTypes = $ref<string[] | null>(null);
+let unreadOnly = $computed(() => tab === 'unread');
+
+const mentionsPagination = {
+	endpoint: 'notes/mentions' as const,
+	limit: 10,
+};
+
+const directNotesPagination = {
+	endpoint: 'notes/mentions' as const,
+	limit: 10,
+	params: {
+		visibility: 'specified',
+	},
+};
+
+function setFilter(ev) {
+	const typeItems = notificationTypes.map(t => ({
+		text: i18n.t(`_notification._types.${t}`),
+		active: includeTypes && includeTypes.includes(t),
+		action: () => {
+			includeTypes = [t];
+		},
+	}));
+	const items = includeTypes != null ? [{
+		icon: 'ti ti-x',
+		text: i18n.ts.clear,
+		action: () => {
+			includeTypes = null;
+		},
+	}, null, ...typeItems] : typeItems;
+	os.popupMenu(items, ev.currentTarget ?? ev.target);
+}
+
+const headerActions = $computed(() => [tab === 'all' ? {
+	text: i18n.ts.filter,
+	icon: 'ti ti-filter',
+	highlighted: includeTypes != null,
+	handler: setFilter,
+} : undefined, tab === 'all' ? {
+	text: i18n.ts.markAllAsRead,
+	icon: 'ti ti-check',
+	handler: () => {
+		os.apiWithDialog('notifications/mark-all-as-read');
+	},
+} : undefined].filter(x => x !== undefined));
+
+const headerTabs = $computed(() => [{
+	key: 'all',
+	title: i18n.ts.all,
+}, {
+	key: 'unread',
+	title: i18n.ts.unread,
+}, {
+	key: 'mentions',
+	title: i18n.ts.mentions,
+	icon: 'ti ti-at',
+}, {
+	key: 'directNotes',
+	title: i18n.ts.directNotes,
+	icon: 'ti ti-mail',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.notifications,
+	icon: 'ti ti-bell',
+})));
+</script>
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
new file mode 100644
index 0000000000..a84cb1e80e
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
@@ -0,0 +1,63 @@
+<template>
+<!-- eslint-disable vue/no-mutating-props -->
+<XContainer :draggable="true" @remove="() => $emit('remove')">
+	<template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template>
+	<template #func>
+		<button @click="choose()">
+			<i class="fas fa-folder-open"></i>
+		</button>
+	</template>
+
+	<section class="oyyftmcf">
+		<MkDriveFileThumbnail v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/>
+	</section>
+</XContainer>
+</template>
+
+<script lang="ts" setup>
+/* eslint-disable vue/no-mutating-props */
+import { onMounted } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import * as os from '@/os';
+
+const props = defineProps<{
+	modelValue: any
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+let file: any = $ref(null);
+
+async function choose() {
+	os.selectDriveFile(false).then((fileResponse: any) => {
+		file = fileResponse;
+		emit('update:modelValue', {
+			...props.modelValue,
+			fileId: fileResponse.id,
+		});
+	});
+}
+
+onMounted(async () => {
+	if (props.modelValue.fileId == null) {
+		await choose();
+	} else {
+		os.api('drive/files/show', {
+			fileId: props.modelValue.fileId,
+		}).then(fileResponse => {
+			file = fileResponse;
+		});
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.oyyftmcf {
+	> .preview {
+		height: 150px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
new file mode 100644
index 0000000000..dc2a620c09
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
@@ -0,0 +1,57 @@
+<template>
+<!-- eslint-disable vue/no-mutating-props -->
+<XContainer :draggable="true" @remove="() => $emit('remove')">
+	<template #header><i class="ti ti-note"></i> {{ $ts._pages.blocks.note }}</template>
+
+	<section style="padding: 0 16px 0 16px;">
+		<MkInput v-model="id">
+			<template #label>{{ $ts._pages.blocks._note.id }}</template>
+			<template #caption>{{ $ts._pages.blocks._note.idDescription }}</template>
+		</MkInput>
+		<MkSwitch v-model="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch>
+
+		<XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/>
+		<XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/>
+	</section>
+</XContainer>
+</template>
+
+<script lang="ts" setup>
+/* eslint-disable vue/no-mutating-props */
+import { watch } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import XNote from '@/components/MkNote.vue';
+import XNoteDetailed from '@/components/MkNoteDetailed.vue';
+import * as os from '@/os';
+
+const props = defineProps<{
+	modelValue: any
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+let id: any = $ref(props.modelValue.note);
+let note: any = $ref(null);
+
+watch(id, async () => {
+	if (id && (id.startsWith('http://') || id.startsWith('https://'))) {
+		emit('update:modelValue', {
+			...props.modelValue,
+			note: (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop(),
+		});
+	} else {
+		emit('update:modelValue', {
+			...props.modelValue,
+			note: id,
+		});
+	}
+
+	note = await os.api('notes/show', { noteId: props.modelValue.note });
+}, {
+	immediate: true,
+});
+</script>
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
new file mode 100644
index 0000000000..27324bdaef
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
@@ -0,0 +1,97 @@
+<template>
+<!-- eslint-disable vue/no-mutating-props -->
+<XContainer :draggable="true" @remove="() => $emit('remove')">
+	<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
+	<template #func>
+		<button class="_button" @click="rename()">
+			<i class="ti ti-pencil"></i>
+		</button>
+	</template>
+
+	<section class="ilrvjyvi">
+		<XBlocks v-model="children" class="children"/>
+		<MkButton rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
+	</section>
+</XContainer>
+</template>
+
+<script lang="ts" setup>
+/* eslint-disable vue/no-mutating-props */
+import { defineAsyncComponent, inject, onMounted, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { deepClone } from '@/scripts/clone';
+import MkButton from '@/components/MkButton.vue';
+
+const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue'));
+
+const props = withDefaults(defineProps<{
+	modelValue: any,
+}>(), {
+	modelValue: {},
+});
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+const children = $ref(deepClone(props.modelValue.children ?? []));
+
+watch($$(children), () => {
+	emit('update:modelValue', {
+		...props.modelValue,
+		children,
+	});
+}, {
+	deep: true,
+});
+
+const getPageBlockList = inject<(any) => any>('getPageBlockList');
+
+async function rename() {
+	const { canceled, result: title } = await os.inputText({
+		title: 'Enter title',
+		default: props.modelValue.title,
+	});
+	if (canceled) return;
+	emit('update:modelValue', {
+		...props.modelValue,
+		title,
+	});
+}
+
+async function add() {
+	const { canceled, result: type } = await os.select({
+		title: i18n.ts._pages.chooseBlock,
+		items: getPageBlockList(),
+	});
+	if (canceled) return;
+
+	const id = uuid();
+	children.push({ id, type });
+}
+
+onMounted(() => {
+	if (props.modelValue.title == null) {
+		rename();
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.ilrvjyvi {
+	> .children {
+		margin: 16px;
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	> .add {
+		margin: 16px auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
new file mode 100644
index 0000000000..6f11e2a08b
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -0,0 +1,54 @@
+<template>
+<!-- eslint-disable vue/no-mutating-props -->
+<XContainer :draggable="true" @remove="() => $emit('remove')">
+	<template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template>
+
+	<section class="vckmsadr">
+		<textarea v-model="text"></textarea>
+	</section>
+</XContainer>
+</template>
+
+<script lang="ts" setup>
+/* eslint-disable vue/no-mutating-props */
+import { watch } from 'vue';
+import XContainer from '../page-editor.container.vue';
+
+const props = defineProps<{
+	modelValue: any
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update:modelValue', value: any): void;
+}>();
+
+const text = $ref(props.modelValue.text ?? '');
+
+watch($$(text), () => {
+	emit('update:modelValue', {
+		...props.modelValue,
+		text,
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+.vckmsadr {
+	> textarea {
+		display: block;
+		-webkit-appearance: none;
+		-moz-appearance: none;
+		appearance: none;
+		width: 100%;
+		min-width: 100%;
+		min-height: 150px;
+		border: none;
+		box-shadow: none;
+		padding: 16px;
+		background: transparent;
+		color: var(--fg);
+		font-size: 14px;
+		box-sizing: border-box;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
new file mode 100644
index 0000000000..f99fcb202f
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
@@ -0,0 +1,65 @@
+<template>
+<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)">
+	<template #item="{element}">
+		<div :class="$style.item">
+			<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
+			<component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/>
+		</div>
+	</template>
+</Sortable>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XSection from './els/page-editor.el.section.vue';
+import XText from './els/page-editor.el.text.vue';
+import XImage from './els/page-editor.el.image.vue';
+import XNote from './els/page-editor.el.note.vue';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+export default defineComponent({
+	components: {
+		Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+		XSection, XText, XImage, XNote,
+	},
+
+	props: {
+		modelValue: {
+			type: Array,
+			required: true,
+		},
+	},
+
+	emits: ['update:modelValue'],
+
+	methods: {
+		updateItem(v) {
+			const i = this.modelValue.findIndex(x => x.id === v.id);
+			const newValue = [
+				...this.modelValue.slice(0, i),
+				v,
+				...this.modelValue.slice(i + 1),
+			];
+			this.$emit('update:modelValue', newValue);
+		},
+
+		removeItem(el) {
+			const i = this.modelValue.findIndex(x => x.id === el.id);
+			const newValue = [
+				...this.modelValue.slice(0, i),
+				...this.modelValue.slice(i + 1),
+			];
+			this.$emit('update:modelValue', newValue);
+		},
+	},
+});
+</script>
+
+<style lang="scss" module>
+.item {
+	& + .item {
+		margin-top: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue
new file mode 100644
index 0000000000..15cdda5efb
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue
@@ -0,0 +1,155 @@
+<template>
+<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
+	<header>
+		<div class="title"><slot name="header"></slot></div>
+		<div class="buttons">
+			<slot name="func"></slot>
+			<button v-if="removable" class="_button" @click="remove()">
+				<i class="ti ti-trash"></i>
+			</button>
+			<button v-if="draggable" class="drag-handle _button">
+				<i class="ti ti-menu-2"></i>
+			</button>
+			<button class="_button" @click="toggleContent(!showBody)">
+				<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
+				<template v-else><i class="ti ti-chevron-down"></i></template>
+			</button>
+		</div>
+	</header>
+	<p v-show="showBody" v-if="error != null" class="error">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
+	<p v-show="showBody" v-if="warn != null" class="warn">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
+	<div v-show="showBody" class="body">
+		<slot></slot>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+	props: {
+		expanded: {
+			type: Boolean,
+			default: true,
+		},
+		removable: {
+			type: Boolean,
+			default: true,
+		},
+		draggable: {
+			type: Boolean,
+			default: false,
+		},
+		error: {
+			required: false,
+			default: null,
+		},
+		warn: {
+			required: false,
+			default: null,
+		},
+	},
+	emits: ['toggle', 'remove'],
+	data() {
+		return {
+			showBody: this.expanded,
+		};
+	},
+	methods: {
+		toggleContent(show: boolean) {
+			this.showBody = show;
+			this.$emit('toggle', show);
+		},
+		remove() {
+			this.$emit('remove');
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.cpjygsrt {
+	position: relative;
+	overflow: hidden;
+	background: var(--panel);
+	border: solid 2px var(--X12);
+	border-radius: 8px;
+
+	&:hover {
+		border: solid 2px var(--X13);
+	}
+
+	&.warn {
+		border: solid 2px #dec44c;
+	}
+
+	&.error {
+		border: solid 2px #f00;
+	}
+
+	> header {
+		> .title {
+			z-index: 1;
+			margin: 0;
+			padding: 0 16px;
+			line-height: 42px;
+			font-size: 0.9em;
+			font-weight: bold;
+			box-shadow: 0 1px rgba(#000, 0.07);
+
+			> i {
+				margin-right: 6px;
+			}
+
+			&:empty {
+				display: none;
+			}
+		}
+
+		> .buttons {
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			right: 0;
+
+			> button {
+				padding: 0;
+				width: 42px;
+				font-size: 0.9em;
+				line-height: 42px;
+			}
+
+			.drag-handle {
+				cursor: move;
+			}
+		}
+	}
+
+	> .warn {
+		color: #b19e49;
+		margin: 0;
+		padding: 16px 16px 0 16px;
+		font-size: 14px;
+	}
+
+	> .error {
+		color: #f00;
+		margin: 0;
+		padding: 16px 16px 0 16px;
+		font-size: 14px;
+	}
+
+	> .body {
+		::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
+			&:not(.inline):first-child {
+				margin-top: 28px;
+			}
+
+			&:not(.inline):last-child {
+				margin-bottom: 20px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
new file mode 100644
index 0000000000..968aa12de2
--- /dev/null
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -0,0 +1,394 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="jqqmcavi">
+			<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ $ts._pages.viewPage }}</MkButton>
+			<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ $ts.save }}</MkButton>
+			<MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ $ts.duplicate }}</MkButton>
+			<MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ $ts.delete }}</MkButton>
+		</div>
+
+		<div v-if="tab === 'settings'">
+			<div class="_formRoot">
+				<MkInput v-model="title" class="_formBlock">
+					<template #label>{{ $ts._pages.title }}</template>
+				</MkInput>
+
+				<MkInput v-model="summary" class="_formBlock">
+					<template #label>{{ $ts._pages.summary }}</template>
+				</MkInput>
+
+				<MkInput v-model="name" class="_formBlock">
+					<template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
+					<template #label>{{ $ts._pages.url }}</template>
+				</MkInput>
+
+				<MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch>
+
+				<MkSelect v-model="font" class="_formBlock">
+					<template #label>{{ $ts._pages.font }}</template>
+					<option value="serif">{{ $ts._pages.fontSerif }}</option>
+					<option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option>
+				</MkSelect>
+
+				<MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch>
+
+				<div class="eyeCatch">
+					<MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
+					<div v-else-if="eyeCatchingImage">
+						<img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
+						<MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<div v-else-if="tab === 'contents'">
+			<div :class="$style.contents">
+				<XBlocks v-model="content" class="content"/>
+
+				<MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, provide, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XBlocks from './page-editor.blocks.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInput from '@/components/form/input.vue';
+import { url } from '@/config';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { mainRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+const props = defineProps<{
+	initPageId?: string;
+	initPageName?: string;
+	initUser?: string;
+}>();
+
+let tab = $ref('settings');
+let author = $ref($i);
+let readonly = $ref(false);
+let page = $ref(null);
+let pageId = $ref(null);
+let currentName = $ref(null);
+let title = $ref('');
+let summary = $ref(null);
+let name = $ref(Date.now().toString());
+let eyeCatchingImage = $ref(null);
+let eyeCatchingImageId = $ref(null);
+let font = $ref('sans-serif');
+let content = $ref([]);
+let alignCenter = $ref(false);
+let hideTitleWhenPinned = $ref(false);
+
+provide('readonly', readonly);
+provide('getPageBlockList', getPageBlockList);
+
+watch($$(eyeCatchingImageId), async () => {
+	if (eyeCatchingImageId == null) {
+		eyeCatchingImage = null;
+	} else {
+		eyeCatchingImage = await os.api('drive/files/show', {
+			fileId: eyeCatchingImageId,
+		});
+	}
+});
+
+function getSaveOptions() {
+	return {
+		title: title.trim(),
+		name: name.trim(),
+		summary: summary,
+		font: font,
+		script: '',
+		hideTitleWhenPinned: hideTitleWhenPinned,
+		alignCenter: alignCenter,
+		content: content,
+		variables: [],
+		eyeCatchingImageId: eyeCatchingImageId,
+	};
+}
+
+function save() {
+	const options = getSaveOptions();
+
+	const onError = err => {
+		if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
+			if (err.info.param === 'name') {
+				os.alert({
+					type: 'error',
+					title: i18n.ts._pages.invalidNameTitle,
+					text: i18n.ts._pages.invalidNameText,
+				});
+			}
+		} else if (err.code === 'NAME_ALREADY_EXISTS') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts._pages.nameAlreadyExists,
+			});
+		}
+	};
+
+	if (pageId) {
+		options.pageId = pageId;
+		os.api('pages/update', options)
+			.then(page => {
+				currentName = name.trim();
+				os.alert({
+					type: 'success',
+					text: i18n.ts._pages.updated,
+				});
+			}).catch(onError);
+	} else {
+		os.api('pages/create', options)
+			.then(created => {
+				pageId = created.id;
+				currentName = name.trim();
+				os.alert({
+					type: 'success',
+					text: i18n.ts._pages.created,
+				});
+				mainRouter.push(`/pages/edit/${pageId}`);
+			}).catch(onError);
+	}
+}
+
+function del() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: title.trim() }),
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.api('pages/delete', {
+			pageId: pageId,
+		}).then(() => {
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.deleted,
+			});
+			mainRouter.push('/pages');
+		});
+	});
+}
+
+function duplicate() {
+	title = title + ' - copy';
+	name = name + '-copy';
+	os.api('pages/create', getSaveOptions()).then(created => {
+		pageId = created.id;
+		currentName = name.trim();
+		os.alert({
+			type: 'success',
+			text: i18n.ts._pages.created,
+		});
+		mainRouter.push(`/pages/edit/${pageId}`);
+	});
+}
+
+async function add() {
+	const { canceled, result: type } = await os.select({
+		type: null,
+		title: i18n.ts._pages.chooseBlock,
+		items: getPageBlockList(),
+	});
+	if (canceled) return;
+
+	const id = uuid();
+	content.push({ id, type });
+}
+
+function getPageBlockList() {
+	return [
+		{ value: 'section', text: i18n.ts._pages.blocks.section },
+		{ value: 'text', text: i18n.ts._pages.blocks.text },
+		{ value: 'image', text: i18n.ts._pages.blocks.image },
+		{ value: 'note', text: i18n.ts._pages.blocks.note },
+	];
+}
+
+function setEyeCatchingImage(img) {
+	selectFile(img.currentTarget ?? img.target, null).then(file => {
+		eyeCatchingImageId = file.id;
+	});
+}
+
+function removeEyeCatchingImage() {
+	eyeCatchingImageId = null;
+}
+
+async function init() {
+	if (props.initPageId) {
+		page = await os.api('pages/show', {
+			pageId: props.initPageId,
+		});
+	} else if (props.initPageName && props.initUser) {
+		page = await os.api('pages/show', {
+			name: props.initPageName,
+			username: props.initUser,
+		});
+		readonly = true;
+	}
+
+	if (page) {
+		author = page.user;
+		pageId = page.id;
+		title = page.title;
+		name = page.name;
+		currentName = page.name;
+		summary = page.summary;
+		font = page.font;
+		hideTitleWhenPinned = page.hideTitleWhenPinned;
+		alignCenter = page.alignCenter;
+		content = page.content;
+		eyeCatchingImageId = page.eyeCatchingImageId;
+	} else {
+		const id = uuid();
+		content = [{
+			id,
+			type: 'text',
+			text: 'Hello World!',
+		}];
+	}
+}
+
+init();
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	key: 'settings',
+	title: i18n.ts._pages.pageSetting,
+	icon: 'ti ti-settings',
+}, {
+	key: 'contents',
+	title: i18n.ts._pages.contents,
+	icon: 'ti ti-note',
+}]);
+
+definePageMetadata(computed(() => {
+	let title = i18n.ts._pages.newPage;
+	if (props.initPageId) {
+		title = i18n.ts._pages.editPage;
+	}
+	else if (props.initPageName && props.initUser) {
+		title = i18n.ts._pages.readPage;
+	}
+	return {
+		title: title,
+		icon: 'ti ti-pencil',
+	};
+}));
+</script>
+
+<style lang="scss" module>
+.contents {
+	&:global {
+		> .add {
+			margin: 16px auto 0 auto;
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.jqqmcavi {
+	margin-bottom: 16px;
+
+	> .button {
+		& + .button {
+			margin-left: 8px;
+		}
+	}
+}
+
+.gwbmwxkm {
+	position: relative;
+
+	> header {
+		> .title {
+			z-index: 1;
+			margin: 0;
+			padding: 0 16px;
+			line-height: 42px;
+			font-size: 0.9em;
+			font-weight: bold;
+			box-shadow: 0 1px rgba(#000, 0.07);
+
+			> i {
+				margin-right: 6px;
+			}
+
+			&:empty {
+				display: none;
+			}
+		}
+
+		> .buttons {
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			right: 0;
+
+			> button {
+				padding: 0;
+				width: 42px;
+				font-size: 0.9em;
+				line-height: 42px;
+			}
+		}
+	}
+
+	> section {
+		padding: 0 32px 32px 32px;
+
+		@media (max-width: 500px) {
+			padding: 0 16px 16px 16px;
+		}
+
+		> .view {
+			display: inline-block;
+			margin: 16px 0 0 0;
+			font-size: 14px;
+		}
+
+		> .content {
+			margin-bottom: 16px;
+		}
+
+		> .eyeCatch {
+			margin-bottom: 16px;
+
+			> div {
+				> img {
+					max-width: 100%;
+				}
+			}
+		}
+	}
+}
+
+.qmuvgica {
+	padding: 16px;
+
+	> .variables {
+		margin-bottom: 16px;
+	}
+
+	> .add {
+		margin-bottom: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
new file mode 100644
index 0000000000..a95bfe485c
--- /dev/null
+++ b/packages/frontend/src/pages/page.vue
@@ -0,0 +1,277 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+			<div v-if="page" :key="page.id" class="xcukqgmh">
+				<div class="_block main">
+					<!--
+				<div class="header">
+					<h1>{{ page.title }}</h1>
+				</div>
+				-->
+					<div class="banner">
+						<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
+					</div>
+					<div class="content">
+						<XPage :page="page"/>
+					</div>
+					<div class="actions">
+						<div class="like">
+							<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+							<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+						</div>
+						<div class="other">
+							<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
+							<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
+						</div>
+					</div>
+					<div class="user">
+						<MkAvatar :user="page.user" class="avatar"/>
+						<div class="name">
+							<MkUserName :user="page.user" style="display: block;"/>
+							<MkAcct :user="page.user"/>
+						</div>
+						<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+					</div>
+					<div class="links">
+						<MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
+						<template v-if="$i && $i.id === page.userId">
+							<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
+							<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
+							<button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button>
+						</template>
+					</div>
+				</div>
+				<div class="footer">
+					<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+					<div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
+				</div>
+				<MkAd :prefer="['horizontal', 'horizontal-big']"/>
+				<MkContainer :max-height="300" :foldable="true" class="other">
+					<template #header><i class="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="otherPostsPagination">
+						<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
+					</MkPagination>
+				</MkContainer>
+			</div>
+			<MkError v-else-if="error" @retry="fetchPage()"/>
+			<MkLoading v-else/>
+		</transition>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import XPage from '@/components/page/page.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { url } from '@/config';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkPagePreview from '@/components/MkPagePreview.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	pageName: string;
+	username: string;
+}>();
+
+let page = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/pages' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: page.user.id,
+	})),
+};
+const path = $computed(() => props.username + '/' + props.pageName);
+
+function fetchPage() {
+	page = null;
+	os.api('pages/show', {
+		name: props.pageName,
+		username: props.username,
+	}).then(_page => {
+		page = _page;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: page.title ?? page.name,
+		text: page.summary,
+		url: `${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('pages/like', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = true;
+		page.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('pages/unlike', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = false;
+		page.likedCount--;
+	});
+}
+
+function pin(pin) {
+	os.apiWithDialog('i/update', {
+		pinnedPageId: pin ? page.id : null,
+	});
+}
+
+watch(() => path, fetchPage, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => page ? {
+	title: computed(() => page.title || page.name),
+	avatar: page.user,
+	path: `/@${page.user.username}/pages/${page.name}`,
+	share: {
+		title: page.title || page.name,
+		text: page.summary,
+	},
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.xcukqgmh {
+	> .main {
+		padding: 32px;
+
+		> .header {
+			padding: 16px;
+
+			> h1 {
+				margin: 0;
+			}
+		}
+
+		> .banner {
+			> img {
+				// TODO: 良い感じのアスペクト比で表示
+				display: block;
+				width: 100%;
+				height: 150px;
+				object-fit: cover;
+			}
+		}
+
+		> .content {
+			margin-top: 16px;
+			padding: 16px 0 0 0;
+		}
+
+		> .actions {
+			display: flex;
+			align-items: center;
+			margin-top: 16px;
+			padding: 16px 0 0 0;
+			border-top: solid 0.5px var(--divider);
+
+			> .like {
+				> .button {
+					--accent: rgb(241 97 132);
+					--X8: rgb(241 92 128);
+					--buttonBg: rgb(216 71 106 / 5%);
+					--buttonHoverBg: rgb(216 71 106 / 10%);
+					color: #ff002f;
+
+					::v-deep(.count) {
+						margin-left: 0.5em;
+					}
+				}
+			}
+
+			> .other {
+				margin-left: auto;
+
+				> button {
+					padding: 8px;
+					margin: 0 8px;
+
+					&:hover {
+						color: var(--fgHighlighted);
+					}
+				}
+			}
+		}
+
+		> .user {
+			margin-top: 16px;
+			padding: 16px 0 0 0;
+			border-top: solid 0.5px var(--divider);
+			display: flex;
+			align-items: center;
+
+			> .avatar {
+				width: 52px;
+				height: 52px;
+			}
+
+			> .name {
+				margin: 0 0 0 12px;
+				font-size: 90%;
+			}
+
+			> .koudoku {
+				margin-left: auto;
+			}
+		}
+
+		> .links {
+			margin-top: 16px;
+			padding: 24px 0 0 0;
+			border-top: solid 0.5px var(--divider);
+
+			> .link {
+				margin-right: 0.75em;
+			}
+		}
+	}
+
+	> .footer {
+		margin: var(--margin) 0 var(--margin) 0;
+		font-size: 85%;
+		opacity: 0.75;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue
new file mode 100644
index 0000000000..b077180df8
--- /dev/null
+++ b/packages/frontend/src/pages/pages.vue
@@ -0,0 +1,99 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
+
+		<div v-else-if="tab === 'my'" class="rknalgpo my">
+			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="myPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
+
+		<div v-else-if="tab === 'liked'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
+				<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
+import MkPagePreview from '@/components/MkPagePreview.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagesPagination = {
+	endpoint: 'pages/featured' as const,
+	noPaging: true,
+};
+const myPagesPagination = {
+	endpoint: 'i/pages' as const,
+	limit: 5,
+};
+const likedPagesPagination = {
+	endpoint: 'i/page-likes' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/pages/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'featured',
+	title: i18n.ts._pages.featured,
+	icon: 'fas fa-fire-alt',
+}, {
+	key: 'my',
+	title: i18n.ts._pages.my,
+	icon: 'ti ti-edit',
+}, {
+	key: 'liked',
+	title: i18n.ts._pages.liked,
+	icon: 'ti ti-heart',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.pages,
+	icon: 'ti ti-note',
+})));
+</script>
+
+<style lang="scss" scoped>
+.rknalgpo {
+	&.my .ckltabjg:first-child {
+		margin-top: 16px;
+	}
+
+	.ckltabjg:not(:last-child) {
+		margin-bottom: 8px;
+	}
+
+	@media (min-width: 500px) {
+		.ckltabjg:not(:last-child) {
+			margin-bottom: 16px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue
new file mode 100644
index 0000000000..354f686e46
--- /dev/null
+++ b/packages/frontend/src/pages/preview.vue
@@ -0,0 +1,27 @@
+<template>
+<div class="graojtoi">
+	<MkSample/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkSample from '@/components/MkSample.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.preview,
+	icon: 'ti ti-eye',
+})));
+</script>
+
+<style lang="scss" scoped>
+.graojtoi {
+	padding: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue
new file mode 100644
index 0000000000..f179fbe957
--- /dev/null
+++ b/packages/frontend/src/pages/registry.keys.vue
@@ -0,0 +1,96 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600" :margin-min="16">
+		<FormSplit>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts._registry.domain }}</template>
+				<template #value>{{ i18n.ts.system }}</template>
+			</MkKeyValue>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts._registry.scope }}</template>
+				<template #value>{{ scope.join('/') }}</template>
+			</MkKeyValue>
+		</FormSplit>
+		
+		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
+
+		<FormSection v-if="keys">
+			<template #label>{{ i18n.ts.keys }}</template>
+			<div class="_formLinks">
+				<FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+			</div>
+		</FormSection>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormSplit from '@/components/form/split.vue';
+
+const props = defineProps<{
+	path: string;
+}>();
+
+const scope = $computed(() => props.path.split('/'));
+
+let keys = $ref(null);
+
+function fetchKeys() {
+	os.api('i/registry/keys-with-type', {
+		scope: scope,
+	}).then(res => {
+		keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0]));
+	});
+}
+
+async function createKey() {
+	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
+		key: {
+			type: 'string',
+			label: i18n.ts._registry.key,
+		},
+		value: {
+			type: 'string',
+			multiline: true,
+			label: i18n.ts.value,
+		},
+		scope: {
+			type: 'string',
+			label: i18n.ts._registry.scope,
+			default: scope.join('/'),
+		},
+	});
+	if (canceled) return;
+	os.apiWithDialog('i/registry/set', {
+		scope: result.scope.split('/'),
+		key: result.key,
+		value: JSON5.parse(result.value),
+	}).then(() => {
+		fetchKeys();
+	});
+}
+
+watch(() => props.path, fetchKeys, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'ti ti-adjustments',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue
new file mode 100644
index 0000000000..378420b1ba
--- /dev/null
+++ b/packages/frontend/src/pages/registry.value.vue
@@ -0,0 +1,123 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600" :margin-min="16">
+		<FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo>
+
+		<template v-if="value">
+			<FormSplit>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ i18n.ts._registry.domain }}</template>
+					<template #value>{{ i18n.ts.system }}</template>
+				</MkKeyValue>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ i18n.ts._registry.scope }}</template>
+					<template #value>{{ scope.join('/') }}</template>
+				</MkKeyValue>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ i18n.ts._registry.key }}</template>
+					<template #value>{{ key }}</template>
+				</MkKeyValue>
+			</FormSplit>
+			
+			<FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace">
+				<template #label>{{ i18n.ts.value }} (JSON)</template>
+			</FormTextarea>
+
+			<MkButton class="_formBlock" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.updatedAt }}</template>
+				<template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+			</MkKeyValue>
+
+			<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+		</template>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormInfo from '@/components/MkInfo.vue';
+
+const props = defineProps<{
+	path: string;
+}>();
+
+const scope = $computed(() => props.path.split('/').slice(0, -1));
+const key = $computed(() => props.path.split('/').at(-1));
+
+let value = $ref(null);
+let valueForEditor = $ref(null);
+
+function fetchValue() {
+	os.api('i/registry/get-detail', {
+		scope,
+		key,
+	}).then(res => {
+		value = res;
+		valueForEditor = JSON5.stringify(res.value, null, '\t');
+	});
+}
+
+async function save() {
+	try {
+		JSON5.parse(valueForEditor);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts.invalidValue,
+		});
+		return;
+	}
+	os.confirm({
+		type: 'warning',
+		text: i18n.ts.saveConfirm,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.apiWithDialog('i/registry/set', {
+			scope,
+			key,
+			value: JSON5.parse(valueForEditor),
+		});
+	});
+}
+
+function del() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.apiWithDialog('i/registry/remove', {
+			scope,
+			key,
+		});
+	});
+}
+
+watch(() => props.path, fetchValue, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'ti ti-adjustments',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue
new file mode 100644
index 0000000000..a2c65294fc
--- /dev/null
+++ b/packages/frontend/src/pages/registry.vue
@@ -0,0 +1,74 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600" :margin-min="16">
+		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
+
+		<FormSection v-if="scopes">
+			<template #label>{{ i18n.ts.system }}</template>
+			<div class="_formLinks">
+				<FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+			</div>
+		</FormSection>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+
+let scopes = $ref(null);
+
+function fetchScopes() {
+	os.api('i/registry/scopes').then(res => {
+		scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+	});
+}
+
+async function createKey() {
+	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
+		key: {
+			type: 'string',
+			label: i18n.ts._registry.key,
+		},
+		value: {
+			type: 'string',
+			multiline: true,
+			label: i18n.ts.value,
+		},
+		scope: {
+			type: 'string',
+			label: i18n.ts._registry.scope,
+		},
+	});
+	if (canceled) return;
+	os.apiWithDialog('i/registry/set', {
+		scope: result.scope.split('/'),
+		key: result.key,
+		value: JSON5.parse(result.value),
+	}).then(() => {
+		fetchScopes();
+	});
+}
+
+fetchScopes();
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'ti ti-adjustments',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue
new file mode 100644
index 0000000000..8ec15f6425
--- /dev/null
+++ b/packages/frontend/src/pages/reset-password.vue
@@ -0,0 +1,59 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
+		<div class="_formRoot">
+			<FormInput v-model="password" type="password" class="_formBlock">
+				<template #prefix><i class="ti ti-lock"></i></template>
+				<template #label>{{ i18n.ts.newPassword }}</template>
+			</FormInput>
+		
+			<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, onMounted } from 'vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	token?: string;
+}>();
+
+let password = $ref('');
+
+async function save() {
+	await os.apiWithDialog('reset-password', {
+		token: props.token,
+		password: password,
+	});
+	mainRouter.push('/');
+}
+
+onMounted(() => {
+	if (props.token == null) {
+		os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed');
+		mainRouter.push('/');
+	}
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.resetPassword,
+	icon: 'ti ti-lock',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
new file mode 100644
index 0000000000..edb2d8e18c
--- /dev/null
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -0,0 +1,137 @@
+<template>
+<div class="iltifgqe">
+	<div class="editor _panel _gap">
+		<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
+		<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
+	</div>
+
+	<MkContainer :foldable="true" class="_gap">
+		<template #header>{{ i18n.ts.output }}</template>
+		<div class="bepmlvbi">
+			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+		</div>
+	</MkContainer>
+
+	<div class="_gap">
+		{{ i18n.ts.scratchpadDescription }}
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import 'prismjs';
+import { highlight, languages } from 'prismjs/components/prism-core';
+import 'prismjs/components/prism-clike';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/themes/prism-okaidia.css';
+import { PrismEditor } from 'vue-prism-editor';
+import 'vue-prism-editor/dist/prismeditor.min.css';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import MkContainer from '@/components/MkContainer.vue';
+import MkButton from '@/components/MkButton.vue';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const code = ref('');
+const logs = ref<any[]>([]);
+
+const saved = localStorage.getItem('scratchpad');
+if (saved) {
+	code.value = saved;
+}
+
+watch(code, () => {
+	localStorage.setItem('scratchpad', code.value);
+});
+
+async function run() {
+	logs.value = [];
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'scratchpad',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			logs.value.push({
+				id: Math.random(),
+				text: value.type === 'str' ? value.value : utils.valToString(value),
+				print: true,
+			});
+		},
+		log: (type, params) => {
+			switch (type) {
+				case 'end': logs.value.push({
+					id: Math.random(),
+					text: utils.valToString(params.val, true),
+					print: false,
+				}); break;
+				default: break;
+			}
+		},
+	});
+
+	let ast;
+	try {
+		ast = parse(code.value);
+	} catch (error) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+	try {
+		await aiscript.exec(ast);
+	} catch (error: any) {
+		os.alert({
+			type: 'error',
+			text: error.message,
+		});
+	}
+}
+
+function highlighter(code) {
+	return highlight(code, languages.js, 'javascript');
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.scratchpad,
+	icon: 'ti ti-terminal-2',
+});
+</script>
+
+<style lang="scss" scoped>
+.iltifgqe {
+	padding: 16px;
+
+	> .editor {
+		position: relative;
+	}
+}
+
+.bepmlvbi {
+	padding: 16px;
+
+	> .log {
+		&:not(.print) {
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
new file mode 100644
index 0000000000..c080b763bb
--- /dev/null
+++ b/packages/frontend/src/pages/search.vue
@@ -0,0 +1,38 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XNotes ref="notes" :pagination="pagination"/>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XNotes from '@/components/MkNotes.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	query: string;
+	channel?: string;
+}>();
+
+const pagination = {
+	endpoint: 'notes/search' as const,
+	limit: 10,
+	params: computed(() => ({
+		query: props.query,
+		channelId: props.channel,
+	})),
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.t('searchWith', { q: props.query }),
+	icon: 'ti ti-search',
+})));
+</script>
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
new file mode 100644
index 0000000000..1803129aaa
--- /dev/null
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -0,0 +1,216 @@
+<template>
+<div>
+	<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton>
+	<template v-if="$i.twoFactorEnabled">
+		<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
+		<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
+
+		<template v-if="supportsCredentials">
+			<hr class="totp-method-sep">
+
+			<h2 class="heading">{{ i18n.ts.securityKey }}</h2>
+			<p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
+			<div class="key-list">
+				<div v-for="key in $i.securityKeysList" class="key">
+					<h3>{{ key.name }}</h3>
+					<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
+					<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton>
+				</div>
+			</div>
+
+			<MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch>
+
+			<MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo>
+			<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton>
+
+			<ol v-if="registration && !registration.error">
+				<li v-if="registration.stage >= 0">
+					{{ i18n.ts.tapSecurityKey }}
+					<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
+				</li>
+				<li v-if="registration.stage >= 1">
+					<MkForm :disabled="registration.stage != 1 || registration.saving">
+						<MkInput v-model="keyName" :max="30">
+							<template #label>{{ i18n.ts.securityKeyName }}</template>
+						</MkInput>
+						<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton>
+						<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/>
+					</MkForm>
+				</li>
+			</ol>
+		</template>
+	</template>
+	<div v-if="twoFactorData && !$i.twoFactorEnabled">
+		<ol style="margin: 0; padding: 0 0 0 1em;">
+			<li>
+				<I18n :src="i18n.ts._2fa.step1" tag="span">
+					<template #a>
+						<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
+					</template>
+					<template #b>
+						<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
+					</template>
+				</I18n>
+			</li>
+			<li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li>
+			<li>
+				{{ i18n.ts._2fa.step3 }}<br>
+				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
+				<MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton>
+			</li>
+		</ol>
+		<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { hostname } from '@/config';
+import { byteify, hexify, stringify } from '@/scripts/2fa';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const twoFactorData = ref<any>(null);
+const supportsCredentials = ref(!!navigator.credentials);
+const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
+const registration = ref<any>(null);
+const keyName = ref('');
+const token = ref(null);
+
+function register() {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		os.api('i/2fa/register', {
+			password: password,
+		}).then(data => {
+			twoFactorData.value = data;
+		});
+	});
+}
+
+function unregister() {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		os.api('i/2fa/unregister', {
+			password: password,
+		}).then(() => {
+			usePasswordLessLogin.value = false;
+			updatePasswordLessLogin();
+		}).then(() => {
+			os.success();
+			$i!.twoFactorEnabled = false;
+		});
+	});
+}
+
+function submit() {
+	os.api('i/2fa/done', {
+		token: token.value,
+	}).then(() => {
+		os.success();
+		$i!.twoFactorEnabled = true;
+	}).catch(err => {
+		os.alert({
+			type: 'error',
+			text: err,
+		});
+	});
+}
+
+function registerKey() {
+	registration.value.saving = true;
+	os.api('i/2fa/key-done', {
+		password: registration.value.password,
+		name: keyName.value,
+		challengeId: registration.value.challengeId,
+		// we convert each 16 bits to a string to serialise
+		clientDataJSON: stringify(registration.value.credential.response.clientDataJSON),
+		attestationObject: hexify(registration.value.credential.response.attestationObject),
+	}).then(key => {
+		registration.value = null;
+		key.lastUsed = new Date();
+		os.success();
+	});
+}
+
+function unregisterKey(key) {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		return os.api('i/2fa/remove-key', {
+			password,
+			credentialId: key.id,
+		}).then(() => {
+			usePasswordLessLogin.value = false;
+			updatePasswordLessLogin();
+		}).then(() => {
+			os.success();
+		});
+	});
+}
+
+function addSecurityKey() {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		os.api('i/2fa/register-key', {
+			password,
+		}).then(reg => {
+			registration.value = {
+				password,
+				challengeId: reg!.challengeId,
+				stage: 0,
+				publicKeyOptions: {
+					challenge: byteify(reg!.challenge, 'base64'),
+					rp: {
+						id: hostname,
+						name: 'Misskey',
+					},
+					user: {
+						id: byteify($i!.id, 'ascii'),
+						name: $i!.username,
+						displayName: $i!.name,
+					},
+					pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
+					timeout: 60000,
+					attestation: 'direct',
+				},
+				saving: true,
+			};
+			return navigator.credentials.create({
+				publicKey: registration.value.publicKeyOptions,
+			});
+		}).then(credential => {
+			registration.value.credential = credential;
+			registration.value.saving = false;
+			registration.value.stage = 1;
+		}).catch(err => {
+			console.warn('Error while registering?', err);
+			registration.value.error = err.message;
+			registration.value.stage = -1;
+		});
+	});
+}
+
+async function updatePasswordLessLogin() {
+	await os.api('i/2fa/password-less', {
+		value: !!usePasswordLessLogin.value,
+	});
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue
new file mode 100644
index 0000000000..ccd99c162a
--- /dev/null
+++ b/packages/frontend/src/pages/settings/account-info.vue
@@ -0,0 +1,158 @@
+<template>
+<div class="_formRoot">
+	<MkKeyValue>
+		<template #key>ID</template>
+		<template #value><span class="_monospace">{{ $i.id }}</span></template>
+	</MkKeyValue>
+
+	<FormSection>
+		<MkKeyValue>
+			<template #key>{{ i18n.ts.registeredDate }}</template>
+			<template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
+		</MkKeyValue>
+	</FormSection>
+
+	<FormSection v-if="stats">
+		<template #label>{{ i18n.ts.statistics }}</template>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.notesCount }}</template>
+			<template #value>{{ number(stats.notesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.repliesCount }}</template>
+			<template #value>{{ number(stats.repliesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.renotesCount }}</template>
+			<template #value>{{ number(stats.renotesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.repliedCount }}</template>
+			<template #value>{{ number(stats.repliedCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.renotedCount }}</template>
+			<template #value>{{ number(stats.renotedCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.pollVotesCount }}</template>
+			<template #value>{{ number(stats.pollVotesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.pollVotedCount }}</template>
+			<template #value>{{ number(stats.pollVotedCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.sentReactionsCount }}</template>
+			<template #value>{{ number(stats.sentReactionsCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.receivedReactionsCount }}</template>
+			<template #value>{{ number(stats.receivedReactionsCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.noteFavoritesCount }}</template>
+			<template #value>{{ number(stats.noteFavoritesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followingCount }}</template>
+			<template #value>{{ number(stats.followingCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template>
+			<template #value>{{ number(stats.localFollowingCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template>
+			<template #value>{{ number(stats.remoteFollowingCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followersCount }}</template>
+			<template #value>{{ number(stats.followersCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template>
+			<template #value>{{ number(stats.localFollowersCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template>
+			<template #value>{{ number(stats.remoteFollowersCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.pageLikesCount }}</template>
+			<template #value>{{ number(stats.pageLikesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.pageLikedCount }}</template>
+			<template #value>{{ number(stats.pageLikedCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.driveFilesCount }}</template>
+			<template #value>{{ number(stats.driveFilesCount) }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>{{ i18n.ts.driveUsage }}</template>
+			<template #value>{{ bytes(stats.driveUsage) }}</template>
+		</MkKeyValue>
+	</FormSection>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.other }}</template>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>emailVerified</template>
+			<template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>twoFactorEnabled</template>
+			<template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>securityKeys</template>
+			<template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>usePasswordLessLogin</template>
+			<template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>isModerator</template>
+			<template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+		<MkKeyValue oneline style="margin: 1em 0;">
+			<template #key>isAdmin</template>
+			<template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template>
+		</MkKeyValue>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import FormSection from '@/components/form/section.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const stats = ref<any>({});
+
+onMounted(() => {
+	os.api('users/stats', {
+		userId: $i!.id,
+	}).then(response => {
+		stats.value = response;
+	});
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accountInfo,
+	icon: 'ti ti-info-circle',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
new file mode 100644
index 0000000000..493d3b2618
--- /dev/null
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -0,0 +1,143 @@
+<template>
+<div class="_formRoot">
+	<FormSuspense :p="init">
+		<FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton>
+
+		<div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)">
+			<div class="avatar">
+				<MkAvatar :user="account" class="avatar"/>
+			</div>
+			<div class="body">
+				<div class="name">
+					<MkUserName :user="account"/>
+				</div>
+				<div class="acct">
+					<MkAcct :user="account"/>
+				</div>
+			</div>
+		</div>
+	</FormSuspense>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const storedAccounts = ref<any>(null);
+const accounts = ref<any>(null);
+
+const init = async () => {
+	getAccounts().then(accounts => {
+		storedAccounts.value = accounts.filter(x => x.id !== $i!.id);
+
+		console.log(storedAccounts.value);
+
+		return os.api('users/show', {
+			userIds: storedAccounts.value.map(x => x.id),
+		});
+	}).then(response => {
+		accounts.value = response;
+		console.log(accounts.value);
+	});
+};
+
+function menu(account, ev) {
+	os.popupMenu([{
+		text: i18n.ts.switch,
+		icon: 'ti ti-switch-horizontal',
+		action: () => switchAccount(account),
+	}, {
+		text: i18n.ts.remove,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: () => removeAccount(account),
+	}], ev.currentTarget ?? ev.target);
+}
+
+function addAccount(ev) {
+	os.popupMenu([{
+		text: i18n.ts.existingAccount,
+		action: () => { addExistingAccount(); },
+	}, {
+		text: i18n.ts.createAccount,
+		action: () => { createAccount(); },
+	}], ev.currentTarget ?? ev.target);
+}
+
+function removeAccount(account) {
+	_removeAccount(account.id);
+}
+
+function addExistingAccount() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+		done: res => {
+			addAccounts(res.id, res.i);
+			os.success();
+		},
+	}, 'closed');
+}
+
+function createAccount() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
+		done: res => {
+			addAccounts(res.id, res.i);
+			switchAccountWithToken(res.i);
+		},
+	}, 'closed');
+}
+
+async function switchAccount(account: any) {
+	const fetchedAccounts: any[] = await getAccounts();
+	const token = fetchedAccounts.find(x => x.id === account.id).token;
+	switchAccountWithToken(token);
+}
+
+function switchAccountWithToken(token: string) {
+	login(token);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accounts,
+	icon: 'ti ti-users',
+});
+</script>
+
+<style lang="scss" scoped>
+.lcjjdxlm {
+	display: flex;
+	padding: 16px;
+
+	> .avatar {
+		display: block;
+		flex-shrink: 0;
+		margin: 0 12px 0 0;
+
+		> .avatar {
+			width: 50px;
+			height: 50px;
+		}
+	}
+
+	> .body {
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		width: calc(100% - 62px);
+		position: relative;
+
+		> .name {
+			font-weight: bold;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue
new file mode 100644
index 0000000000..8d7291cd10
--- /dev/null
+++ b/packages/frontend/src/pages/settings/api.vue
@@ -0,0 +1,46 @@
+<template>
+<div class="_formRoot">
+	<FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton>
+	<FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink>
+	<FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const isDesktop = ref(window.innerWidth >= 1100);
+
+function generateToken() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
+		done: async result => {
+			const { name, permissions } = result;
+			const { token } = await os.api('miauth/gen-token', {
+				session: null,
+				name: name,
+				permission: permissions,
+			});
+
+			os.alert({
+				type: 'success',
+				title: i18n.ts.token,
+				text: token,
+			});
+		},
+	}, 'closed');
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API',
+	icon: 'ti ti-api',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue
new file mode 100644
index 0000000000..05abadff23
--- /dev/null
+++ b/packages/frontend/src/pages/settings/apps.vue
@@ -0,0 +1,96 @@
+<template>
+<div class="_formRoot">
+	<FormPagination ref="list" :pagination="pagination">
+		<template #empty>
+			<div class="_fullinfo">
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+				<div>{{ i18n.ts.nothing }}</div>
+			</div>
+		</template>
+		<template #default="{items}">
+			<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
+				<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
+				<div class="body">
+					<div class="name">{{ token.name }}</div>
+					<div class="description">{{ token.description }}</div>
+					<div class="_keyValue">
+						<div>{{ i18n.ts.installedDate }}:</div>
+						<div><MkTime :time="token.createdAt"/></div>
+					</div>
+					<div class="_keyValue">
+						<div>{{ i18n.ts.lastUsedDate }}:</div>
+						<div><MkTime :time="token.lastUsedAt"/></div>
+					</div>
+					<div class="actions">
+						<button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button>
+					</div>
+					<details>
+						<summary>{{ i18n.ts.details }}</summary>
+						<ul>
+							<li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+						</ul>
+					</details>
+				</div>
+			</div>
+		</template>
+	</FormPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import FormPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const list = ref<any>(null);
+
+const pagination = {
+	endpoint: 'i/apps' as const,
+	limit: 100,
+	params: {
+		sort: '+lastUsedAt',
+	},
+};
+
+function revoke(token) {
+	os.api('i/revoke-token', { tokenId: token.id }).then(() => {
+		list.value.reload();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.installedApps,
+	icon: 'ti ti-plug',
+});
+</script>
+
+<style lang="scss" scoped>
+.bfomjevm {
+	display: flex;
+	padding: 16px;
+
+	> .icon {
+		display: block;
+		flex-shrink: 0;
+		margin: 0 12px 0 0;
+		width: 50px;
+		height: 50px;
+		border-radius: 8px;
+	}
+
+	> .body {
+		width: calc(100% - 62px);
+		position: relative;
+
+		> .name {
+			font-weight: bold;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue
new file mode 100644
index 0000000000..2caad22b7b
--- /dev/null
+++ b/packages/frontend/src/pages/settings/custom-css.vue
@@ -0,0 +1,46 @@
+<template>
+<div class="_formRoot">
+	<FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo>
+
+	<FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;">
+		<template #label>CSS</template>
+	</FormTextarea>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
+
+async function apply() {
+	localStorage.setItem('customCss', localCustomCss.value);
+
+	const { canceled } = await os.confirm({
+		type: 'info',
+		text: i18n.ts.reloadToApplySetting,
+	});
+	if (canceled) return;
+
+	unisonReload();
+}
+
+watch(localCustomCss, async () => {
+	await apply();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.customCss,
+	icon: 'ti ti-code',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
new file mode 100644
index 0000000000..82cefe05d5
--- /dev/null
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -0,0 +1,39 @@
+<template>
+<div class="_formRoot">
+	<FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch>
+
+	<FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch>
+
+	<FormRadios v-model="columnAlign" class="_formBlock">
+		<template #label>{{ i18n.ts._deck.columnAlign }}</template>
+		<option value="left">{{ i18n.ts.left }}</option>
+		<option value="center">{{ i18n.ts.center }}</option>
+	</FormRadios>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormLink from '@/components/form/link.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormInput from '@/components/form/input.vue';
+import { deckStore } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
+const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
+const columnAlign = computed(deckStore.makeGetterSetter('columnAlign'));
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.deck,
+	icon: 'ti ti-columns',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue
new file mode 100644
index 0000000000..8a25ff39f0
--- /dev/null
+++ b/packages/frontend/src/pages/settings/delete-account.vue
@@ -0,0 +1,52 @@
+<template>
+<div class="_formRoot">
+	<FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo>
+	<FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo>
+	<FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton>
+	<FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import FormInfo from '@/components/MkInfo.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { signout } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+async function deleteAccount() {
+	{
+		const { canceled } = await os.confirm({
+			type: 'warning',
+			text: i18n.ts.deleteAccountConfirm,
+		});
+		if (canceled) return;
+	}
+
+	const { canceled, result: password } = await os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	});
+	if (canceled) return;
+
+	await os.apiWithDialog('i/delete-account', {
+		password: password,
+	});
+
+	await os.alert({
+		title: i18n.ts._accountDelete.started,
+	});
+
+	await signout();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._accountDelete.accountDelete,
+	icon: 'ti ti-alert-triangle',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
new file mode 100644
index 0000000000..2d45b1add8
--- /dev/null
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -0,0 +1,145 @@
+<template>
+<div class="_formRoot">
+	<FormSection v-if="!fetching">
+		<template #label>{{ i18n.ts.usageAmount }}</template>
+		<div class="_formBlock uawsfosz">
+			<div class="meter"><div :style="meterStyle"></div></div>
+		</div>
+		<FormSplit>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.capacity }}</template>
+				<template #value>{{ bytes(capacity, 1) }}</template>
+			</MkKeyValue>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.inUse }}</template>
+				<template #value>{{ bytes(usage, 1) }}</template>
+			</MkKeyValue>
+		</FormSplit>
+	</FormSection>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.statistics }}</template>
+		<MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+	</FormSection>
+
+	<FormSection>
+		<FormLink @click="chooseUploadFolder()">
+			{{ i18n.ts.uploadFolder }}
+			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
+			<template #suffixIcon><i class="fas fa-folder-open"></i></template>
+		</FormLink>
+		<FormSwitch v-model="keepOriginalUploading" class="_formBlock">
+			<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
+			<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
+		</FormSwitch>
+		<FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()">
+			<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
+		</FormSwitch>
+		<FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()">
+			<template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+			<template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template>
+		</FormSwitch>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import tinycolor from 'tinycolor2';
+import FormLink from '@/components/form/link.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSection from '@/components/form/section.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import { defaultStore } from '@/store';
+import MkChart from '@/components/MkChart.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+const fetching = ref(true);
+const usage = ref<any>(null);
+const capacity = ref<any>(null);
+const uploadFolder = ref<any>(null);
+let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw);
+let autoSensitive = $ref($i.autoSensitive);
+
+const meterStyle = computed(() => {
+	return {
+		width: `${usage.value / capacity.value * 100}%`,
+		background: tinycolor({
+			h: 180 - (usage.value / capacity.value * 180),
+			s: 0.7,
+			l: 0.5,
+		}),
+	};
+});
+
+const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
+
+os.api('drive').then(info => {
+	capacity.value = info.capacity;
+	usage.value = info.usage;
+	fetching.value = false;
+});
+
+if (defaultStore.state.uploadFolder) {
+	os.api('drive/folders/show', {
+		folderId: defaultStore.state.uploadFolder,
+	}).then(response => {
+		uploadFolder.value = response;
+	});
+}
+
+function chooseUploadFolder() {
+	os.selectDriveFolder(false).then(async folder => {
+		defaultStore.set('uploadFolder', folder ? folder.id : null);
+		os.success();
+		if (defaultStore.state.uploadFolder) {
+			uploadFolder.value = await os.api('drive/folders/show', {
+				folderId: defaultStore.state.uploadFolder,
+			});
+		} else {
+			uploadFolder.value = null;
+		}
+	});
+}
+
+function saveProfile() {
+	os.api('i/update', {
+		alwaysMarkNsfw: !!alwaysMarkNsfw,
+		autoSensitive: !!autoSensitive,
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.drive,
+	icon: 'ti ti-cloud',
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.uawsfosz {
+
+	> .meter {
+		$size: 12px;
+		background: rgba(0, 0, 0, 0.1);
+		border-radius: math.div($size, 2);
+		overflow: hidden;
+
+		> div {
+			height: $size;
+			border-radius: math.div($size, 2);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
new file mode 100644
index 0000000000..3fff8c6b1d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -0,0 +1,111 @@
+<template>
+<div class="_formRoot">
+	<FormSection>
+		<template #label>{{ i18n.ts.emailAddress }}</template>
+		<FormInput v-model="emailAddress" type="email" manual-save>
+			<template #prefix><i class="ti ti-mail"></i></template>
+			<template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template>
+			<template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template>
+		</FormInput>
+	</FormSection>
+
+	<FormSection>
+		<FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail">
+			{{ i18n.ts.receiveAnnouncementFromInstance }}
+		</FormSwitch>
+	</FormSection>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.emailNotification }}</template>
+		<FormSwitch v-model="emailNotification_mention" class="_formBlock">
+			{{ i18n.ts._notification._types.mention }}
+		</FormSwitch>
+		<FormSwitch v-model="emailNotification_reply" class="_formBlock">
+			{{ i18n.ts._notification._types.reply }}
+		</FormSwitch>
+		<FormSwitch v-model="emailNotification_quote" class="_formBlock">
+			{{ i18n.ts._notification._types.quote }}
+		</FormSwitch>
+		<FormSwitch v-model="emailNotification_follow" class="_formBlock">
+			{{ i18n.ts._notification._types.follow }}
+		</FormSwitch>
+		<FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock">
+			{{ i18n.ts._notification._types.receiveFollowRequest }}
+		</FormSwitch>
+		<FormSwitch v-model="emailNotification_groupInvited" class="_formBlock">
+			{{ i18n.ts._notification._types.groupInvited }}
+		</FormSwitch>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import FormSection from '@/components/form/section.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const emailAddress = ref($i!.email);
+
+const onChangeReceiveAnnouncementEmail = (v) => {
+	os.api('i/update', {
+		receiveAnnouncementEmail: v,
+	});
+};
+
+const saveEmailAddress = () => {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		os.apiWithDialog('i/update-email', {
+			password: password,
+			email: emailAddress.value,
+		});
+	});
+};
+
+const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
+const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
+const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote'));
+const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow'));
+const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest'));
+const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited'));
+
+const saveNotificationSettings = () => {
+	os.api('i/update', {
+		emailNotificationTypes: [
+			...[emailNotification_mention.value ? 'mention' : null],
+			...[emailNotification_reply.value ? 'reply' : null],
+			...[emailNotification_quote.value ? 'quote' : null],
+			...[emailNotification_follow.value ? 'follow' : null],
+			...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
+			...[emailNotification_groupInvited.value ? 'groupInvited' : null],
+		].filter(x => x != null),
+	});
+};
+
+watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => {
+	saveNotificationSettings();
+});
+
+onMounted(() => {
+	watch(emailAddress, () => {
+		saveEmailAddress();
+	});
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.email,
+	icon: 'ti ti-mail',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
new file mode 100644
index 0000000000..84d99d2fd7
--- /dev/null
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -0,0 +1,196 @@
+<template>
+<div class="_formRoot">
+	<FormSelect v-model="lang" class="_formBlock">
+		<template #label>{{ i18n.ts.uiLanguage }}</template>
+		<option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option>
+		<template #caption>
+			<I18n :src="i18n.ts.i18nInfo" tag="span">
+				<template #link>
+					<MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
+				</template>
+			</I18n>
+		</template>
+	</FormSelect>
+
+	<FormRadios v-model="overridedDeviceKind" class="_formBlock">
+		<template #label>{{ i18n.ts.overridedDeviceKind }}</template>
+		<option :value="null">{{ i18n.ts.auto }}</option>
+		<option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option>
+		<option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option>
+		<option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option>
+	</FormRadios>
+
+	<FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.behavior }}</template>
+		<FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch>
+		<FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch>
+		<FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch>
+		<FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch>
+
+		<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
+			<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
+			<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
+			<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
+			<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
+		</FormSelect>
+	</FormSection>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.appearance }}</template>
+		<FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch>
+		<FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch>
+		<FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch>
+		<FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch>
+		<FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch>
+		<FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch>
+		<FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
+		<FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
+		<FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
+		<div class="_formBlock">
+			<FormRadios v-model="emojiStyle">
+				<template #label>{{ i18n.ts.emojiStyle }}</template>
+				<option value="native">{{ i18n.ts.native }}</option>
+				<option value="fluentEmoji">Fluent Emoji</option>
+				<option value="twemoji">Twemoji</option>
+			</FormRadios>
+			<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
+		</div>
+
+		<FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
+
+		<FormRadios v-model="fontSize" class="_formBlock">
+			<template #label>{{ i18n.ts.fontSize }}</template>
+			<option :value="null"><span style="font-size: 14px;">Aa</span></option>
+			<option value="1"><span style="font-size: 15px;">Aa</span></option>
+			<option value="2"><span style="font-size: 16px;">Aa</span></option>
+			<option value="3"><span style="font-size: 17px;">Aa</span></option>
+		</FormRadios>
+	</FormSection>
+
+	<FormSection>
+		<FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch>
+	</FormSection>
+
+	<FormSelect v-model="instanceTicker" class="_formBlock">
+		<template #label>{{ i18n.ts.instanceTicker }}</template>
+		<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
+		<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
+		<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
+	</FormSelect>
+
+	<FormSelect v-model="nsfw" class="_formBlock">
+		<template #label>{{ i18n.ts.nsfw }}</template>
+		<option value="respect">{{ i18n.ts._nsfw.respect }}</option>
+		<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
+		<option value="force">{{ i18n.ts._nsfw.force }}</option>
+	</FormSelect>
+
+	<FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock">
+		<template #label>{{ i18n.ts.numberOfPageCache }}</template>
+		<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
+	</FormRange>
+
+	<FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink>
+
+	<FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormRange from '@/components/form/range.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import MkLink from '@/components/MkLink.vue';
+import { langs } from '@/config';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const lang = ref(localStorage.getItem('lang'));
+const fontSize = ref(localStorage.getItem('fontSize'));
+const useSystemFont = ref(localStorage.getItem('useSystemFont') != null);
+
+async function reloadAsk() {
+	const { canceled } = await os.confirm({
+		type: 'info',
+		text: i18n.ts.reloadToApplySetting,
+	});
+	if (canceled) return;
+
+	unisonReload();
+}
+
+const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
+const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
+const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
+const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
+const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
+const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
+const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v));
+const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
+const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
+const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
+const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
+const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
+const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
+const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript'));
+const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
+const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
+const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
+const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
+const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
+const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
+const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode'));
+
+watch(lang, () => {
+	localStorage.setItem('lang', lang.value as string);
+	localStorage.removeItem('locale');
+});
+
+watch(fontSize, () => {
+	if (fontSize.value == null) {
+		localStorage.removeItem('fontSize');
+	} else {
+		localStorage.setItem('fontSize', fontSize.value);
+	}
+});
+
+watch(useSystemFont, () => {
+	if (useSystemFont.value) {
+		localStorage.setItem('useSystemFont', 't');
+	} else {
+		localStorage.removeItem('useSystemFont');
+	}
+});
+
+watch([
+	lang,
+	fontSize,
+	useSystemFont,
+	enableInfiniteScroll,
+	squareAvatars,
+	aiChanMode,
+	showGapBetweenNotesInTimeline,
+	instanceTicker,
+	overridedDeviceKind,
+], async () => {
+	await reloadAsk();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'ti ti-adjustments',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
new file mode 100644
index 0000000000..7db267c142
--- /dev/null
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="_formRoot">
+	<FormSection>
+		<template #label>{{ i18n.ts._exportOrImport.allNotes }}</template>
+		<FormFolder>
+			<template #label>{{ i18n.ts.export }}</template>
+			<template #icon><i class="ti ti-download"></i></template>
+			<MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+		</FormFolder>
+	</FormSection>
+	<FormSection>
+		<template #label>{{ i18n.ts._exportOrImport.followingList }}</template>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.export }}</template>
+			<template #icon><i class="ti ti-download"></i></template>
+			<FormSwitch v-model="excludeMutingUsers" class="_formBlock">
+				{{ i18n.ts._exportOrImport.excludeMutingUsers }}
+			</FormSwitch>
+			<FormSwitch v-model="excludeInactiveUsers" class="_formBlock">
+				{{ i18n.ts._exportOrImport.excludeInactiveUsers }}
+			</FormSwitch>
+			<MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.import }}</template>
+			<template #icon><i class="ti ti-upload"></i></template>
+			<MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+		</FormFolder>
+	</FormSection>
+	<FormSection>
+		<template #label>{{ i18n.ts._exportOrImport.userLists }}</template>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.export }}</template>
+			<template #icon><i class="ti ti-download"></i></template>
+			<MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.import }}</template>
+			<template #icon><i class="ti ti-upload"></i></template>
+			<MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+		</FormFolder>
+	</FormSection>
+	<FormSection>
+		<template #label>{{ i18n.ts._exportOrImport.muteList }}</template>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.export }}</template>
+			<template #icon><i class="ti ti-download"></i></template>
+			<MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.import }}</template>
+			<template #icon><i class="ti ti-upload"></i></template>
+			<MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+		</FormFolder>
+	</FormSection>
+	<FormSection>
+		<template #label>{{ i18n.ts._exportOrImport.blockingList }}</template>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.export }}</template>
+			<template #icon><i class="ti ti-download"></i></template>
+			<MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+		</FormFolder>
+		<FormFolder class="_formBlock">
+			<template #label>{{ i18n.ts.import }}</template>
+			<template #icon><i class="ti ti-upload"></i></template>
+			<MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+		</FormFolder>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const excludeMutingUsers = ref(false);
+const excludeInactiveUsers = ref(false);
+
+const onExportSuccess = () => {
+	os.alert({
+		type: 'info',
+		text: i18n.ts.exportRequested,
+	});
+};
+
+const onImportSuccess = () => {
+	os.alert({
+		type: 'info',
+		text: i18n.ts.importRequested,
+	});
+};
+
+const onError = (ev) => {
+	os.alert({
+		type: 'error',
+		text: ev.message,
+	});
+};
+
+const exportNotes = () => {
+	os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportFollowing = () => {
+	os.api('i/export-following', {
+		excludeMuting: excludeMutingUsers.value,
+		excludeInactive: excludeInactiveUsers.value,
+	})
+		.then(onExportSuccess).catch(onError);
+};
+
+const exportBlocking = () => {
+	os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportUserLists = () => {
+	os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportMuting = () => {
+	os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
+};
+
+const importFollowing = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importUserLists = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importMuting = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importBlocking = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.importAndExport,
+	icon: 'ti ti-package',
+});
+</script>
+
+<style module>
+.button {
+	margin-right: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
new file mode 100644
index 0000000000..01436cd554
--- /dev/null
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -0,0 +1,291 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+		<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
+			<div class="body">
+				<div v-if="!narrow || currentPage?.route.name == null" class="nav">
+					<div class="baaadecd">
+						<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+						<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
+					</div>
+				</div>
+				<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
+					<div class="bkzroven">
+						<RouterView/>
+					</div>
+				</div>
+			</div>
+		</div>
+	</MkSpacer>
+</mkstickycontainer>
+</template>
+
+<script setup lang="ts">
+import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkInfo from '@/components/MkInfo.vue';
+import MkSuperMenu from '@/components/MkSuperMenu.vue';
+import { scroll } from '@/scripts/scroll';
+import { signout, $i } from '@/account';
+import { unisonReload } from '@/scripts/unison-reload';
+import { instance } from '@/instance';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import * as os from '@/os';
+
+const indexInfo = {
+	title: i18n.ts.settings,
+	icon: 'ti ti-settings',
+	hideHeader: true,
+};
+const INFO = ref(indexInfo);
+const el = ref<HTMLElement | null>(null);
+const childInfo = ref(null);
+
+const router = useRouter();
+
+let narrow = $ref(false);
+const NARROW_THRESHOLD = 600;
+
+let currentPage = $computed(() => router.currentRef.value.child);
+
+const ro = new ResizeObserver((entries, observer) => {
+	if (entries.length === 0) return;
+	narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
+});
+
+const menuDef = computed(() => [{
+	title: i18n.ts.basicSettings,
+	items: [{
+		icon: 'ti ti-user',
+		text: i18n.ts.profile,
+		to: '/settings/profile',
+		active: currentPage?.route.name === 'profile',
+	}, {
+		icon: 'ti ti-lock-open',
+		text: i18n.ts.privacy,
+		to: '/settings/privacy',
+		active: currentPage?.route.name === 'privacy',
+	}, {
+		icon: 'ti ti-mood-happy',
+		text: i18n.ts.reaction,
+		to: '/settings/reaction',
+		active: currentPage?.route.name === 'reaction',
+	}, {
+		icon: 'ti ti-cloud',
+		text: i18n.ts.drive,
+		to: '/settings/drive',
+		active: currentPage?.route.name === 'drive',
+	}, {
+		icon: 'ti ti-bell',
+		text: i18n.ts.notifications,
+		to: '/settings/notifications',
+		active: currentPage?.route.name === 'notifications',
+	}, {
+		icon: 'ti ti-mail',
+		text: i18n.ts.email,
+		to: '/settings/email',
+		active: currentPage?.route.name === 'email',
+	}, {
+		icon: 'ti ti-share',
+		text: i18n.ts.integration,
+		to: '/settings/integration',
+		active: currentPage?.route.name === 'integration',
+	}, {
+		icon: 'ti ti-lock',
+		text: i18n.ts.security,
+		to: '/settings/security',
+		active: currentPage?.route.name === 'security',
+	}],
+}, {
+	title: i18n.ts.clientSettings,
+	items: [{
+		icon: 'ti ti-adjustments',
+		text: i18n.ts.general,
+		to: '/settings/general',
+		active: currentPage?.route.name === 'general',
+	}, {
+		icon: 'ti ti-palette',
+		text: i18n.ts.theme,
+		to: '/settings/theme',
+		active: currentPage?.route.name === 'theme',
+	}, {
+		icon: 'ti ti-menu-2',
+		text: i18n.ts.navbar,
+		to: '/settings/navbar',
+		active: currentPage?.route.name === 'navbar',
+	}, {
+		icon: 'ti ti-equal-double',
+		text: i18n.ts.statusbar,
+		to: '/settings/statusbar',
+		active: currentPage?.route.name === 'statusbar',
+	}, {
+		icon: 'ti ti-music',
+		text: i18n.ts.sounds,
+		to: '/settings/sounds',
+		active: currentPage?.route.name === 'sounds',
+	}, {
+		icon: 'ti ti-plug',
+		text: i18n.ts.plugins,
+		to: '/settings/plugin',
+		active: currentPage?.route.name === 'plugin',
+	}],
+}, {
+	title: i18n.ts.otherSettings,
+	items: [{
+		icon: 'ti ti-package',
+		text: i18n.ts.importAndExport,
+		to: '/settings/import-export',
+		active: currentPage?.route.name === 'import-export',
+	}, {
+		icon: 'ti ti-planet-off',
+		text: i18n.ts.instanceMute,
+		to: '/settings/instance-mute',
+		active: currentPage?.route.name === 'instance-mute',
+	}, {
+		icon: 'ti ti-ban',
+		text: i18n.ts.muteAndBlock,
+		to: '/settings/mute-block',
+		active: currentPage?.route.name === 'mute-block',
+	}, {
+		icon: 'ti ti-message-off',
+		text: i18n.ts.wordMute,
+		to: '/settings/word-mute',
+		active: currentPage?.route.name === 'word-mute',
+	}, {
+		icon: 'ti ti-api',
+		text: 'API',
+		to: '/settings/api',
+		active: currentPage?.route.name === 'api',
+	}, {
+		icon: 'ti ti-webhook',
+		text: 'Webhook',
+		to: '/settings/webhook',
+		active: currentPage?.route.name === 'webhook',
+	}, {
+		icon: 'ti ti-dots',
+		text: i18n.ts.other,
+		to: '/settings/other',
+		active: currentPage?.route.name === 'other',
+	}],
+}, {
+	items: [{
+		icon: 'ti ti-device-floppy',
+		text: i18n.ts.preferencesBackups,
+		to: '/settings/preferences-backups',
+		active: currentPage?.route.name === 'preferences-backups',
+	}, {
+		type: 'button',
+		icon: 'ti ti-trash',
+		text: i18n.ts.clearCache,
+		action: () => {
+			localStorage.removeItem('locale');
+			localStorage.removeItem('theme');
+			unisonReload();
+		},
+	}, {
+		type: 'button',
+		icon: 'ti ti-power',
+		text: i18n.ts.logout,
+		action: async () => {
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.logoutConfirm,
+			});
+			if (canceled) return;
+			signout();
+		},
+		danger: true,
+	}],
+}]);
+
+watch($$(narrow), () => {
+});
+
+onMounted(() => {
+	ro.observe(el.value);
+
+	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+	if (!narrow && currentPage?.route.name == null) {
+		router.replace('/settings/profile');
+	}
+});
+
+onActivated(() => {
+	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+	if (!narrow && currentPage?.route.name == null) {
+		router.replace('/settings/profile');
+	}
+});
+
+onUnmounted(() => {
+	ro.disconnect();
+});
+
+const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
+
+provideMetadataReceiver((info) => {
+	if (info == null) {
+		childInfo.value = null;
+	} else {
+		childInfo.value = info;
+	}
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+// w 890
+// h 700
+</script>
+
+<style lang="scss" scoped>
+.vvcocwet {
+	> .body {
+		> .nav {
+			.baaadecd {
+				> .info {
+					margin: 16px 0;
+				}
+
+				> .accounts {
+					> .avatar {
+						display: block;
+						width: 50px;
+						height: 50px;
+						margin: 8px auto 16px auto;
+					}
+				}
+			}
+		}
+
+		> .main {
+			.bkzroven {
+			}
+		}
+	}
+
+	&.wide {
+		> .body {
+			display: flex;
+			height: 100%;
+
+			> .nav {
+				width: 34%;
+				padding-right: 32px;
+				box-sizing: border-box;
+			}
+
+			> .main {
+				flex: 1;
+				min-width: 0;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue
new file mode 100644
index 0000000000..54504de188
--- /dev/null
+++ b/packages/frontend/src/pages/settings/instance-mute.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_formRoot">
+	<MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo>
+	<FormTextarea v-model="instanceMutes" class="_formBlock">
+		<template #label>{{ i18n.ts._instanceMute.heading }}</template>
+		<template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template>
+	</FormTextarea>
+	<MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const instanceMutes = ref($i!.mutedInstances.join('\n'));
+const changed = ref(false);
+
+async function save() {
+	let mutes = instanceMutes.value
+		.trim().split('\n')
+		.map(el => el.trim())
+		.filter(el => el);
+
+	await os.api('i/update', {
+		mutedInstances: mutes,
+	});
+
+	changed.value = false;
+
+	// Refresh filtered list to signal to the user how they've been saved
+	instanceMutes.value = mutes.join('\n');
+}
+
+watch(instanceMutes, () => {
+	changed.value = true;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceMute,
+	icon: 'ti ti-planet-off',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue
new file mode 100644
index 0000000000..557fe778e6
--- /dev/null
+++ b/packages/frontend/src/pages/settings/integration.vue
@@ -0,0 +1,99 @@
+<template>
+<div class="_formRoot">
+	<FormSection v-if="instance.enableTwitterIntegration">
+		<template #label><i class="ti ti-brand-twitter"></i> Twitter</template>
+		<p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
+		<MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton>
+	</FormSection>
+
+	<FormSection v-if="instance.enableDiscordIntegration">
+		<template #label><i class="ti ti-brand-discord"></i> Discord</template>
+		<p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
+		<MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton>
+	</FormSection>
+
+	<FormSection v-if="instance.enableGithubIntegration">
+		<template #label><i class="ti ti-brand-github"></i> GitHub</template>
+		<p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
+		<MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { apiUrl } from '@/config';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import { $i } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const twitterForm = ref<Window | null>(null);
+const discordForm = ref<Window | null>(null);
+const githubForm = ref<Window | null>(null);
+
+const integrations = computed(() => $i!.integrations);
+
+function openWindow(service: string, type: string) {
+	return window.open(`${apiUrl}/${type}/${service}`,
+		`${service}_${type}_window`,
+		'height=570, width=520',
+	);
+}
+
+function connectTwitter() {
+	twitterForm.value = openWindow('twitter', 'connect');
+}
+
+function disconnectTwitter() {
+	openWindow('twitter', 'disconnect');
+}
+
+function connectDiscord() {
+	discordForm.value = openWindow('discord', 'connect');
+}
+
+function disconnectDiscord() {
+	openWindow('discord', 'disconnect');
+}
+
+function connectGithub() {
+	githubForm.value = openWindow('github', 'connect');
+}
+
+function disconnectGithub() {
+	openWindow('github', 'disconnect');
+}
+
+onMounted(() => {
+	document.cookie = `igi=${$i!.token}; path=/;` +
+		' max-age=31536000;' +
+		(document.location.protocol.startsWith('https') ? ' secure' : '');
+
+	watch(integrations, () => {
+		if (integrations.value.twitter) {
+			if (twitterForm.value) twitterForm.value.close();
+		}
+		if (integrations.value.discord) {
+			if (discordForm.value) discordForm.value.close();
+		}
+		if (integrations.value.github) {
+			if (githubForm.value) githubForm.value.close();
+		}
+	});
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'ti ti-share',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..1cf33d34db
--- /dev/null
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -0,0 +1,61 @@
+<template>
+<div class="_formRoot">
+	<MkTab v-model="tab" style="margin-bottom: var(--margin);">
+		<option value="mute">{{ i18n.ts.mutedUsers }}</option>
+		<option value="block">{{ i18n.ts.blockedUsers }}</option>
+	</MkTab>
+	<div v-if="tab === 'mute'">
+		<MkPagination :pagination="mutingPagination" class="muting">
+			<template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
+			<template #default="{items}">
+				<FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
+					<MkAcct :user="mute.mutee"/>
+				</FormLink>
+			</template>
+		</MkPagination>
+	</div>
+	<div v-if="tab === 'block'">
+		<MkPagination :pagination="blockingPagination" class="blocking">
+			<template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
+			<template #default="{items}">
+				<FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
+					<MkAcct :user="block.blockee"/>
+				</FormLink>
+			</template>
+		</MkPagination>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkTab from '@/components/MkTab.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormLink from '@/components/form/link.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let tab = $ref('mute');
+
+const mutingPagination = {
+	endpoint: 'mute/list' as const,
+	limit: 10,
+};
+
+const blockingPagination = {
+	endpoint: 'blocking/list' as const,
+	limit: 10,
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.muteAndBlock,
+	icon: 'ti ti-ban',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
new file mode 100644
index 0000000000..0b2776ec90
--- /dev/null
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -0,0 +1,87 @@
+<template>
+<div class="_formRoot">
+	<FormTextarea v-model="items" tall manual-save class="_formBlock">
+		<template #label>{{ i18n.ts.navbar }}</template>
+		<template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template>
+	</FormTextarea>
+
+	<FormRadios v-model="menuDisplay" class="_formBlock">
+		<template #label>{{ i18n.ts.display }}</template>
+		<option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option>
+		<option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option>
+		<option value="top">{{ i18n.ts._menuDisplay.top }}</option>
+		<!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
+	</FormRadios>
+
+	<FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const items = ref(defaultStore.state.menu.join('\n'));
+
+const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== ''));
+const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+
+async function reloadAsk() {
+	const { canceled } = await os.confirm({
+		type: 'info',
+		text: i18n.ts.reloadToApplySetting,
+	});
+	if (canceled) return;
+
+	unisonReload();
+}
+
+async function addItem() {
+	const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k));
+	const { canceled, result: item } = await os.select({
+		title: i18n.ts.addItem,
+		items: [...menu.map(k => ({
+			value: k, text: i18n.ts[navbarItemDef[k].title],
+		})), {
+			value: '-', text: i18n.ts.divider,
+		}],
+	});
+	if (canceled) return;
+	items.value = [...split.value, item].join('\n');
+}
+
+async function save() {
+	defaultStore.set('menu', split.value);
+	await reloadAsk();
+}
+
+function reset() {
+	defaultStore.reset('menu');
+	items.value = defaultStore.state.menu.join('\n');
+}
+
+watch(items, async () => {
+	await save();
+});
+
+watch(menuDisplay, async () => {
+	await reloadAsk();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.navbar,
+	icon: 'ti ti-list',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
new file mode 100644
index 0000000000..e85fede157
--- /dev/null
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -0,0 +1,90 @@
+<template>
+<div class="_formRoot">
+	<FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink>
+	<FormSection>
+		<FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
+		<FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
+		<FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink>
+	</FormSection>
+	<FormSection>
+		<template #label>{{ i18n.ts.pushNotification }}</template>
+		<MkPushNotificationAllowButton ref="allowButton" />
+		<FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage">
+			<template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template>
+			<template #caption>
+				<I18n :src="i18n.ts.sendPushNotificationReadMessageCaption">
+					<template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template>
+				</I18n>
+			</template>
+		</FormSwitch>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import FormButton from '@/components/MkButton.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
+
+let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>();
+let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer);
+let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false);
+
+async function readAllUnreadNotes() {
+	await os.api('i/read-all-unread-notes');
+}
+
+async function readAllMessagingMessages() {
+	await os.api('i/read-all-messaging-messages');
+}
+
+async function readAllNotifications() {
+	await os.api('notifications/mark-all-as-read');
+}
+
+function configure() {
+	const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x));
+	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
+		includingTypes,
+		showGlobalToggle: false,
+	}, {
+		done: async (res) => {
+			const { includingTypes: value } = res;
+			await os.apiWithDialog('i/update', {
+				mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
+			}).then(i => {
+				$i!.mutingNotificationTypes = i.mutingNotificationTypes;
+			});
+		},
+	}, 'closed');
+}
+
+function onChangeSendReadMessage(v: boolean) {
+	if (!pushRegistrationInServer) return;
+
+	os.apiWithDialog('sw/update-registration', {
+		endpoint: pushRegistrationInServer.endpoint,
+		sendReadMessage: v,
+	}).then(res => {
+		if (!allowButton)	return;
+		allowButton.pushRegistrationInServer = res;
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notifications,
+	icon: 'ti ti-bell',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
new file mode 100644
index 0000000000..40bb202789
--- /dev/null
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="_formRoot">
+	<FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote">
+		{{ i18n.ts.showFeaturedNotesInTimeline }}
+	</FormSwitch>
+
+	<!--
+	<FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch>
+	-->
+
+	<FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink>
+
+	<FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
+
+	<FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormLink from '@/components/form/link.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const reportError = computed(defaultStore.makeGetterSetter('reportError'));
+
+function onChangeInjectFeaturedNote(v) {
+	os.api('i/update', {
+		injectFeaturedNote: v,
+	}).then((i) => {
+		$i!.injectFeaturedNote = i.injectFeaturedNote;
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'ti ti-dots',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
new file mode 100644
index 0000000000..550bba242e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -0,0 +1,124 @@
+<template>
+<div class="_formRoot">
+	<FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo>
+
+	<FormTextarea v-model="code" tall class="_formBlock">
+		<template #label>{{ i18n.ts.code }}</template>
+	</FormTextarea>
+
+	<div class="_formBlock">
+		<FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, nextTick, ref } from 'vue';
+import { AiScript, parse } from '@syuilo/aiscript';
+import { serialize } from '@syuilo/aiscript/built/serializer';
+import { v4 as uuid } from 'uuid';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const code = ref(null);
+
+function installPlugin({ id, meta, ast, token }) {
+	ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
+		...meta,
+		id,
+		active: true,
+		configData: {},
+		token: token,
+		ast: ast,
+	}));
+}
+
+async function install() {
+	let ast;
+	try {
+		ast = parse(code.value);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+
+	const meta = AiScript.collectMetadata(ast);
+	if (meta == null) {
+		os.alert({
+			type: 'error',
+			text: 'No metadata found :(',
+		});
+		return;
+	}
+
+	const metadata = meta.get(null);
+	if (metadata == null) {
+		os.alert({
+			type: 'error',
+			text: 'No metadata found :(',
+		});
+		return;
+	}
+
+	const { name, version, author, description, permissions, config } = metadata;
+	if (name == null || version == null || author == null) {
+		os.alert({
+			type: 'error',
+			text: 'Required property not found :(',
+		});
+		return;
+	}
+
+	const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
+		os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
+			title: i18n.ts.tokenRequested,
+			information: i18n.ts.pluginTokenRequestedDescription,
+			initialName: name,
+			initialPermissions: permissions,
+		}, {
+			done: async result => {
+				const { name, permissions } = result;
+				const { token } = await os.api('miauth/gen-token', {
+					session: null,
+					name: name,
+					permission: permissions,
+				});
+				res(token);
+			},
+		}, 'closed');
+	});
+
+	installPlugin({
+		id: uuid(),
+		meta: {
+			name, version, author, description, permissions, config,
+		},
+		token,
+		ast: serialize(ast),
+	});
+
+	os.success();
+
+	nextTick(() => {
+		unisonReload();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._plugin.install,
+	icon: 'ti ti-download',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
new file mode 100644
index 0000000000..905efd833d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="_formRoot">
+	<FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.manage }}</template>
+		<div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
+			<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
+
+			<FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
+
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.author }}</template>
+				<template #value>{{ plugin.author }}</template>
+			</MkKeyValue>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.description }}</template>
+				<template #value>{{ plugin.description }}</template>
+			</MkKeyValue>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ i18n.ts.permission }}</template>
+				<template #value>{{ plugin.permission }}</template>
+			</MkKeyValue>
+
+			<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+				<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
+				<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
+			</div>
+		</div>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, ref } from 'vue';
+import FormLink from '@/components/form/link.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const plugins = ref(ColdDeviceStorage.get('plugins'));
+
+function uninstall(plugin) {
+	ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
+	os.success();
+	nextTick(() => {
+		unisonReload();
+	});
+}
+
+// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
+async function config(plugin) {
+	const config = plugin.config;
+	for (const key in plugin.configData) {
+		config[key].default = plugin.configData[key];
+	}
+
+	const { canceled, result } = await os.form(plugin.name, config);
+	if (canceled) return;
+
+	const coldPlugins = ColdDeviceStorage.get('plugins');
+	coldPlugins.find(p => p.id === plugin.id)!.configData = result;
+	ColdDeviceStorage.set('plugins', coldPlugins);
+
+	nextTick(() => {
+		location.reload();
+	});
+}
+
+function changeActive(plugin, active) {
+	const coldPlugins = ColdDeviceStorage.get('plugins');
+	coldPlugins.find(p => p.id === plugin.id)!.active = active;
+	ColdDeviceStorage.set('plugins', coldPlugins);
+
+	nextTick(() => {
+		location.reload();
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.plugins,
+	icon: 'ti ti-plug',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
new file mode 100644
index 0000000000..f427a170c4
--- /dev/null
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -0,0 +1,444 @@
+<template>
+<div class="_formRoot">
+	<div :class="$style.buttons">
+		<MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton>
+		<MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton>
+	</div>
+
+	<FormSection>
+		<template #label>{{ ts._preferencesBackups.list }}</template>
+		<template v-if="profiles && Object.keys(profiles).length > 0">
+			<div
+				v-for="(profile, id) in profiles"
+				:key="id"
+				class="_formBlock _panel"
+				:class="$style.profile"
+				@click="$event => menu($event, id)"
+				@contextmenu.prevent.stop="$event => menu($event, id)"
+			>
+				<div :class="$style.profileName">{{ profile.name }}</div>
+				<div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div>
+				<div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div>
+			</div>
+		</template>
+		<div v-else-if="profiles">
+			<MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo>
+		</div>
+		<MkLoading v-else/>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, useCssModule } from 'vue';
+import { v4 as uuid } from 'uuid';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { version, host } from '@/config';
+import { definePageMetadata } from '@/scripts/page-metadata';
+const { t, ts } = i18n;
+
+useCssModule();
+
+const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
+	'menu',
+	'visibility',
+	'localOnly',
+	'statusbars',
+	'widgets',
+	'tl',
+	'overridedDeviceKind',
+	'serverDisconnectedBehavior',
+	'nsfw',
+	'animation',
+	'animatedMfm',
+	'loadRawImages',
+	'imageNewTab',
+	'disableShowingAnimatedImages',
+	'disablePagesScript',
+	'emojiStyle',
+	'disableDrawer',
+	'useBlurEffectForModal',
+	'useBlurEffect',
+	'showFixedPostForm',
+	'enableInfiniteScroll',
+	'useReactionPickerForContextMenu',
+	'showGapBetweenNotesInTimeline',
+	'instanceTicker',
+	'reactionPickerSize',
+	'reactionPickerWidth',
+	'reactionPickerHeight',
+	'reactionPickerUseDrawerForMobile',
+	'defaultSideView',
+	'menuDisplay',
+	'reportError',
+	'squareAvatars',
+	'numberOfPageCache',
+	'aiChanMode',
+];
+const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
+	'lightTheme',
+	'darkTheme',
+	'syncDeviceDarkMode',
+	'plugins',
+	'mediaVolume',
+	'sound_masterVolume',
+	'sound_note',
+	'sound_noteMy',
+	'sound_notification',
+	'sound_chat',
+	'sound_chatBg',
+	'sound_antenna',
+	'sound_channel',
+];
+
+const scope = ['clientPreferencesProfiles'];
+
+const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings'];
+
+type Profile = {
+	name: string;
+	createdAt: string;
+	updatedAt: string | null;
+	misskeyVersion: string;
+	host: string;
+	settings: {
+		hot: Record<keyof typeof defaultStoreSaveKeys, unknown>;
+		cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
+		fontSize: string | null;
+		useSystemFont: 't' | null;
+		wallpaper: string | null;
+	};
+};
+
+const connection = $i && stream.useChannel('main');
+
+let profiles = $ref<Record<string, Profile> | null>(null);
+
+os.api('i/registry/get-all', { scope })
+	.then(res => {
+		profiles = res || {};
+	});
+
+function isObject(value: unknown): value is Record<string, unknown> {
+	return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function validate(profile: unknown): void {
+	if (!isObject(profile)) throw new Error('not an object');
+
+	// Check if unnecessary properties exist
+	if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist');
+
+	if (!profile.name) throw new Error('Missing required prop: name');
+	if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion');
+	
+	// Check if createdAt and updatedAt is Date
+	// https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date
+	if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date');
+	if (profile.updatedAt) {
+		if (Number.isNaN(new Date(profile.updatedAt).getTime())) {
+			throw new Error('updatedAt is not Date');
+		}
+	} else if (profile.updatedAt !== null) {
+		throw new Error('updatedAt is not null');
+	}
+
+	if (!profile.settings) throw new Error('Missing required prop: settings');
+	if (!isObject(profile.settings)) throw new Error('Invalid prop: settings');
+}
+
+function getSettings(): Profile['settings'] {
+	const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>;
+	for (const key of defaultStoreSaveKeys) {
+		hot[key] = defaultStore.state[key];
+	}
+
+	const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
+	for (const key of coldDeviceStorageSaveKeys) {
+		cold[key] = ColdDeviceStorage.get(key);
+	}
+
+	return {
+		hot,
+		cold,
+		fontSize: localStorage.getItem('fontSize'),
+		useSystemFont: localStorage.getItem('useSystemFont') as 't' | null,
+		wallpaper: localStorage.getItem('wallpaper'),
+	};
+}
+
+async function saveNew(): Promise<void> {
+	if (!profiles) return;
+
+	const { canceled, result: name } = await os.inputText({
+		title: ts._preferencesBackups.inputName,
+	});
+	if (canceled) return;
+
+	if (Object.values(profiles).some(x => x.name === name)) {
+		return os.alert({
+			title: ts._preferencesBackups.cannotSave,
+			text: t('_preferencesBackups.nameAlreadyExists', { name }),
+		});
+	}
+
+	const id = uuid();
+	const profile: Profile = {
+		name,
+		createdAt: (new Date()).toISOString(),
+		updatedAt: null,
+		misskeyVersion: version,
+		host,
+		settings: getSettings(),
+	};
+	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+}
+
+function loadFile(): void {
+	const input = document.createElement('input');
+	input.type = 'file';
+	input.multiple = false;
+	input.onchange = async () => {
+		if (!profiles) return;
+		if (!input.files || input.files.length === 0) return;
+
+		const file = input.files[0];
+
+		if (file.type !== 'application/json') {
+			return os.alert({
+				type: 'error',
+				title: ts._preferencesBackups.cannotLoad,
+				text: ts._preferencesBackups.invalidFile,
+			});
+		}
+
+		let profile: Profile;
+		try {
+			profile = JSON.parse(await file.text()) as unknown as Profile;
+			validate(profile);
+		} catch (err) {
+			return os.alert({
+				type: 'error',
+				title: ts._preferencesBackups.cannotLoad,
+				text: err?.message,
+			});
+		}
+
+		const id = uuid();
+		await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+
+		// 一応廃棄
+		(window as any).__misskey_input_ref__ = null;
+	};
+
+	// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+	// iOS Safari で正常に動かす為のおまじない
+	(window as any).__misskey_input_ref__ = input;
+
+	input.click();
+}
+
+async function applyProfile(id: string): Promise<void> {
+	if (!profiles) return;
+
+	const profile = profiles[id];
+
+	const { canceled: cancel1 } = await os.confirm({
+		type: 'warning',
+		title: ts._preferencesBackups.apply,
+		text: t('_preferencesBackups.applyConfirm', { name: profile.name }),
+	});
+	if (cancel1) return;
+
+	// TODO: バージョン or ホストが違ったらさらに警告を表示
+
+	const settings = profile.settings;
+
+	// defaultStore
+	for (const key of defaultStoreSaveKeys) {
+		if (settings.hot[key] !== undefined) {
+			defaultStore.set(key, settings.hot[key]);
+		}
+	}
+	
+	// coldDeviceStorage
+	for (const key of coldDeviceStorageSaveKeys) {
+		if (settings.cold[key] !== undefined) {
+			ColdDeviceStorage.set(key, settings.cold[key]);
+		}
+	}
+
+	// fontSize
+	if (settings.fontSize) {
+		localStorage.setItem('fontSize', settings.fontSize);
+	} else {
+		localStorage.removeItem('fontSize');
+	}
+
+	// useSystemFont
+	if (settings.useSystemFont) {
+		localStorage.setItem('useSystemFont', settings.useSystemFont);
+	} else {
+		localStorage.removeItem('useSystemFont');
+	}
+
+	// wallpaper
+	if (settings.wallpaper != null) {
+		localStorage.setItem('wallpaper', settings.wallpaper);
+	} else {
+		localStorage.removeItem('wallpaper');
+	}
+
+	const { canceled: cancel2 } = await os.confirm({
+		type: 'info',
+		text: ts.reloadToApplySetting,
+	});
+	if (cancel2) return;
+
+	unisonReload();
+}
+
+async function deleteProfile(id: string): Promise<void> {
+	if (!profiles) return;
+
+	const { canceled } = await os.confirm({
+		type: 'info',
+		title: ts.delete,
+		text: t('deleteAreYouSure', { x: profiles[id].name }),
+	});
+	if (canceled) return;
+
+	await os.apiWithDialog('i/registry/remove', { scope, key: id });
+	delete profiles[id];
+}
+
+async function save(id: string): Promise<void> {
+	if (!profiles) return;
+
+	const { name, createdAt } = profiles[id];
+
+	const { canceled } = await os.confirm({
+		type: 'info',
+		title: ts._preferencesBackups.save,
+		text: t('_preferencesBackups.saveConfirm', { name }),
+	});
+	if (canceled) return;
+
+	const profile: Profile = {
+		name,
+		createdAt,
+		updatedAt: (new Date()).toISOString(),
+		misskeyVersion: version,
+		host,
+		settings: getSettings(),
+	};
+	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+}
+
+async function rename(id: string): Promise<void> {
+	if (!profiles) return;
+
+	const { canceled: cancel1, result: name } = await os.inputText({
+		title: ts._preferencesBackups.inputName,
+	});
+	if (cancel1 || profiles[id].name === name) return;
+
+	if (Object.values(profiles).some(x => x.name === name)) {
+		return os.alert({
+			title: ts._preferencesBackups.cannotSave,
+			text: t('_preferencesBackups.nameAlreadyExists', { name }),
+		});
+	}
+
+	const registry = Object.assign({}, { ...profiles[id] });
+
+	const { canceled: cancel2 } = await os.confirm({
+		type: 'info',
+		title: ts._preferencesBackups.rename,
+		text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }),
+	});
+	if (cancel2) return;
+
+	registry.name = name;
+	await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry });
+}
+
+function menu(ev: MouseEvent, profileId: string) {
+	if (!profiles) return;
+
+	return os.popupMenu([{
+		text: ts._preferencesBackups.apply,
+		icon: 'ti ti-check',
+		action: () => applyProfile(profileId),
+	}, {
+		type: 'a',
+		text: ts.download,
+		icon: 'ti ti-download',
+		href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })),
+		download: `${profiles[profileId].name}.json`,
+	}, null, {
+		text: ts.rename,
+		icon: 'ti ti-forms',
+		action: () => rename(profileId),
+	}, {
+		text: ts._preferencesBackups.save,
+		icon: 'ti ti-device-floppy',
+		action: () => save(profileId),
+	}, null, {
+		text: ts._preferencesBackups.delete,
+		icon: 'ti ti-trash',
+		action: () => deleteProfile(profileId),
+		danger: true,
+	}], ev.currentTarget ?? ev.target);
+}
+
+onMounted(() => {
+	// streamingのuser storage updateイベントを監視して更新
+	connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => {
+		if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return;
+		if (!profiles) return;
+
+		profiles[key] = value;
+	});
+});
+
+onUnmounted(() => {
+	connection?.off('registryUpdated');
+});
+
+definePageMetadata(computed(() => ({
+	title: ts.preferencesBackups,
+	icon: 'ti ti-device-floppy',
+	bg: 'var(--bg)',
+})));
+</script>
+
+<style lang="scss" module>
+.buttons {
+	display: flex;
+	gap: var(--margin);
+	flex-wrap: wrap;
+}
+
+.profile {
+	padding: 20px;
+	cursor: pointer;
+
+	&Name {
+		font-weight: 700;
+	}
+
+	&Time {
+		font-size: .85em;
+		opacity: .7;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
new file mode 100644
index 0000000000..915ca05767
--- /dev/null
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="_formRoot">
+	<FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch>
+	<FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch>
+
+	<FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()">
+		{{ i18n.ts.makeReactionsPublic }}
+		<template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
+	</FormSwitch>
+		
+	<FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()">
+		<template #label>{{ i18n.ts.ffVisibility }}</template>
+		<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
+		<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
+		<option value="private">{{ i18n.ts._ffVisibility.private }}</option>
+		<template #caption>{{ i18n.ts.ffVisibilityDescription }}</template>
+	</FormSelect>
+		
+	<FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()">
+		{{ i18n.ts.hideOnlineStatus }}
+		<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template>
+	</FormSwitch>
+	<FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()">
+		{{ i18n.ts.noCrawle }}
+		<template #caption>{{ i18n.ts.noCrawleDescription }}</template>
+	</FormSwitch>
+	<FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()">
+		{{ i18n.ts.makeExplorable }}
+		<template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
+	</FormSwitch>
+
+	<FormSection>
+		<FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch>
+		<FormFolder v-if="!rememberNoteVisibility" class="_formBlock">
+			<template #label>{{ i18n.ts.defaultNoteVisibility }}</template>
+			<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
+			<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
+			<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
+			<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
+
+			<FormSelect v-model="defaultNoteVisibility" class="_formBlock">
+				<option value="public">{{ i18n.ts._visibility.public }}</option>
+				<option value="home">{{ i18n.ts._visibility.home }}</option>
+				<option value="followers">{{ i18n.ts._visibility.followers }}</option>
+				<option value="specified">{{ i18n.ts._visibility.specified }}</option>
+			</FormSelect>
+			<FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch>
+		</FormFolder>
+	</FormSection>
+
+	<FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let isLocked = $ref($i.isLocked);
+let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
+let noCrawle = $ref($i.noCrawle);
+let isExplorable = $ref($i.isExplorable);
+let hideOnlineStatus = $ref($i.hideOnlineStatus);
+let publicReactions = $ref($i.publicReactions);
+let ffVisibility = $ref($i.ffVisibility);
+
+let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility'));
+let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly'));
+let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
+let keepCw = $computed(defaultStore.makeGetterSetter('keepCw'));
+
+function save() {
+	os.api('i/update', {
+		isLocked: !!isLocked,
+		autoAcceptFollowed: !!autoAcceptFollowed,
+		noCrawle: !!noCrawle,
+		isExplorable: !!isExplorable,
+		hideOnlineStatus: !!hideOnlineStatus,
+		publicReactions: !!publicReactions,
+		ffVisibility: ffVisibility,
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.privacy,
+	icon: 'ti ti-lock-open',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
new file mode 100644
index 0000000000..14eeeaaa11
--- /dev/null
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -0,0 +1,220 @@
+<template>
+<div class="_formRoot">
+	<div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
+		<div class="avatar">
+			<MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/>
+			<MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
+		</div>
+		<MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
+	</div>
+
+	<FormInput v-model="profile.name" :max="30" manual-save class="_formBlock">
+		<template #label>{{ i18n.ts._profile.name }}</template>
+	</FormInput>
+
+	<FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
+		<template #label>{{ i18n.ts._profile.description }}</template>
+		<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
+	</FormTextarea>
+
+	<FormInput v-model="profile.location" manual-save class="_formBlock">
+		<template #label>{{ i18n.ts.location }}</template>
+		<template #prefix><i class="ti ti-map-pin"></i></template>
+	</FormInput>
+
+	<FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock">
+		<template #label>{{ i18n.ts.birthday }}</template>
+		<template #prefix><i class="ti ti-cake"></i></template>
+	</FormInput>
+
+	<FormSelect v-model="profile.lang" class="_formBlock">
+		<template #label>{{ i18n.ts.language }}</template>
+		<option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option>
+	</FormSelect>
+
+	<FormSlot class="_formBlock">
+		<FormFolder>
+			<template #icon><i class="ti ti-list"></i></template>
+			<template #label>{{ i18n.ts._profile.metadataEdit }}</template>
+
+			<div class="_formRoot">
+				<FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock">
+					<FormInput v-model="record.name" small>
+						<template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template>
+					</FormInput>
+					<FormInput v-model="record.value" small>
+						<template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template>
+					</FormInput>
+				</FormSplit>
+				<MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+				<MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+			</div>
+		</FormFolder>
+		<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
+	</FormSlot>
+
+	<FormFolder>
+		<template #label>{{ i18n.ts.advancedSettings }}</template>
+
+		<div class="_formRoot">
+			<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
+			<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
+		</div>
+	</FormFolder>
+
+	<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormSlot from '@/components/form/slot.vue';
+import { host } from '@/config';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { langmap } from '@/scripts/langmap';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const profile = reactive({
+	name: $i.name,
+	description: $i.description,
+	location: $i.location,
+	birthday: $i.birthday,
+	lang: $i.lang,
+	isBot: $i.isBot,
+	isCat: $i.isCat,
+	showTimelineReplies: $i.showTimelineReplies,
+});
+
+watch(() => profile, () => {
+	save();
+}, {
+	deep: true,
+});
+
+const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value })));
+
+function addField() {
+	fields.push({
+		name: '',
+		value: '',
+	});
+}
+
+while (fields.length < 4) {
+	addField();
+}
+
+function saveFields() {
+	os.apiWithDialog('i/update', {
+		fields: fields.filter(field => field.name !== '' && field.value !== ''),
+	});
+}
+
+function save() {
+	os.apiWithDialog('i/update', {
+		name: profile.name || null,
+		description: profile.description || null,
+		location: profile.location || null,
+		birthday: profile.birthday || null,
+		lang: profile.lang || null,
+		isBot: !!profile.isBot,
+		isCat: !!profile.isCat,
+		showTimelineReplies: !!profile.showTimelineReplies,
+	});
+}
+
+function changeAvatar(ev) {
+	selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => {
+		let originalOrCropped = file;
+
+		const { canceled } = await os.confirm({
+			type: 'question',
+			text: i18n.t('cropImageAsk'),
+		});
+
+		if (!canceled) {
+			originalOrCropped = await os.cropImage(file, {
+				aspectRatio: 1,
+			});
+		}
+
+		const i = await os.apiWithDialog('i/update', {
+			avatarId: originalOrCropped.id,
+		});
+		$i.avatarId = i.avatarId;
+		$i.avatarUrl = i.avatarUrl;
+	});
+}
+
+function changeBanner(ev) {
+	selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => {
+		let originalOrCropped = file;
+
+		const { canceled } = await os.confirm({
+			type: 'question',
+			text: i18n.t('cropImageAsk'),
+		});
+
+		if (!canceled) {
+			originalOrCropped = await os.cropImage(file, {
+				aspectRatio: 2,
+			});
+		}
+
+		const i = await os.apiWithDialog('i/update', {
+			bannerId: originalOrCropped.id,
+		});
+		$i.bannerId = i.bannerId;
+		$i.bannerUrl = i.bannerUrl;
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.profile,
+	icon: 'ti ti-user',
+});
+</script>
+
+<style lang="scss" scoped>
+.llvierxe {
+	position: relative;
+	background-size: cover;
+	background-position: center;
+	border: solid 1px var(--divider);
+	border-radius: 10px;
+	overflow: clip;
+
+	> .avatar {
+		display: inline-block;
+		text-align: center;
+		padding: 16px;
+
+		> .avatar {
+			display: inline-block;
+			width: 72px;
+			height: 72px;
+			margin: 0 auto 16px auto;
+		}
+	}
+
+	> .bannerEdit {
+		position: absolute;
+		top: 16px;
+		right: 16px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue
new file mode 100644
index 0000000000..2748cd7d4e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/reaction.vue
@@ -0,0 +1,154 @@
+<template>
+<div class="_formRoot">
+	<FromSlot class="_formBlock">
+		<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
+		<div v-panel style="border-radius: 6px;">
+			<Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true">
+				<template #item="{element}">
+					<button class="_button item" @click="remove(element, $event)">
+						<MkEmoji :emoji="element" :normal="true"/>
+					</button>
+				</template>
+				<template #footer>
+					<button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
+				</template>
+			</Sortable>
+		</div>
+		<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
+	</FromSlot>
+
+	<FormRadios v-model="reactionPickerSize" class="_formBlock">
+		<template #label>{{ i18n.ts.size }}</template>
+		<option :value="1">{{ i18n.ts.small }}</option>
+		<option :value="2">{{ i18n.ts.medium }}</option>
+		<option :value="3">{{ i18n.ts.large }}</option>
+	</FormRadios>
+	<FormRadios v-model="reactionPickerWidth" class="_formBlock">
+		<template #label>{{ i18n.ts.numberOfColumn }}</template>
+		<option :value="1">5</option>
+		<option :value="2">6</option>
+		<option :value="3">7</option>
+		<option :value="4">8</option>
+		<option :value="5">9</option>
+	</FormRadios>
+	<FormRadios v-model="reactionPickerHeight" class="_formBlock">
+		<template #label>{{ i18n.ts.height }}</template>
+		<option :value="1">{{ i18n.ts.small }}</option>
+		<option :value="2">{{ i18n.ts.medium }}</option>
+		<option :value="3">{{ i18n.ts.large }}</option>
+		<option :value="4">{{ i18n.ts.large }}+</option>
+	</FormRadios>
+
+	<FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock">
+		{{ i18n.ts.useDrawerReactionPickerForMobile }}
+		<template #caption>{{ i18n.ts.needReloadToApply }}</template>
+	</FormSwitch>
+
+	<FormSection>
+		<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+			<FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
+			<FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+		</div>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, watch } from 'vue';
+import Sortable from 'vuedraggable';
+import FormInput from '@/components/form/input.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FromSlot from '@/components/form/slot.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { deepClone } from '@/scripts/clone';
+
+let reactions = $ref(deepClone(defaultStore.state.reactions));
+
+const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize'));
+const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
+const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
+const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
+
+function save() {
+	defaultStore.set('reactions', reactions);
+}
+
+function remove(reaction, ev: MouseEvent) {
+	os.popupMenu([{
+		text: i18n.ts.remove,
+		action: () => {
+			reactions = reactions.filter(x => x !== reaction);
+		},
+	}], ev.currentTarget ?? ev.target);
+}
+
+function preview(ev: MouseEvent) {
+	os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+		asReactionPicker: true,
+		src: ev.currentTarget ?? ev.target,
+	}, {}, 'closed');
+}
+
+async function setDefault() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.resetAreYouSure,
+	});
+	if (canceled) return;
+
+	reactions = deepClone(defaultStore.def.reactions.default);
+}
+
+function chooseEmoji(ev: MouseEvent) {
+	os.pickEmoji(ev.currentTarget ?? ev.target, {
+		showPinned: false,
+	}).then(emoji => {
+		if (!reactions.includes(emoji)) {
+			reactions.push(emoji);
+		}
+	});
+}
+
+watch($$(reactions), () => {
+	save();
+}, {
+	deep: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.reaction,
+	icon: 'ti ti-mood-happy',
+	action: {
+		icon: 'ti ti-eye',
+		handler: preview,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.zoaiodol {
+	padding: 12px;
+	font-size: 1.1em;
+
+	> .item {
+		display: inline-block;
+		padding: 8px;
+		cursor: move;
+	}
+
+	> .add {
+		display: inline-block;
+		padding: 8px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue
new file mode 100644
index 0000000000..33f49eb3ef
--- /dev/null
+++ b/packages/frontend/src/pages/settings/security.vue
@@ -0,0 +1,160 @@
+<template>
+<div class="_formRoot">
+	<FormSection>
+		<template #label>{{ i18n.ts.password }}</template>
+		<FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton>
+	</FormSection>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
+		<X2fa/>
+	</FormSection>
+	
+	<FormSection>
+		<template #label>{{ i18n.ts.signinHistory }}</template>
+		<MkPagination :pagination="pagination" disable-auto-load>
+			<template #default="{items}">
+				<div>
+					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
+						<header>
+							<i v-if="item.success" class="ti ti-check icon succ"></i>
+							<i v-else class="ti ti-circle-x icon fail"></i>
+							<code class="ip _monospace">{{ item.ip }}</code>
+							<MkTime :time="item.createdAt" class="time"/>
+						</header>
+					</div>
+				</div>
+			</template>
+		</MkPagination>
+	</FormSection>
+
+	<FormSection>
+		<FormSlot>
+			<FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton>
+			<template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template>
+		</FormSlot>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import X2fa from './2fa.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSlot from '@/components/form/slot.vue';
+import FormButton from '@/components/MkButton.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'i/signin-history' as const,
+	limit: 5,
+};
+
+async function change() {
+	const { canceled: canceled1, result: currentPassword } = await os.inputText({
+		title: i18n.ts.currentPassword,
+		type: 'password',
+	});
+	if (canceled1) return;
+
+	const { canceled: canceled2, result: newPassword } = await os.inputText({
+		title: i18n.ts.newPassword,
+		type: 'password',
+	});
+	if (canceled2) return;
+
+	const { canceled: canceled3, result: newPassword2 } = await os.inputText({
+		title: i18n.ts.newPasswordRetype,
+		type: 'password',
+	});
+	if (canceled3) return;
+
+	if (newPassword !== newPassword2) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts.retypedNotMatch,
+		});
+		return;
+	}
+	
+	os.apiWithDialog('i/change-password', {
+		currentPassword,
+		newPassword,
+	});
+}
+
+function regenerateToken() {
+	os.inputText({
+		title: i18n.ts.password,
+		type: 'password',
+	}).then(({ canceled, result: password }) => {
+		if (canceled) return;
+		os.api('i/regenerate_token', {
+			password: password,
+		});
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'ti ti-lock',
+});
+</script>
+
+<style lang="scss" scoped>
+.timnmucd {
+	padding: 16px;
+
+	&:first-child {
+		border-top-left-radius: 6px;
+		border-top-right-radius: 6px;
+	}
+
+	&:last-child {
+		border-bottom-left-radius: 6px;
+		border-bottom-right-radius: 6px;
+	}
+
+	&:not(:last-child) {
+		border-bottom: solid 0.5px var(--divider);
+	}
+
+	> header {
+		display: flex;
+		align-items: center;
+
+		> .icon {
+			width: 1em;
+			margin-right: 0.75em;
+
+			&.succ {
+				color: var(--success);
+			}
+
+			&.fail {
+				color: var(--error);
+			}
+		}
+
+		> .ip {
+			flex: 1;
+			min-width: 0;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			margin-right: 12px;
+		}
+
+		> .time {
+			margin-left: auto;
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
new file mode 100644
index 0000000000..62627c6333
--- /dev/null
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="_formRoot">
+	<FormSelect v-model="type">
+		<template #label>{{ i18n.ts.sound }}</template>
+		<option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option>
+	</FormSelect>
+	<FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
+		<template #label>{{ i18n.ts.volume }}</template>
+	</FormRange>
+
+	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton>
+		<FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormRange from '@/components/form/range.vue';
+import { i18n } from '@/i18n';
+import { playFile, soundsTypes } from '@/scripts/sound';
+
+const props = defineProps<{
+	type: string;
+	volume: number;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'update', result: { type: string; volume: number; }): void;
+}>();
+
+let type = $ref(props.type);
+let volume = $ref(props.volume);
+
+function listen() {
+	playFile(type, volume);
+}
+
+function save() {
+	emit('update', { type, volume });
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
new file mode 100644
index 0000000000..ef60b2c3c9
--- /dev/null
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -0,0 +1,82 @@
+<template>
+<div class="_formRoot">
+	<FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
+		<template #label>{{ i18n.ts.masterVolume }}</template>
+	</FormRange>
+
+	<FormSection>
+		<template #label>{{ i18n.ts.sounds }}</template>
+		<FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;">
+			<template #label>{{ $t('_sfx.' + type) }}</template>
+			<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
+
+			<XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/>
+		</FormFolder>
+	</FormSection>
+
+	<FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import XSound from './sounds.sound.vue';
+import FormRange from '@/components/form/range.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { playFile } from '@/scripts/sound';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const masterVolume = computed({
+	get: () => {
+		return ColdDeviceStorage.get('sound_masterVolume');
+	},
+	set: (value) => {
+		ColdDeviceStorage.set('sound_masterVolume', value);
+	},
+});
+
+const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
+
+const sounds = ref({
+	note: ColdDeviceStorage.get('sound_note'),
+	noteMy: ColdDeviceStorage.get('sound_noteMy'),
+	notification: ColdDeviceStorage.get('sound_notification'),
+	chat: ColdDeviceStorage.get('sound_chat'),
+	chatBg: ColdDeviceStorage.get('sound_chatBg'),
+	antenna: ColdDeviceStorage.get('sound_antenna'),
+	channel: ColdDeviceStorage.get('sound_channel'),
+});
+
+async function updated(type, sound) {
+	const v = {
+		type: sound.type,
+		volume: sound.volume,
+	};
+
+	ColdDeviceStorage.set('sound_' + type, v);
+	sounds.value[type] = v;
+}
+
+function reset() {
+	for (const sound of Object.keys(sounds.value)) {
+		const v = ColdDeviceStorage.default['sound_' + sound];
+		ColdDeviceStorage.set('sound_' + sound, v);
+		sounds.value[sound] = v;
+	}
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.sounds,
+	icon: 'ti ti-music',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
new file mode 100644
index 0000000000..608222386e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -0,0 +1,140 @@
+<template>
+<div class="_formRoot">
+	<FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock">
+		<template #label>{{ i18n.ts.type }}</template>
+		<option value="rss">RSS</option>
+		<option value="federation">Federation</option>
+		<option value="userList">User list timeline</option>
+	</FormSelect>
+
+	<MkInput v-model="statusbar.name" manual-save class="_formBlock">
+		<template #label>{{ i18n.ts.label }}</template>
+	</MkInput>
+
+	<MkSwitch v-model="statusbar.black" class="_formBlock">
+		<template #label>Black</template>
+	</MkSwitch>
+
+	<FormRadios v-model="statusbar.size" class="_formBlock">
+		<template #label>{{ i18n.ts.size }}</template>
+		<option value="verySmall">{{ i18n.ts.small }}+</option>
+		<option value="small">{{ i18n.ts.small }}</option>
+		<option value="medium">{{ i18n.ts.medium }}</option>
+		<option value="large">{{ i18n.ts.large }}</option>
+		<option value="veryLarge">{{ i18n.ts.large }}+</option>
+	</FormRadios>
+
+	<template v-if="statusbar.type === 'rss'">
+		<MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url">
+			<template #label>URL</template>
+		</MkInput>
+		<MkSwitch v-model="statusbar.props.shuffle" class="_formBlock">
+			<template #label>{{ i18n.ts.shuffle }}</template>
+		</MkSwitch>
+		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+			<template #label>{{ i18n.ts.refreshInterval }}</template>
+		</MkInput>
+		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+			<template #label>{{ i18n.ts.speed }}</template>
+			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+		</FormRange>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>{{ i18n.ts.reverse }}</template>
+		</MkSwitch>
+	</template>
+	<template v-else-if="statusbar.type === 'federation'">
+		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+			<template #label>{{ i18n.ts.refreshInterval }}</template>
+		</MkInput>
+		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+			<template #label>{{ i18n.ts.speed }}</template>
+			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+		</FormRange>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>{{ i18n.ts.reverse }}</template>
+		</MkSwitch>
+		<MkSwitch v-model="statusbar.props.colored" class="_formBlock">
+			<template #label>{{ i18n.ts.colored }}</template>
+		</MkSwitch>
+	</template>
+	<template v-else-if="statusbar.type === 'userList' && userLists != null">
+		<FormSelect v-model="statusbar.props.userListId" class="_formBlock">
+			<template #label>{{ i18n.ts.userList }}</template>
+			<option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
+		</FormSelect>
+		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+			<template #label>{{ i18n.ts.refreshInterval }}</template>
+		</MkInput>
+		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+			<template #label>{{ i18n.ts.speed }}</template>
+			<template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+		</FormRange>
+		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+			<template #label>{{ i18n.ts.reverse }}</template>
+		</MkSwitch>
+	</template>
+
+	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormRange from '@/components/form/range.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { deepClone } from '@/scripts/clone';
+
+const props = defineProps<{
+	_id: string;
+	userLists: any[] | null;
+}>();
+
+const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id)));
+
+watch(() => statusbar.type, () => {
+	if (statusbar.type === 'rss') {
+		statusbar.name = 'NEWS';
+		statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews';
+		statusbar.props.shuffle = true;
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+	} else if (statusbar.type === 'federation') {
+		statusbar.name = 'FEDERATION';
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+		statusbar.props.colored = false;
+	} else if (statusbar.type === 'userList') {
+		statusbar.name = 'LIST TL';
+		statusbar.props.refreshIntervalSec = 120;
+		statusbar.props.display = 'marquee';
+		statusbar.props.marqueeDuration = 100;
+		statusbar.props.marqueeReverse = false;
+	}
+});
+
+watch(statusbar, save);
+
+async function save() {
+	const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
+	const statusbars = deepClone(defaultStore.state.statusbars);
+	statusbars[i] = deepClone(statusbar);
+	defaultStore.set('statusbars', statusbars);
+}
+
+function del() {
+	defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
new file mode 100644
index 0000000000..86c69fa2c3
--- /dev/null
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="_formRoot">
+	<FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock">
+		<template #label>{{ x.type ?? i18n.ts.notSet }}</template>
+		<template #suffix>{{ x.name }}</template>
+		<XStatusbar :_id="x.id" :user-lists="userLists"/>
+	</FormFolder>
+	<FormButton primary @click="add">{{ i18n.ts.add }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XStatusbar from './statusbar.statusbar.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const statusbars = defaultStore.reactiveState.statusbars;
+
+let userLists = $ref();
+
+onMounted(() => {
+	os.api('users/lists/list').then(res => {
+		userLists = res;
+	});
+});
+
+async function add() {
+	defaultStore.push('statusbars', {
+		id: uuid(),
+		type: null,
+		black: false,
+		size: 'medium',
+		props: {},
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.statusbar,
+	icon: 'ti ti-list',
+	bg: 'var(--bg)',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
new file mode 100644
index 0000000000..52a436e18d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -0,0 +1,80 @@
+<template>
+<div class="_formRoot">
+	<FormTextarea v-model="installThemeCode" class="_formBlock">
+		<template #label>{{ i18n.ts._theme.code }}</template>
+	</FormTextarea>
+
+	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
+		<FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormButton from '@/components/MkButton.vue';
+import { applyTheme, validateTheme } from '@/scripts/theme';
+import * as os from '@/os';
+import { addTheme, getThemes } from '@/theme-store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let installThemeCode = $ref(null);
+
+function parseThemeCode(code: string) {
+	let theme;
+
+	try {
+		theme = JSON5.parse(code);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts._theme.invalid,
+		});
+		return false;
+	}
+	if (!validateTheme(theme)) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts._theme.invalid,
+		});
+		return false;
+	}
+	if (getThemes().some(t => t.id === theme.id)) {
+		os.alert({
+			type: 'info',
+			text: i18n.ts._theme.alreadyInstalled,
+		});
+		return false;
+	}
+
+	return theme;
+}
+
+function preview(code: string): void {
+	const theme = parseThemeCode(code);
+	if (theme) applyTheme(theme, false);
+}
+
+async function install(code: string): Promise<void> {
+	const theme = parseThemeCode(code);
+	if (!theme) return;
+	await addTheme(theme);
+	os.alert({
+		type: 'success',
+		text: i18n.t('_theme.installed', { name: theme.name }),
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.install,
+	icon: 'ti ti-download',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
new file mode 100644
index 0000000000..409f0af650
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="_formRoot">
+	<FormSelect v-model="selectedThemeId" class="_formBlock">
+		<template #label>{{ i18n.ts.theme }}</template>
+		<optgroup :label="i18n.ts._theme.installedThemes">
+			<option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
+		</optgroup>
+		<optgroup :label="i18n.ts._theme.builtinThemes">
+			<option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
+		</optgroup>
+	</FormSelect>
+	<template v-if="selectedTheme">
+		<FormInput readonly :model-value="selectedTheme.author" class="_formBlock">
+			<template #label>{{ i18n.ts.author }}</template>
+		</FormInput>
+		<FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock">
+			<template #label>{{ i18n.ts._theme.description }}</template>
+		</FormTextarea>
+		<FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock">
+			<template #label>{{ i18n.ts._theme.code }}</template>
+			<template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template>
+		</FormTextarea>
+		<FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton>
+	</template>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+import { getThemes, removeTheme } from '@/theme-store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const installedThemes = ref(getThemes());
+const builtinThemes = getBuiltinThemesRef();
+const selectedThemeId = ref(null);
+
+const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]);
+
+const selectedTheme = computed(() => {
+	if (selectedThemeId.value == null) return null;
+	return themes.value.find(x => x.id === selectedThemeId.value);
+});
+
+const selectedThemeCode = computed(() => {
+	if (selectedTheme.value == null) return null;
+	return JSON5.stringify(selectedTheme.value, null, '\t');
+});
+
+function copyThemeCode() {
+	copyToClipboard(selectedThemeCode.value);
+	os.success();
+}
+
+function uninstall() {
+	removeTheme(selectedTheme.value as Theme);
+	installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value);
+	selectedThemeId.value = null;
+	os.success();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.manage,
+	icon: 'ti ti-tool',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
new file mode 100644
index 0000000000..f37c213b06
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -0,0 +1,409 @@
+<template>
+<div class="_formRoot rsljpzjq">
+	<div v-adaptive-border class="rfqxtzch _panel _formBlock">
+		<div class="toggle">
+			<div class="toggleWrapper">
+				<input id="dn" v-model="darkMode" type="checkbox" class="dn"/>
+				<label for="dn" class="toggle">
+					<span class="before">{{ i18n.ts.light }}</span>
+					<span class="after">{{ i18n.ts.dark }}</span>
+					<span class="toggle__handler">
+						<span class="crater crater--1"></span>
+						<span class="crater crater--2"></span>
+						<span class="crater crater--3"></span>
+					</span>
+					<span class="star star--1"></span>
+					<span class="star star--2"></span>
+					<span class="star star--3"></span>
+					<span class="star star--4"></span>
+					<span class="star star--5"></span>
+					<span class="star star--6"></span>
+				</label>
+			</div>
+		</div>
+		<div class="sync">
+			<FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch>
+		</div>
+	</div>
+
+	<div class="selects _formBlock">
+		<FormSelect v-model="lightThemeId" large class="select">
+			<template #label>{{ i18n.ts.themeForLightMode }}</template>
+			<template #prefix><i class="ti ti-sun"></i></template>
+			<option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option>
+			<optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes">
+				<option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
+			</optgroup>
+			<optgroup :label="i18n.ts._theme.builtinThemes">
+				<option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
+			</optgroup>
+		</FormSelect>
+		<FormSelect v-model="darkThemeId" large class="select">
+			<template #label>{{ i18n.ts.themeForDarkMode }}</template>
+			<template #prefix><i class="ti ti-moon"></i></template>
+			<option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option>
+			<optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes">
+				<option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
+			</optgroup>
+			<optgroup :label="i18n.ts._theme.builtinThemes">
+				<option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
+			</optgroup>
+		</FormSelect>
+	</div>
+
+	<FormSection>
+		<div class="_formLinksGrid">
+			<FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink>
+			<FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink>
+			<FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink>
+			<FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink>
+		</div>
+	</FormSection>
+
+	<FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton>
+	<FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onActivated, ref, watch } from 'vue';
+import JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/MkButton.vue';
+import { getBuiltinThemesRef } from '@/scripts/theme';
+import { selectFile } from '@/scripts/select-file';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { uniqueBy } from '@/scripts/array';
+import { fetchThemes, getThemes } from '@/theme-store';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const installedThemes = ref(getThemes());
+const builtinThemes = getBuiltinThemesRef();
+
+const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null);
+const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
+const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
+const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null);
+const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
+const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
+const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id));
+
+const darkTheme = ColdDeviceStorage.ref('darkTheme');
+const darkThemeId = computed({
+	get() {
+		return darkTheme.value.id;
+	},
+	set(id) {
+		const t = themes.value.find(x => x.id === id);
+		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+			ColdDeviceStorage.set('darkTheme', t);
+		}
+	},
+});
+const lightTheme = ColdDeviceStorage.ref('lightTheme');
+const lightThemeId = computed({
+	get() {
+		return lightTheme.value.id;
+	},
+	set(id) {
+		const t = themes.value.find(x => x.id === id);
+		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+			ColdDeviceStorage.set('lightTheme', t);
+		}
+	},
+});
+const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
+const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
+const wallpaper = ref(localStorage.getItem('wallpaper'));
+const themesCount = installedThemes.value.length;
+
+watch(syncDeviceDarkMode, () => {
+	if (syncDeviceDarkMode.value) {
+		defaultStore.set('darkMode', isDeviceDarkmode());
+	}
+});
+
+watch(wallpaper, () => {
+	if (wallpaper.value == null) {
+		localStorage.removeItem('wallpaper');
+	} else {
+		localStorage.setItem('wallpaper', wallpaper.value);
+	}
+	location.reload();
+});
+
+onActivated(() => {
+	fetchThemes().then(() => {
+		installedThemes.value = getThemes();
+	});
+});
+
+fetchThemes().then(() => {
+	installedThemes.value = getThemes();
+});
+
+function setWallpaper(event) {
+	selectFile(event.currentTarget ?? event.target, null).then(file => {
+		wallpaper.value = file.url;
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.theme,
+	icon: 'ti ti-palette',
+});
+</script>
+
+<style lang="scss" scoped>
+.rfqxtzch {
+	border-radius: 6px;
+
+	> .toggle {
+		position: relative;
+		padding: 26px 0;
+		text-align: center;
+
+		&.disabled {
+			opacity: 0.7;
+
+			&, * {
+				cursor: not-allowed !important;
+			}
+		}
+
+		> .toggleWrapper {
+			display: inline-block;
+			text-align: left;
+			overflow: clip;
+			padding: 0 100px;
+			vertical-align: bottom;
+
+			input {
+				position: absolute;
+				left: -99em;
+			}
+		}
+
+		.toggle {
+			cursor: pointer;
+			display: inline-block;
+			position: relative;
+			width: 90px;
+			height: 50px;
+			background-color: #83D8FF;
+			border-radius: 90px - 6;
+			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+
+			> .before, > .after {
+				position: absolute;
+				top: 15px;
+				transition: color 1s ease;
+			}
+
+			> .before {
+				left: -70px;
+				color: var(--accent);
+			}
+
+			> .after {
+				right: -68px;
+				color: var(--fg);
+			}
+		}
+
+		.toggle__handler {
+			display: inline-block;
+			position: relative;
+			z-index: 1;
+			top: 3px;
+			left: 3px;
+			width: 50px - 6;
+			height: 50px - 6;
+			background-color: #FFCF96;
+			border-radius: 50px;
+			box-shadow: 0 2px 6px rgba(0,0,0,.3);
+			transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
+			transform:  rotate(-45deg);
+
+			.crater {
+				position: absolute;
+				background-color: #E8CDA5;
+				opacity: 0;
+				transition: opacity 200ms ease-in-out !important;
+				border-radius: 100%;
+			}
+
+			.crater--1 {
+				top: 18px;
+				left: 10px;
+				width: 4px;
+				height: 4px;
+			}
+
+			.crater--2 {
+				top: 28px;
+				left: 22px;
+				width: 6px;
+				height: 6px;
+			}
+
+			.crater--3 {
+				top: 10px;
+				left: 25px;
+				width: 8px;
+				height: 8px;
+			}
+		}
+
+		.star {
+			position: absolute;
+			background-color: #ffffff;
+			transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+			border-radius: 50%;
+		}
+
+		.star--1 {
+			top: 10px;
+			left: 35px;
+			z-index: 0;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--2 {
+			top: 18px;
+			left: 28px;
+			z-index: 1;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--3 {
+			top: 27px;
+			left: 40px;
+			z-index: 0;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--4,
+		.star--5,
+		.star--6 {
+			opacity: 0;
+			transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+		}
+
+		.star--4 {
+			top: 16px;
+			left: 11px;
+			z-index: 0;
+			width: 2px;
+			height: 2px;
+			transform: translate3d(3px,0,0);
+		}
+
+		.star--5 {
+			top: 32px;
+			left: 17px;
+			z-index: 0;
+			width: 3px;
+			height: 3px;
+			transform: translate3d(3px,0,0);
+		}
+
+		.star--6 {
+			top: 36px;
+			left: 28px;
+			z-index: 0;
+			width: 2px;
+			height: 2px;
+			transform: translate3d(3px,0,0);
+		}
+
+		input:checked {
+			+ .toggle {
+				background-color: #749DD6;
+
+				> .before {
+					color: var(--fg);
+				}
+
+				> .after {
+					color: var(--accent);
+				}
+
+				.toggle__handler {
+					background-color: #FFE5B5;
+					transform: translate3d(40px, 0, 0) rotate(0);
+
+					.crater { opacity: 1; }
+				}
+
+				.star--1 {
+					width: 2px;
+					height: 2px;
+				}
+
+				.star--2 {
+					width: 4px;
+					height: 4px;
+					transform: translate3d(-5px, 0, 0);
+				}
+
+				.star--3 {
+					width: 2px;
+					height: 2px;
+					transform: translate3d(-7px, 0, 0);
+				}
+
+				.star--4,
+				.star--5,
+				.star--6 {
+					opacity: 1;
+					transform: translate3d(0,0,0);
+				}
+
+				.star--4 {
+					transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+				}
+
+				.star--5 {
+					transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+				}
+
+				.star--6 {
+					transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+				}
+			}
+		}
+	}
+
+	> .sync {
+		padding: 14px 16px;
+		border-top: solid 0.5px var(--divider);
+	}
+}
+
+.rsljpzjq {
+	> .selects {
+		display: flex;
+		gap: 1.5em var(--margin);
+		flex-wrap: wrap;
+
+		> .select {
+			flex: 1;
+			min-width: 280px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue
new file mode 100644
index 0000000000..c8ec1ea586
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.edit.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="_formRoot">
+	<FormInput v-model="name" class="_formBlock">
+		<template #label>Name</template>
+	</FormInput>
+
+	<FormInput v-model="url" type="url" class="_formBlock">
+		<template #label>URL</template>
+	</FormInput>
+
+	<FormInput v-model="secret" class="_formBlock">
+		<template #prefix><i class="ti ti-lock"></i></template>
+		<template #label>Secret</template>
+	</FormInput>
+
+	<FormSection>
+		<template #label>Events</template>
+
+		<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
+		<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
+		<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
+		<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
+		<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
+		<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
+		<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
+	</FormSection>
+
+	<FormSwitch v-model="active" class="_formBlock">Active</FormSwitch>
+
+	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormInput from '@/components/form/input.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	webhookId: string;
+}>();
+
+const webhook = await os.api('i/webhooks/show', {
+	webhookId: props.webhookId,
+});
+
+let name = $ref(webhook.name);
+let url = $ref(webhook.url);
+let secret = $ref(webhook.secret);
+let active = $ref(webhook.active);
+
+let event_follow = $ref(webhook.on.includes('follow'));
+let event_followed = $ref(webhook.on.includes('followed'));
+let event_note = $ref(webhook.on.includes('note'));
+let event_reply = $ref(webhook.on.includes('reply'));
+let event_renote = $ref(webhook.on.includes('renote'));
+let event_reaction = $ref(webhook.on.includes('reaction'));
+let event_mention = $ref(webhook.on.includes('mention'));
+
+async function save(): Promise<void> {
+	const events = [];
+	if (event_follow) events.push('follow');
+	if (event_followed) events.push('followed');
+	if (event_note) events.push('note');
+	if (event_reply) events.push('reply');
+	if (event_renote) events.push('renote');
+	if (event_reaction) events.push('reaction');
+	if (event_mention) events.push('mention');
+
+	os.apiWithDialog('i/webhooks/update', {
+		name,
+		url,
+		secret,
+		webhookId: props.webhookId,
+		on: events,
+		active,
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Edit webhook',
+	icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue
new file mode 100644
index 0000000000..00a547da69
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.new.vue
@@ -0,0 +1,82 @@
+<template>
+<div class="_formRoot">
+	<FormInput v-model="name" class="_formBlock">
+		<template #label>Name</template>
+	</FormInput>
+
+	<FormInput v-model="url" type="url" class="_formBlock">
+		<template #label>URL</template>
+	</FormInput>
+
+	<FormInput v-model="secret" class="_formBlock">
+		<template #prefix><i class="ti ti-lock"></i></template>
+		<template #label>Secret</template>
+	</FormInput>
+
+	<FormSection>
+		<template #label>Events</template>
+
+		<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
+		<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
+		<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
+		<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
+		<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
+		<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
+		<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
+	</FormSection>
+
+	<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+		<FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormInput from '@/components/form/input.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let name = $ref('');
+let url = $ref('');
+let secret = $ref('');
+
+let event_follow = $ref(true);
+let event_followed = $ref(true);
+let event_note = $ref(true);
+let event_reply = $ref(true);
+let event_renote = $ref(true);
+let event_reaction = $ref(true);
+let event_mention = $ref(true);
+
+async function create(): Promise<void> {
+	const events = [];
+	if (event_follow) events.push('follow');
+	if (event_followed) events.push('followed');
+	if (event_note) events.push('note');
+	if (event_reply) events.push('reply');
+	if (event_renote) events.push('renote');
+	if (event_reaction) events.push('reaction');
+	if (event_mention) events.push('mention');
+
+	os.apiWithDialog('i/webhooks/create', {
+		name,
+		url,
+		secret,
+		on: events,
+	});
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Create new webhook',
+	icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue
new file mode 100644
index 0000000000..9be23ee4f0
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_formRoot">
+	<FormSection>
+		<FormLink :to="`/settings/webhook/new`">
+			Create webhook
+		</FormLink>
+	</FormSection>
+	
+	<FormSection>
+		<MkPagination :pagination="pagination">
+			<template #default="{items}">
+				<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock">
+					<template #icon>
+						<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
+						<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
+						<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
+						<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
+					</template>
+					{{ webhook.name || webhook.url }}
+					<template #suffix>
+						<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
+					</template>
+				</FormLink>
+			</template>
+		</MkPagination>
+	</FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+	endpoint: 'i/webhooks/list' as const,
+	limit: 10,
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Webhook',
+	icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue
new file mode 100644
index 0000000000..6961d8151d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/word-mute.vue
@@ -0,0 +1,128 @@
+<template>
+<div class="_formRoot">
+	<MkTab v-model="tab" class="_formBlock">
+		<option value="soft">{{ i18n.ts._wordMute.soft }}</option>
+		<option value="hard">{{ i18n.ts._wordMute.hard }}</option>
+	</MkTab>
+	<div class="_formBlock">
+		<div v-show="tab === 'soft'">
+			<MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo>
+			<FormTextarea v-model="softMutedWords" class="_formBlock">
+				<span>{{ i18n.ts._wordMute.muteWords }}</span>
+				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
+			</FormTextarea>
+		</div>
+		<div v-show="tab === 'hard'">
+			<MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo>
+			<FormTextarea v-model="hardMutedWords" class="_formBlock">
+				<span>{{ i18n.ts._wordMute.muteWords }}</span>
+				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
+			</FormTextarea>
+			<MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock">
+				<template #key>{{ i18n.ts._wordMute.mutedNotes }}</template>
+				<template #value>{{ number(hardWordMutedNotesCount) }}</template>
+			</MkKeyValue>
+		</div>
+	</div>
+	<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkTab from '@/components/MkTab.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { defaultStore } from '@/store';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const render = (mutedWords) => mutedWords.map(x => {
+	if (Array.isArray(x)) {
+		return x.join(' ');
+	} else {
+		return x;
+	}
+}).join('\n');
+
+const tab = ref('soft');
+const softMutedWords = ref(render(defaultStore.state.mutedWords));
+const hardMutedWords = ref(render($i!.mutedWords));
+const hardWordMutedNotesCount = ref(null);
+const changed = ref(false);
+
+os.api('i/get-word-muted-notes-count', {}).then(response => {
+	hardWordMutedNotesCount.value = response?.count;
+});
+
+watch(softMutedWords, () => {
+	changed.value = true;
+});
+
+watch(hardMutedWords, () => {
+	changed.value = true;
+});
+
+async function save() {
+	const parseMutes = (mutes, tab) => {
+		// split into lines, remove empty lines and unnecessary whitespace
+		let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
+
+		// check each line if it is a RegExp or not
+		for (let i = 0; i < lines.length; i++) {
+			const line = lines[i];
+			const regexp = line.match(/^\/(.+)\/(.*)$/);
+			if (regexp) {
+				// check that the RegExp is valid
+				try {
+					new RegExp(regexp[1], regexp[2]);
+					// note that regex lines will not be split by spaces!
+				} catch (err: any) {
+					// invalid syntax: do not save, do not reset changed flag
+					os.alert({
+						type: 'error',
+						title: i18n.ts.regexpError,
+						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
+					});
+					// re-throw error so these invalid settings are not saved
+					throw err;
+				}
+			} else {
+				lines[i] = line.split(' ');
+			}
+		}
+
+		return lines;
+	};
+
+	let softMutes, hardMutes;
+	try {
+		softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft);
+		hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard);
+	} catch (err) {
+		// already displayed error message in parseMutes
+		return;
+	}
+
+	defaultStore.set('mutedWords', softMutes);
+	await os.api('i/update', {
+		mutedWords: hardMutes,
+	});
+
+	changed.value = false;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.wordMute,
+	icon: 'ti ti-message-off',
+});
+</script>
diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue
new file mode 100644
index 0000000000..a7e797eeab
--- /dev/null
+++ b/packages/frontend/src/pages/share.vue
@@ -0,0 +1,169 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XPostForm
+			v-if="state === 'writing'"
+			fixed
+			:instant="true"
+			:initial-text="initialText"
+			:initial-visibility="visibility"
+			:initial-files="files"
+			:initial-local-only="localOnly"
+			:reply="reply"
+			:renote="renote"
+			:initial-visible-users="visibleUsers"
+			class="_panel"
+			@posted="state = 'posted'"
+		/>
+		<MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
+
+import { } from 'vue';
+import { noteVisibilities } from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import XPostForm from '@/components/MkPostForm.vue';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const urlParams = new URLSearchParams(window.location.search);
+const localOnlyQuery = urlParams.get('localOnly');
+const visibilityQuery = urlParams.get('visibility');
+
+let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
+let title = $ref(urlParams.get('title'));
+const text = urlParams.get('text');
+const url = urlParams.get('url');
+let initialText = $ref(null as string | null);
+let reply = $ref(null as Misskey.entities.Note | null);
+let renote = $ref(null as Misskey.entities.Note | null);
+let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null);
+let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null);
+let files = $ref([] as Misskey.entities.DriveFile[]);
+let visibleUsers = $ref([] as Misskey.entities.User[]);
+
+async function init() {
+	let noteText = '';
+	if (title) noteText += `[ ${title} ]\n`;
+	// Googleニュース対策
+	if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, '');
+	else if (text && title !== text) noteText += `${text}\n`;
+	if (url) noteText += `${url}`;
+	initialText = noteText.trim();
+
+	if (visibility === 'specified') {
+		const visibleUserIds = urlParams.get('visibleUserIds');
+		const visibleAccts = urlParams.get('visibleAccts');
+		await Promise.all(
+			[
+				...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
+				...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []),
+			]
+			// TypeScriptの指示通りに変換する
+			.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
+			.map(q => os.api('users/show', q)
+				.then(user => {
+					visibleUsers.push(user);
+				}, () => {
+					console.error(`Invalid user query: ${JSON.stringify(q)}`);
+				}),
+			),
+		);
+	}
+
+	try {
+		//#region Reply
+		const replyId = urlParams.get('replyId');
+		const replyUri = urlParams.get('replyUri');
+		if (replyId) {
+			reply = await os.api('notes/show', {
+				noteId: replyId,
+			});
+		} else if (replyUri) {
+			const obj = await os.api('ap/show', {
+				uri: replyUri,
+			});
+			if (obj.type === 'Note') {
+				reply = obj.object;
+			}
+		}
+		//#endregion
+
+		//#region Renote
+		const renoteId = urlParams.get('renoteId');
+		const renoteUri = urlParams.get('renoteUri');
+		if (renoteId) {
+			renote = await os.api('notes/show', {
+				noteId: renoteId,
+			});
+		} else if (renoteUri) {
+			const obj = await os.api('ap/show', {
+				uri: renoteUri,
+			});
+			if (obj.type === 'Note') {
+				renote = obj.object;
+			}
+		}
+		//#endregion
+
+		//#region Drive files
+		const fileIds = urlParams.get('fileIds');
+		if (fileIds) {
+			await Promise.all(
+				fileIds.split(',')
+				.map(fileId => os.api('drive/files/show', { fileId })
+					.then(file => {
+						files.push(file);
+					}, () => {
+						console.error(`Failed to fetch a file ${fileId}`);
+					}),
+				),
+			);
+		}
+		//#endregion
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: err.message,
+			text: err.name,
+		});
+	}
+
+	state = 'writing';
+}
+
+init();
+
+function close(): void {
+	window.close();
+
+	// 閉じなければ100ms後タイムラインに
+	window.setTimeout(() => {
+		mainRouter.push('/');
+	}, 100);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.share,
+	icon: 'ti ti-share',
+});
+</script>
+
+<style lang="scss" scoped>
+.close {
+	margin: 16px auto;
+}
+</style>
diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue
new file mode 100644
index 0000000000..5459532310
--- /dev/null
+++ b/packages/frontend/src/pages/signup-complete.vue
@@ -0,0 +1,41 @@
+<template>
+<div>
+	{{ i18n.ts.processing }}
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as os from '@/os';
+import { login } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	code: string;
+}>();
+
+onMounted(async () => {
+	await os.alert({
+		type: 'info',
+		text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }),
+	});
+	const res = await os.apiWithDialog('signup-pending', {
+		code: props.code,
+	});
+	login(res.i, '/');
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.signup,
+	icon: 'ti ti-user',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
new file mode 100644
index 0000000000..72775ed5c9
--- /dev/null
+++ b/packages/frontend/src/pages/tag.vue
@@ -0,0 +1,35 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XNotes class="_content" :pagination="pagination"/>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XNotes from '@/components/MkNotes.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	tag: string;
+}>();
+
+const pagination = {
+	endpoint: 'notes/search-by-tag' as const,
+	limit: 10,
+	params: computed(() => ({
+		tag: props.tag,
+	})),
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: props.tag,
+	icon: 'ti ti-hash',
+})));
+</script>
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
new file mode 100644
index 0000000000..d8ff170ca2
--- /dev/null
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -0,0 +1,283 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+		<div class="cwepdizn _formRoot">
+			<FormFolder :default-open="true" class="_formBlock">
+				<template #label>{{ i18n.ts.backgroundColor }}</template>
+				<div class="cwepdizn-colors">
+					<div class="row">
+						<button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
+							<div class="preview" :style="{ background: color.forPreview }"></div>
+						</button>
+					</div>
+					<div class="row">
+						<button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
+							<div class="preview" :style="{ background: color.forPreview }"></div>
+						</button>
+					</div>
+				</div>
+			</FormFolder>
+
+			<FormFolder :default-open="true" class="_formBlock">
+				<template #label>{{ i18n.ts.accentColor }}</template>
+				<div class="cwepdizn-colors">
+					<div class="row">
+						<button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)">
+							<div class="preview" :style="{ background: color }"></div>
+						</button>
+					</div>
+				</div>
+			</FormFolder>
+
+			<FormFolder :default-open="true" class="_formBlock">
+				<template #label>{{ i18n.ts.textColor }}</template>
+				<div class="cwepdizn-colors">
+					<div class="row">
+						<button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)">
+							<div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
+						</button>
+					</div>
+				</div>
+			</FormFolder>
+
+			<FormFolder :default-open="false" class="_formBlock">
+				<template #icon><i class="ti ti-code"></i></template>
+				<template #label>{{ i18n.ts.editCode }}</template>
+
+				<div class="_formRoot">
+					<FormTextarea v-model="themeCode" tall class="_formBlock">
+						<template #label>{{ i18n.ts._theme.code }}</template>
+					</FormTextarea>
+					<FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton>
+				</div>
+			</FormFolder>
+
+			<FormFolder :default-open="false" class="_formBlock">
+				<template #label>{{ i18n.ts.addDescription }}</template>
+
+				<div class="_formRoot">
+					<FormTextarea v-model="description">
+						<template #label>{{ i18n.ts._theme.description }}</template>
+					</FormTextarea>
+				</div>
+			</FormFolder>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { watch } from 'vue';
+import { toUnicode } from 'punycode/';
+import tinycolor from 'tinycolor2';
+import { v4 as uuid } from 'uuid';
+import JSON5 from 'json5';
+
+import FormButton from '@/components/MkButton.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormFolder from '@/components/form/folder.vue';
+
+import { $i } from '@/account';
+import { Theme, applyTheme } from '@/scripts/theme';
+import lightTheme from '@/themes/_light.json5';
+import darkTheme from '@/themes/_dark.json5';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { addTheme } from '@/theme-store';
+import { i18n } from '@/i18n';
+import { useLeaveGuard } from '@/scripts/use-leave-guard';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const bgColors = [
+	{ color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
+	{ color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' },
+	{ color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' },
+	{ color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' },
+	{ color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' },
+	{ color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' },
+	{ color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' },
+	{ color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' },
+	{ color: '#2b2b2b', kind: 'dark', forPreview: '#444444' },
+	{ color: '#362e29', kind: 'dark', forPreview: '#735c4d' },
+	{ color: '#303629', kind: 'dark', forPreview: '#506d2f' },
+	{ color: '#293436', kind: 'dark', forPreview: '#258192' },
+	{ color: '#2e2936', kind: 'dark', forPreview: '#504069' },
+	{ color: '#252722', kind: 'dark', forPreview: '#3c462f' },
+	{ color: '#212525', kind: 'dark', forPreview: '#303e3e' },
+	{ color: '#191919', kind: 'dark', forPreview: '#272727' },
+] as const;
+const accentColors = ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'];
+const fgColors = [
+	{ color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null },
+	{ color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' },
+	{ color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' },
+	{ color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' },
+	{ color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' },
+	{ color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' },
+	{ color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' },
+];
+
+let theme = $ref<Partial<Theme>>({
+	base: 'light',
+	props: lightTheme.props,
+});
+let description = $ref<string | null>(null);
+let themeCode = $ref<string | null>(null);
+let changed = $ref(false);
+
+useLeaveGuard($$(changed));
+
+function showPreview() {
+	os.pageWindow('/preview');
+}
+
+function setBgColor(color: typeof bgColors[number]) {
+	if (theme.base !== color.kind) {
+		const base = color.kind === 'dark' ? darkTheme : lightTheme;
+		for (const prop of Object.keys(base.props)) {
+			if (prop === 'accent') continue;
+			if (prop === 'fg') continue;
+			theme.props[prop] = base.props[prop];
+		}
+	}
+	theme.base = color.kind;
+	theme.props.bg = color.color;
+
+	if (theme.props.fg) {
+		const matchedFgColor = fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(theme.props.fg).toRgbString()));
+		if (matchedFgColor) setFgColor(matchedFgColor);
+	}
+}
+
+function setAccentColor(color) {
+	theme.props.accent = color;
+}
+
+function setFgColor(color) {
+	theme.props.fg = theme.base === 'light' ? color.forLight : color.forDark;
+}
+
+function apply() {
+	themeCode = JSON5.stringify(theme, null, '\t');
+	applyTheme(theme, false);
+	changed = true;
+}
+
+function applyThemeCode() {
+	let parsed;
+
+	try {
+		parsed = JSON5.parse(themeCode);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts._theme.invalid,
+		});
+		return;
+	}
+
+	theme = parsed;
+}
+
+async function saveAs() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts.name,
+		allowEmpty: false,
+	});
+	if (canceled) return;
+
+	theme.id = uuid();
+	theme.name = name;
+	theme.author = `@${$i.username}@${toUnicode(host)}`;
+	if (description) theme.desc = description;
+	await addTheme(theme);
+	applyTheme(theme);
+	if (defaultStore.state.darkMode) {
+		ColdDeviceStorage.set('darkTheme', theme);
+	} else {
+		ColdDeviceStorage.set('lightTheme', theme);
+	}
+	changed = false;
+	os.alert({
+		type: 'success',
+		text: i18n.t('_theme.installed', { name: theme.name }),
+	});
+}
+
+watch($$(theme), apply, { deep: true });
+
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'ti ti-eye',
+	text: i18n.ts.preview,
+	handler: showPreview,
+}, {
+	asFullButton: true,
+	icon: 'ti ti-check',
+	text: i18n.ts.saveAs,
+	handler: saveAs,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.themeEditor,
+	icon: 'ti ti-palette',
+});
+</script>
+
+<style lang="scss" scoped>
+.cwepdizn {
+	::v-deep(.cwepdizn-colors) {
+		text-align: center;
+
+		> .row {
+			> .color {
+				display: inline-block;
+				position: relative;
+				width: 64px;
+				height: 64px;
+				border-radius: 8px;
+
+				> .preview {
+					position: absolute;
+					top: 0;
+					left: 0;
+					right: 0;
+					bottom: 0;
+					margin: auto;
+					width: 42px;
+					height: 42px;
+					border-radius: 4px;
+					box-shadow: 0 2px 4px rgb(0 0 0 / 30%);
+					transition: transform 0.15s ease;
+				}
+
+				&:hover {
+					> .preview {
+						transform: scale(1.1);
+					}
+				}
+
+				&.active {
+					box-shadow: 0 0 0 2px var(--divider) inset;
+				}
+
+				&.rounded {
+					border-radius: 999px;
+
+					> .preview {
+						border-radius: 999px;
+					}
+				}
+
+				&.char {
+					line-height: 42px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue
new file mode 100644
index 0000000000..ae7b098b90
--- /dev/null
+++ b/packages/frontend/src/pages/timeline.tutorial.vue
@@ -0,0 +1,142 @@
+<template>
+<div class="_card">
+	<div :class="$style.title" class="_title">
+		<div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div>
+		<div :class="$style.step">
+			<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
+				<i class="ti ti-chevron-left"></i>
+			</button>
+			<span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span>
+			<button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++">
+				<i class="ti ti-chevron-right"></i>
+			</button>
+		</div>
+	</div>
+	<div v-if="tutorial === 0" class="_content">
+		<div>{{ i18n.ts._tutorial.step1_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step1_2 }}</div>
+		<div>{{ i18n.ts._tutorial.step1_3 }}</div>
+	</div>
+	<div v-else-if="tutorial === 1" class="_content">
+		<div>{{ i18n.ts._tutorial.step2_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step2_2 }}</div>
+		<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
+	</div>
+	<div v-else-if="tutorial === 2" class="_content">
+		<div>{{ i18n.ts._tutorial.step3_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step3_2 }}</div>
+		<div>{{ i18n.ts._tutorial.step3_3 }}</div>
+		<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
+	</div>
+	<div v-else-if="tutorial === 3" class="_content">
+		<div>{{ i18n.ts._tutorial.step4_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step4_2 }}</div>
+	</div>
+	<div v-else-if="tutorial === 4" class="_content">
+		<div>{{ i18n.ts._tutorial.step5_1 }}</div>
+		<I18n :src="i18n.ts._tutorial.step5_2" tag="div">
+			<template #featured>
+				<MkA class="_link" to="/featured">{{ i18n.ts.featured }}</MkA>
+			</template>
+			<template #explore>
+				<MkA class="_link" to="/explore">{{ i18n.ts.explore }}</MkA>
+			</template>
+		</I18n>
+		<div>{{ i18n.ts._tutorial.step5_3 }}</div>
+		<small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small>
+	</div>
+	<div v-else-if="tutorial === 5" class="_content">
+		<div>{{ i18n.ts._tutorial.step6_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step6_2 }}</div>
+		<div>{{ i18n.ts._tutorial.step6_3 }}</div>
+	</div>
+	<div v-else-if="tutorial === 6" class="_content">
+		<div>{{ i18n.ts._tutorial.step7_1 }}</div>
+		<I18n :src="i18n.ts._tutorial.step7_2" tag="div">
+			<template #help>
+				<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
+			</template>
+		</I18n>
+		<div>{{ i18n.ts._tutorial.step7_3 }}</div>
+	</div>
+	<div v-else-if="tutorial === 7" class="_content">
+		<div>{{ i18n.ts._tutorial.step8_1 }}</div>
+		<div>{{ i18n.ts._tutorial.step8_2 }}</div>
+		<small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small>
+	</div>
+
+	<div class="_footer" :class="$style.footer">
+		<template v-if="tutorial === tutorialsNumber - 1">
+			<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1" />
+			<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
+		</template>
+		<template v-else>
+			<MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const tutorialsNumber = 8;
+
+const tutorial = computed({
+	get() { return defaultStore.reactiveState.tutorial.value || 0; },
+	set(value) { defaultStore.set('tutorial', value); },
+});
+</script>
+
+<style lang="scss" module>
+.small {
+	opacity: 0.7;
+}
+
+.title {
+	display: flex;
+	flex-wrap: wrap;
+
+	&Text {
+		margin: 4px 0;
+		padding-right: 4px;
+	}
+}
+
+.step {
+	margin-left: auto;
+
+	&Arrow {
+		padding: 4px;
+		&:disabled {
+			opacity: 0.5;
+		}
+		&:first-child {
+			padding-right: 8px;
+		}
+		&:last-child {
+			padding-left: 8px;
+		}
+	}
+
+	&Number {
+		font-weight: normal;
+		margin: 4px;
+	}
+}
+
+.footer {
+	display: flex;
+	flex-wrap: wrap;
+	flex-direction: row;
+	justify-content: right;
+
+	&Item {
+		margin: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
new file mode 100644
index 0000000000..1c9e389367
--- /dev/null
+++ b/packages/frontend/src/pages/timeline.vue
@@ -0,0 +1,183 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs" :display-my-avatar="true"/></template>
+	<MkSpacer :content-max="800">
+		<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
+			<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+			<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+
+			<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
+			<div class="tl _block">
+				<XTimeline
+					ref="tl" :key="src"
+					class="tl"
+					:src="src"
+					:sound="true"
+					@queue="queueUpdated"
+				/>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, watch } from 'vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import XPostForm from '@/components/MkPostForm.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
+
+const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
+const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
+const keymap = {
+	't': focus,
+};
+
+const tlComponent = $ref<InstanceType<typeof XTimeline>>();
+const rootEl = $ref<HTMLElement>();
+
+let queue = $ref(0);
+const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) });
+
+watch ($$(src), () => queue = 0);
+
+function queueUpdated(q: number): void {
+	queue = q;
+}
+
+function top(): void {
+	scroll(rootEl, { top: 0 });
+}
+
+async function chooseList(ev: MouseEvent): Promise<void> {
+	const lists = await os.api('users/lists/list');
+	const items = lists.map(list => ({
+		type: 'link' as const,
+		text: list.name,
+		to: `/timeline/list/${list.id}`,
+	}));
+	os.popupMenu(items, ev.currentTarget ?? ev.target);
+}
+
+async function chooseAntenna(ev: MouseEvent): Promise<void> {
+	const antennas = await os.api('antennas/list');
+	const items = antennas.map(antenna => ({
+		type: 'link' as const,
+		text: antenna.name,
+		indicate: antenna.hasUnreadNote,
+		to: `/timeline/antenna/${antenna.id}`,
+	}));
+	os.popupMenu(items, ev.currentTarget ?? ev.target);
+}
+
+async function chooseChannel(ev: MouseEvent): Promise<void> {
+	const channels = await os.api('channels/followed');
+	const items = channels.map(channel => ({
+		type: 'link' as const,
+		text: channel.name,
+		indicate: channel.hasUnreadNote,
+		to: `/channels/${channel.id}`,
+	}));
+	os.popupMenu(items, ev.currentTarget ?? ev.target);
+}
+
+function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void {
+	defaultStore.set('tl', {
+		...defaultStore.state.tl,
+		src: newSrc,
+	});
+}
+
+async function timetravel(): Promise<void> {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
+
+	tlComponent.timetravel(date);
+}
+
+function focus(): void {
+	tlComponent.focus();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	key: 'home',
+	title: i18n.ts._timelines.home,
+	icon: 'ti ti-home',
+	iconOnly: true,
+}, ...(isLocalTimelineAvailable ? [{
+	key: 'local',
+	title: i18n.ts._timelines.local,
+	icon: 'ti ti-messages',
+	iconOnly: true,
+}, {
+	key: 'social',
+	title: i18n.ts._timelines.social,
+	icon: 'ti ti-share',
+	iconOnly: true,
+}] : []), ...(isGlobalTimelineAvailable ? [{
+	key: 'global',
+	title: i18n.ts._timelines.global,
+	icon: 'ti ti-world',
+	iconOnly: true,
+}] : []), {
+	icon: 'ti ti-list',
+	title: i18n.ts.lists,
+	iconOnly: true,
+	onClick: chooseList,
+}, {
+	icon: 'ti ti-antenna',
+	title: i18n.ts.antennas,
+	iconOnly: true,
+	onClick: chooseAntenna,
+}, {
+	icon: 'ti ti-device-tv',
+	title: i18n.ts.channel,
+	iconOnly: true,
+	onClick: chooseChannel,
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.timeline,
+	icon: src === 'local' ? 'ti ti-messages' : src === 'social' ? 'ti ti-share' : src === 'global' ? 'ti ti-world' : 'ti ti-home',
+})));
+</script>
+
+<style lang="scss" scoped>
+.cmuxhskf {
+	> .new {
+		position: sticky;
+		top: calc(var(--stickyTop, 0px) + 16px);
+		z-index: 1000;
+		width: 100%;
+
+		> button {
+			display: block;
+			margin: var(--margin) auto 0 auto;
+			padding: 8px 16px;
+			border-radius: 32px;
+		}
+	}
+
+	> .post-form {
+		border-radius: var(--radius);
+	}
+
+	> .tl {
+		background: var(--bg);
+		border-radius: var(--radius);
+		overflow: clip;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue
new file mode 100644
index 0000000000..addc8db9e6
--- /dev/null
+++ b/packages/frontend/src/pages/user-info.vue
@@ -0,0 +1,485 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div v-if="tab === 'overview'" class="_formRoot">
+				<div class="_formBlock aeakzknw">
+					<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+					<div class="body">
+						<span class="name"><MkUserName class="name" :user="user"/></span>
+						<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+						<span class="state">
+							<span v-if="suspended" class="suspended">Suspended</span>
+							<span v-if="silenced" class="silenced">Silenced</span>
+							<span v-if="moderator" class="moderator">Moderator</span>
+						</span>
+					</div>
+				</div>
+
+				<MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo>
+
+				<div v-if="user.url" class="_formLinksGrid _formBlock">
+					<FormLink :to="userPage(user)">Profile</FormLink>
+					<FormLink :to="user.url" :external="true">Profile (remote)</FormLink>
+				</div>
+				<FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink>
+
+				<FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
+
+				<div class="_formBlock">
+					<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
+						<template #key>ID</template>
+						<template #value><span class="_monospace">{{ user.id }}</span></template>
+					</MkKeyValue>
+					<!-- 要る？
+					<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;">
+						<template #key>IP (recent)</template>
+						<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
+					</MkKeyValue>
+					-->
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.createdAt }}</template>
+						<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
+					</MkKeyValue>
+					<MkKeyValue v-if="info" oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.lastActiveDate }}</template>
+						<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
+					</MkKeyValue>
+					<MkKeyValue v-if="info" oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.email }}</template>
+						<template #value><span class="_monospace">{{ info.email }}</span></template>
+					</MkKeyValue>
+				</div>
+
+				<FormSection>
+					<template #label>ActivityPub</template>
+
+					<div class="_formBlock">
+						<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
+							<template #key>{{ i18n.ts.instanceInfo }}</template>
+							<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="ti ti-chevron-right"></i></MkA></template>
+						</MkKeyValue>
+						<MkKeyValue v-else oneline style="margin: 1em 0;">
+							<template #key>{{ i18n.ts.instanceInfo }}</template>
+							<template #value>(Local user)</template>
+						</MkKeyValue>
+						<MkKeyValue oneline style="margin: 1em 0;">
+							<template #key>{{ i18n.ts.updatedAt }}</template>
+							<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+						</MkKeyValue>
+						<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
+							<template #key>Type</template>
+							<template #value><span class="_monospace">{{ ap.type }}</span></template>
+						</MkKeyValue>
+					</div>
+
+					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</FormButton>
+
+					<FormFolder class="_formBlock">
+						<template #label>Raw</template>
+
+						<MkObjectView v-if="ap" tall :value="ap">
+						</MkObjectView>
+					</FormFolder>
+				</FormSection>
+			</div>
+			<div v-else-if="tab === 'moderation'" class="_formRoot">
+				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:model-value="toggleModerator">{{ i18n.ts.moderator }}</FormSwitch>
+				<FormSwitch v-model="silenced" class="_formBlock" @update:model-value="toggleSilence">{{ i18n.ts.silence }}</FormSwitch>
+				<FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</FormSwitch>
+				{{ i18n.ts.reflectMayTakeTime }}
+				<div class="_formBlock">
+					<FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</FormButton>
+					<FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</FormButton>
+				</div>
+				<FormTextarea v-model="moderationNote" manual-save class="_formBlock">
+					<template #label>Moderation note</template>
+				</FormTextarea>
+				<FormFolder class="_formBlock">
+					<template #label>IP</template>
+					<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
+					<MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
+					<template v-if="iAmAdmin && ips">
+						<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
+							<span class="date">{{ record.createdAt }}</span>
+							<span class="ip">{{ record.ip }}</span>
+						</div>
+					</template>
+				</FormFolder>
+				<FormFolder class="_formBlock">
+					<template #label>{{ i18n.ts.files }}</template>
+
+					<MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
+				</FormFolder>
+				<FormSection>
+					<template #label>Drive Capacity Override</template>
+
+					<FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride">
+						<template #label>{{ i18n.ts.driveCapOverrideLabel }}</template>
+						<template #suffix>MB</template>
+						<template #caption>
+							{{ i18n.ts.driveCapOverrideCaption }}
+						</template>
+					</FormInput>
+				</FormSection>
+			</div>
+			<div v-else-if="tab === 'chart'" class="_formRoot">
+				<div class="cmhjzshm">
+					<div class="selects">
+						<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
+							<option value="per-user-notes">{{ i18n.ts.notes }}</option>
+						</MkSelect>
+					</div>
+					<div class="charts">
+						<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+						<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+						<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+						<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+					</div>
+				</div>
+			</div>
+			<div v-else-if="tab === 'raw'" class="_formRoot">
+				<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
+				</MkObjectView>
+
+				<MkObjectView tall :value="user">
+				</MkObjectView>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MkChart from '@/components/MkChart.vue';
+import MkObjectView from '@/components/MkObjectView.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormFolder from '@/components/form/folder.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkSelect from '@/components/form/select.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import { url } from '@/config';
+import { userPage, acct } from '@/filters/user';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { iAmAdmin, iAmModerator } from '@/account';
+import { instance } from '@/instance';
+
+const props = defineProps<{
+	userId: string;
+}>();
+
+let tab = $ref('overview');
+let chartSrc = $ref('per-user-notes');
+let user = $ref<null | misskey.entities.UserDetailed>();
+let init = $ref<ReturnType<typeof createFetcher>>();
+let info = $ref();
+let ips = $ref(null);
+let ap = $ref(null);
+let moderator = $ref(false);
+let silenced = $ref(false);
+let suspended = $ref(false);
+let driveCapacityOverrideMb: number | null = $ref(0);
+let moderationNote = $ref('');
+const filesPagination = {
+	endpoint: 'admin/drive/files' as const,
+	limit: 10,
+	params: computed(() => ({
+		userId: props.userId,
+	})),
+};
+
+function createFetcher() {
+	if (iAmModerator) {
+		return () => Promise.all([os.api('users/show', {
+			userId: props.userId,
+		}), os.api('admin/show-user', {
+			userId: props.userId,
+		}), iAmAdmin ? os.api('admin/get-user-ips', {
+			userId: props.userId,
+		}) : Promise.resolve(null)]).then(([_user, _info, _ips]) => {
+			user = _user;
+			info = _info;
+			ips = _ips;
+			moderator = info.isModerator;
+			silenced = info.isSilenced;
+			suspended = info.isSuspended;
+			driveCapacityOverrideMb = user.driveCapacityOverrideMb;
+			moderationNote = info.moderationNote;
+
+			watch($$(moderationNote), async () => {
+				await os.api('admin/update-user-note', { userId: user.id, text: moderationNote });
+				await refreshUser();
+			});
+		});
+	} else {
+		return () => os.api('users/show', {
+			userId: props.userId,
+		}).then((res) => {
+			user = res;
+		});
+	}
+}
+
+function refreshUser() {
+	init = createFetcher();
+}
+
+async function updateRemoteUser() {
+	await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
+	refreshUser();
+}
+
+async function resetPassword() {
+	const { password } = await os.api('admin/reset-password', {
+		userId: user.id,
+	});
+
+	os.alert({
+		type: 'success',
+		text: i18n.t('newPasswordIs', { password }),
+	});
+}
+
+async function toggleSilence(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
+	});
+	if (confirm.canceled) {
+		silenced = !v;
+	} else {
+		await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
+		await refreshUser();
+	}
+}
+
+async function toggleSuspend(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
+	});
+	if (confirm.canceled) {
+		suspended = !v;
+	} else {
+		await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
+		await refreshUser();
+	}
+}
+
+async function toggleModerator(v) {
+	await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id });
+	await refreshUser();
+}
+
+async function deleteAllFiles() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteAllFilesConfirm,
+	});
+	if (confirm.canceled) return;
+	const process = async () => {
+		await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
+		os.success();
+	};
+	await process().catch(err => {
+		os.alert({
+			type: 'error',
+			text: err.toString(),
+		});
+	});
+	await refreshUser();
+}
+
+async function applyDriveCapacityOverride() {
+	let driveCapOrMb = driveCapacityOverrideMb;
+	if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) {
+		driveCapOrMb = null;
+	}
+	try {
+		await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb });
+		await refreshUser();
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: err.toString(),
+		});
+	}
+}
+
+async function deleteAccount() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteAccountConfirm,
+	});
+	if (confirm.canceled) return;
+
+	const typed = await os.inputText({
+		text: i18n.t('typeToConfirm', { x: user?.username }),
+	});
+	if (typed.canceled) return;
+
+	if (typed.result === user?.username) {
+		await os.apiWithDialog('admin/delete-account', {
+			userId: user.id,
+		});
+	} else {
+		os.alert({
+			type: 'error',
+			text: 'input not match',
+		});
+	}
+}
+
+watch(() => props.userId, () => {
+	init = createFetcher();
+}, {
+	immediate: true,
+});
+
+watch($$(user), () => {
+	os.api('ap/get', {
+		uri: user.uri ?? `${url}/users/${user.id}`,
+	}).then(res => {
+		ap = res;
+	});
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	key: 'overview',
+	title: i18n.ts.overview,
+	icon: 'ti ti-info-circle',
+}, iAmModerator ? {
+	key: 'moderation',
+	title: i18n.ts.moderation,
+	icon: 'ti ti-user-exclamation',
+} : null, {
+	key: 'chart',
+	title: i18n.ts.charts,
+	icon: 'ti ti-chart-line',
+}, {
+	key: 'raw',
+	title: 'Raw',
+	icon: 'ti ti-code',
+}].filter(x => x != null));
+
+definePageMetadata(computed(() => ({
+	title: user ? acct(user) : i18n.ts.userInfo,
+	icon: 'ti ti-info-circle',
+})));
+</script>
+
+<style lang="scss" scoped>
+.aeakzknw {
+	display: flex;
+	align-items: center;
+
+	> .avatar {
+		display: block;
+		width: 64px;
+		height: 64px;
+		margin-right: 16px;
+	}
+
+	> .body {
+		flex: 1;
+		overflow: hidden;
+
+		> .name {
+			display: block;
+			width: 100%;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+
+		> .sub {
+			display: block;
+			width: 100%;
+			font-size: 85%;
+			opacity: 0.7;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+		}
+
+		> .state {
+			display: flex;
+			gap: 8px;
+			flex-wrap: wrap;
+			margin-top: 4px;
+
+			&:empty {
+				display: none;
+			}
+
+			> .suspended, > .silenced, > .moderator {
+				display: inline-block;
+				border: solid 1px;
+				border-radius: 6px;
+				padding: 2px 6px;
+				font-size: 85%;
+			}
+
+			> .suspended {
+				color: var(--error);
+				border-color: var(--error);
+			}
+
+			> .silenced {
+				color: var(--warn);
+				border-color: var(--warn);
+			}
+
+			> .moderator {
+				color: var(--success);
+				border-color: var(--success);
+			}
+		}
+	}
+}
+
+.cmhjzshm {
+	> .selects {
+		display: flex;
+		margin: 0 0 16px 0;
+	}
+
+	> .charts {
+		> .label {
+			margin-bottom: 12px;
+			font-weight: bold;
+		}
+	}
+}
+</style>
+
+<style lang="scss" module>
+.ip {
+	display: flex;
+
+	> :global(.date) {
+		opacity: 0.7;
+	}
+
+	> :global(.ip) {
+		margin-left: auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
new file mode 100644
index 0000000000..fdb3167375
--- /dev/null
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -0,0 +1,121 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj">
+		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
+		<div class="tl _block">
+			<XTimeline
+				ref="tlEl" :key="listId"
+				class="tl"
+				src="list"
+				:list="listId"
+				:sound="true"
+				@queue="queueUpdated"
+			/>
+		</div>
+	</div>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, inject } from 'vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	listId: string;
+}>();
+
+let list = $ref(null);
+let queue = $ref(0);
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+let rootEl = $ref<HTMLElement>();
+
+watch(() => props.listId, async () => {
+	list = await os.api('users/lists/show', {
+		listId: props.listId,
+	});
+}, { immediate: true });
+
+function queueUpdated(q) {
+	queue = q;
+}
+
+function top() {
+	scroll(rootEl, { top: 0 });
+}
+
+function settings() {
+	router.push(`/my/lists/${props.listId}`);
+}
+
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
+
+	tlEl.timetravel(date);
+}
+
+const headerActions = $computed(() => list ? [{
+	icon: 'fas fa-calendar-alt',
+	text: i18n.ts.jumpToSpecifiedDate,
+	handler: timetravel,
+}, {
+	icon: 'ti ti-settings',
+	text: i18n.ts.settings,
+	handler: settings,
+}] : []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'ti ti-list',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.eqqrhokj {
+	padding: var(--margin);
+
+	> .new {
+		position: sticky;
+		top: calc(var(--stickyTop, 0px) + 16px);
+		z-index: 1000;
+		width: 100%;
+
+		> button {
+			display: block;
+			margin: var(--margin) auto 0 auto;
+			padding: 8px 16px;
+			border-radius: 32px;
+		}
+	}
+
+	> .tl {
+		background: var(--bg);
+		border-radius: var(--radius);
+		overflow: clip;
+	}
+
+	&.min-width_800px {
+		max-width: 800px;
+		margin: 0 auto;
+	}
+}
+
+@container (min-width: 800px) {
+	.eqqrhokj {
+		max-width: 800px;
+		margin: 0 auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue
new file mode 100644
index 0000000000..8c71aacb0c
--- /dev/null
+++ b/packages/frontend/src/pages/user/clips.vue
@@ -0,0 +1,47 @@
+<template>
+<MkSpacer :content-max="700">
+	<div class="pages-user-clips">
+		<MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list">
+			<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+				<b>{{ item.name }}</b>
+				<div v-if="item.description" class="description">{{ item.description }}</div>
+			</MkA>
+		</MkPagination>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+const pagination = {
+	endpoint: 'users/clips' as const,
+	limit: 20,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+</script>
+
+<style lang="scss" scoped>
+.pages-user-clips {
+	> .list {
+		> .item {
+			display: block;
+			padding: 16px;
+
+			> .description {
+				margin-top: 8px;
+				padding-top: 8px;
+				border-top: solid 0.5px var(--divider);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue
new file mode 100644
index 0000000000..d42acd838f
--- /dev/null
+++ b/packages/frontend/src/pages/user/follow-list.vue
@@ -0,0 +1,47 @@
+<template>
+<div>
+	<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
+		<div class="users">
+			<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/>
+		</div>
+	</MkPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkUserInfo from '@/components/MkUserInfo.vue';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+	type: 'following' | 'followers';
+}>();
+
+const followingPagination = {
+	endpoint: 'users/following' as const,
+	limit: 20,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+
+const followersPagination = {
+	endpoint: 'users/followers' as const,
+	limit: 20,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+</script>
+
+<style lang="scss" scoped>
+.mk-following-or-followers {
+	> .users {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+		grid-gap: var(--margin);
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue
new file mode 100644
index 0000000000..17c2843381
--- /dev/null
+++ b/packages/frontend/src/pages/user/followers.vue
@@ -0,0 +1,61 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1000">
+		<transition name="fade" mode="out-in">
+			<div v-if="user">
+				<XFollowList :user="user" type="followers"/>
+			</div>
+			<MkError v-else-if="error" @retry="fetchUser()"/>
+			<MkLoading v-else/>
+		</transition>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import XFollowList from './follow-list.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	acct: string;
+}>(), {
+});
+
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+
+function fetchUser(): void {
+	if (props.acct == null) return;
+	user = null;
+	os.api('users/show', Acct.parse(props.acct)).then(u => {
+		user = u;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.acct, fetchUser, {
+	immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => user ? {
+	icon: 'ti ti-user',
+	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+	subtitle: i18n.ts.followers,
+	userName: user,
+	avatar: user,
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue
new file mode 100644
index 0000000000..03892ec03d
--- /dev/null
+++ b/packages/frontend/src/pages/user/following.vue
@@ -0,0 +1,61 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1000">
+		<transition name="fade" mode="out-in">
+			<div v-if="user">
+				<XFollowList :user="user" type="following"/>
+			</div>
+			<MkError v-else-if="error" @retry="fetchUser()"/>
+			<MkLoading v-else/>
+		</transition>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import XFollowList from './follow-list.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	acct: string;
+}>(), {
+});
+
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+
+function fetchUser(): void {
+	if (props.acct == null) return;
+	user = null;
+	os.api('users/show', Acct.parse(props.acct)).then(u => {
+		user = u;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.acct, fetchUser, {
+	immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => user ? {
+	icon: 'ti ti-user',
+	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+	subtitle: i18n.ts.following,
+	userName: user,
+	avatar: user,
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue
new file mode 100644
index 0000000000..b80e83fb11
--- /dev/null
+++ b/packages/frontend/src/pages/user/gallery.vue
@@ -0,0 +1,38 @@
+<template>
+<MkSpacer :content-max="700">
+	<MkPagination v-slot="{items}" :pagination="pagination">
+		<div class="jrnovfpt">
+			<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+		</div>
+	</MkPagination>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.User;
+}>(), {
+});
+
+const pagination = {
+	endpoint: 'users/gallery/posts' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+</script>
+
+<style lang="scss" scoped>
+.jrnovfpt {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: 12px;
+	margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
new file mode 100644
index 0000000000..43c1b37e1d
--- /dev/null
+++ b/packages/frontend/src/pages/user/home.vue
@@ -0,0 +1,530 @@
+<template>
+<MkSpacer :content-max="narrow ? 800 : 1100">
+	<div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
+		<div class="main">
+			<!-- TODO -->
+			<!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
+			<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
+
+			<div class="profile">
+				<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+
+				<div :key="user.id" class="_block main">
+					<div class="banner-container" :style="style">
+						<div ref="bannerEl" class="banner" :style="style"></div>
+						<div class="fade"></div>
+						<div class="title">
+							<MkUserName class="name" :user="user" :nowrap="true"/>
+							<div class="bottom">
+								<span class="username"><MkAcct :user="user" :detail="true"/></span>
+								<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
+								<span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
+								<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
+								<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
+							</div>
+						</div>
+						<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
+						<div v-if="$i" class="actions">
+							<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
+							<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+						</div>
+					</div>
+					<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+					<div class="title">
+						<MkUserName :user="user" :nowrap="false" class="name"/>
+						<div class="bottom">
+							<span class="username"><MkAcct :user="user" :detail="true"/></span>
+							<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
+							<span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
+							<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
+							<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
+						</div>
+					</div>
+					<div class="description">
+						<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+						<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
+					</div>
+					<div class="fields system">
+						<dl v-if="user.location" class="field">
+							<dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt>
+							<dd class="value">{{ user.location }}</dd>
+						</dl>
+						<dl v-if="user.birthday" class="field">
+							<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
+							<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+						</dl>
+						<dl class="field">
+							<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
+							<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+						</dl>
+					</div>
+					<div v-if="user.fields.length > 0" class="fields">
+						<dl v-for="(field, i) in user.fields" :key="i" class="field">
+							<dt class="name">
+								<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+							</dt>
+							<dd class="value">
+								<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+							</dd>
+						</dl>
+					</div>
+					<div class="status">
+						<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
+							<b>{{ number(user.notesCount) }}</b>
+							<span>{{ i18n.ts.notes }}</span>
+						</MkA>
+						<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+							<b>{{ number(user.followingCount) }}</b>
+							<span>{{ i18n.ts.following }}</span>
+						</MkA>
+						<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+							<b>{{ number(user.followersCount) }}</b>
+							<span>{{ i18n.ts.followers }}</span>
+						</MkA>
+					</div>
+				</div>
+			</div>
+
+			<div class="contents">
+				<div v-if="user.pinnedNotes.length > 0" class="_gap">
+					<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+				</div>
+				<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
+				<template v-if="narrow">
+					<XPhotos :key="user.id" :user="user"/>
+					<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+				</template>
+			</div>
+			<div>
+				<XUserTimeline :user="user"/>
+			</div>
+		</div>
+		<div v-if="!narrow" class="sub">
+			<XPhotos :key="user.id" :user="user"/>
+			<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+		</div>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
+import * as misskey from 'misskey-js';
+import XUserTimeline from './index.timeline.vue';
+import XNote from '@/components/MkNote.vue';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
+import MkTab from '@/components/MkTab.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { getScrollPosition } from '@/scripts/scroll';
+import { getUserMenu } from '@/scripts/get-user-menu';
+import number from '@/filters/number';
+import { userPage, acct as getAcct } from '@/filters/user';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+
+const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
+const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.UserDetailed;
+}>(), {
+});
+
+const router = useRouter();
+
+let parallaxAnimationId = $ref<null | number>(null);
+let narrow = $ref<null | boolean>(null);
+let rootEl = $ref<null | HTMLElement>(null);
+let bannerEl = $ref<null | HTMLElement>(null);
+
+const style = $computed(() => {
+	if (props.user.bannerUrl == null) return {};
+	return {
+		backgroundImage: `url(${ props.user.bannerUrl })`,
+	};
+});
+
+const age = $computed(() => {
+	return calcAge(props.user.birthday);
+});
+
+function menu(ev) {
+	os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target);
+}
+
+function parallaxLoop() {
+	parallaxAnimationId = window.requestAnimationFrame(parallaxLoop);
+	parallax();
+}
+
+function parallax() {
+	const banner = bannerEl as any;
+	if (banner == null) return;
+
+	const top = getScrollPosition(rootEl);
+
+	if (top < 0) return;
+
+	const z = 1.75; // 奥行き(小さいほど奥)
+	const pos = -(top / z);
+	banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+}
+
+onMounted(() => {
+	window.requestAnimationFrame(parallaxLoop);
+	narrow = rootEl!.clientWidth < 1000;
+});
+
+onUnmounted(() => {
+	if (parallaxAnimationId) {
+		window.cancelAnimationFrame(parallaxAnimationId);
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.ftskorzw {
+
+	> .main {
+
+		> .punished {
+			font-size: 0.8em;
+			padding: 16px;
+		}
+
+		> .profile {
+
+			> .main {
+				position: relative;
+				overflow: hidden;
+
+				> .banner-container {
+					position: relative;
+					height: 250px;
+					overflow: hidden;
+					background-size: cover;
+					background-position: center;
+
+					> .banner {
+						height: 100%;
+						background-color: #4c5e6d;
+						background-size: cover;
+						background-position: center;
+						box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+						will-change: background-position;
+					}
+
+					> .fade {
+						position: absolute;
+						bottom: 0;
+						left: 0;
+						width: 100%;
+						height: 78px;
+						background: linear-gradient(transparent, rgba(#000, 0.7));
+					}
+
+					> .followed {
+						position: absolute;
+						top: 12px;
+						left: 12px;
+						padding: 4px 8px;
+						color: #fff;
+						background: rgba(0, 0, 0, 0.7);
+						font-size: 0.7em;
+						border-radius: 6px;
+					}
+
+					> .actions {
+						position: absolute;
+						top: 12px;
+						right: 12px;
+						-webkit-backdrop-filter: var(--blur, blur(8px));
+						backdrop-filter: var(--blur, blur(8px));
+						background: rgba(0, 0, 0, 0.2);
+						padding: 8px;
+						border-radius: 24px;
+
+						> .menu {
+							vertical-align: bottom;
+							height: 31px;
+							width: 31px;
+							color: #fff;
+							text-shadow: 0 0 8px #000;
+							font-size: 16px;
+						}
+
+						> .koudoku {
+							margin-left: 4px;
+							vertical-align: bottom;
+						}
+					}
+
+					> .title {
+						position: absolute;
+						bottom: 0;
+						left: 0;
+						width: 100%;
+						padding: 0 0 8px 154px;
+						box-sizing: border-box;
+						color: #fff;
+
+						> .name {
+							display: block;
+							margin: 0;
+							line-height: 32px;
+							font-weight: bold;
+							font-size: 1.8em;
+							text-shadow: 0 0 8px #000;
+						}
+
+						> .bottom {
+							> * {
+								display: inline-block;
+								margin-right: 16px;
+								line-height: 20px;
+								opacity: 0.8;
+
+								&.username {
+									font-weight: bold;
+								}
+							}
+						}
+					}
+				}
+
+				> .title {
+					display: none;
+					text-align: center;
+					padding: 50px 8px 16px 8px;
+					font-weight: bold;
+					border-bottom: solid 0.5px var(--divider);
+
+					> .bottom {
+						> * {
+							display: inline-block;
+							margin-right: 8px;
+							opacity: 0.8;
+						}
+					}
+				}
+
+				> .avatar {
+					display: block;
+					position: absolute;
+					top: 170px;
+					left: 16px;
+					z-index: 2;
+					width: 120px;
+					height: 120px;
+					box-shadow: 1px 1px 3px rgba(#000, 0.2);
+				}
+
+				> .description {
+					padding: 24px 24px 24px 154px;
+					font-size: 0.95em;
+
+					> .empty {
+						margin: 0;
+						opacity: 0.5;
+					}
+				}
+
+				> .fields {
+					padding: 24px;
+					font-size: 0.9em;
+					border-top: solid 0.5px var(--divider);
+
+					> .field {
+						display: flex;
+						padding: 0;
+						margin: 0;
+						align-items: center;
+
+						&:not(:last-child) {
+							margin-bottom: 8px;
+						}
+
+						> .name {
+							width: 30%;
+							overflow: hidden;
+							white-space: nowrap;
+							text-overflow: ellipsis;
+							font-weight: bold;
+							text-align: center;
+						}
+
+						> .value {
+							width: 70%;
+							overflow: hidden;
+							white-space: nowrap;
+							text-overflow: ellipsis;
+							margin: 0;
+						}
+					}
+
+					&.system > .field > .name {
+					}
+				}
+
+				> .status {
+					display: flex;
+					padding: 24px;
+					border-top: solid 0.5px var(--divider);
+
+					> a {
+						flex: 1;
+						text-align: center;
+
+						&.active {
+							color: var(--accent);
+						}
+
+						&:hover {
+							text-decoration: none;
+						}
+
+						> b {
+							display: block;
+							line-height: 16px;
+						}
+
+						> span {
+							font-size: 70%;
+						}
+					}
+				}
+			}
+		}
+
+		> .contents {
+			> .content {
+				margin-bottom: var(--margin);
+			}
+		}
+	}
+
+	&.max-width_500px {
+		> .main {
+			> .profile > .main {
+				> .banner-container {
+					height: 140px;
+
+					> .fade {
+						display: none;
+					}
+
+					> .title {
+						display: none;
+					}
+				}
+
+				> .title {
+					display: block;
+				}
+
+				> .avatar {
+					top: 90px;
+					left: 0;
+					right: 0;
+					width: 92px;
+					height: 92px;
+					margin: auto;
+				}
+
+				> .description {
+					padding: 16px;
+					text-align: center;
+				}
+
+				> .fields {
+					padding: 16px;
+				}
+
+				> .status {
+					padding: 16px;
+				}
+			}
+
+			> .contents {
+				> .nav {
+					font-size: 80%;
+				}
+			}
+		}
+	}
+
+	&.wide {
+		display: flex;
+		width: 100%;
+
+		> .main {
+			width: 100%;
+			min-width: 0;
+		}
+
+		> .sub {
+			max-width: 350px;
+			min-width: 350px;
+			margin-left: var(--margin);
+		}
+	}
+}
+
+@container (max-width: 500px) {
+	.ftskorzw {
+		> .main {
+			> .profile > .main {
+				> .banner-container {
+					height: 140px;
+
+					> .fade {
+						display: none;
+					}
+
+					> .title {
+						display: none;
+					}
+				}
+
+				> .title {
+					display: block;
+				}
+
+				> .avatar {
+					top: 90px;
+					left: 0;
+					right: 0;
+					width: 92px;
+					height: 92px;
+					margin: auto;
+				}
+
+				> .description {
+					padding: 16px;
+					text-align: center;
+				}
+
+				> .fields {
+					padding: 16px;
+				}
+
+				> .status {
+					padding: 16px;
+				}
+			}
+
+			> .contents {
+				> .nav {
+					font-size: 80%;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue
new file mode 100644
index 0000000000..523072d2e6
--- /dev/null
+++ b/packages/frontend/src/pages/user/index.activity.vue
@@ -0,0 +1,52 @@
+<template>
+<MkContainer>
+	<template #header><i class="ti ti-chart-line" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
+	<template #func>
+		<button class="_button" @click="showMenu">
+			<i class="ti ti-dots"></i>
+		</button>
+	</template>
+
+	<div style="padding: 8px;">
+		<MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import MkContainer from '@/components/MkContainer.vue';
+import MkChart from '@/components/MkChart.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.User;
+	limit?: number;
+}>(), {
+	limit: 50,
+});
+
+let chartSrc = $ref('per-user-notes');
+
+function showMenu(ev: MouseEvent) {
+	os.popupMenu([{
+		text: i18n.ts.notes,
+		active: true,
+		action: () => {
+			chartSrc = 'per-user-notes';
+		},
+	}, /*, {
+		text: i18n.ts.following,
+		action: () => {
+			chartSrc = 'per-user-following';
+		}
+	}, {
+		text: i18n.ts.followers,
+		action: () => {
+			chartSrc = 'per-user-followers';
+		}
+	}*/], ev.currentTarget ?? ev.target);
+}
+</script>
diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue
new file mode 100644
index 0000000000..b33979a79d
--- /dev/null
+++ b/packages/frontend/src/pages/user/index.photos.vue
@@ -0,0 +1,102 @@
+<template>
+<MkContainer :max-height="300" :foldable="true">
+	<template #header><i class="ti ti-photo" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template>
+	<div class="ujigsodd">
+		<MkLoading v-if="fetching"/>
+		<div v-if="!fetching && images.length > 0" class="stream">
+			<MkA
+				v-for="image in images"
+				:key="image.note.id + image.file.id"
+				class="img"
+				:to="notePage(image.note)"
+			>
+				<ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/>
+			</MkA>
+		</div>
+		<p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { notePage } from '@/filters/note';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+	user: misskey.entities.UserDetailed;
+}>();
+
+let fetching = $ref(true);
+let images = $ref<{
+	note: misskey.entities.Note;
+	file: misskey.entities.DriveFile;
+}[]>([]);
+
+function thumbnail(image: misskey.entities.DriveFile): string {
+	return defaultStore.state.disableShowingAnimatedImages
+		? getStaticImageUrl(image.thumbnailUrl)
+		: image.thumbnailUrl;
+}
+
+onMounted(() => {
+	const image = [
+		'image/jpeg',
+		'image/webp',
+		'image/avif',
+		'image/png',
+		'image/gif',
+		'image/apng',
+		'image/vnd.mozilla.apng',
+	];
+	os.api('users/notes', {
+		userId: props.user.id,
+		fileType: image,
+		excludeNsfw: defaultStore.state.nsfw !== 'ignore',
+		limit: 10,
+	}).then(notes => {
+		for (const note of notes) {
+			for (const file of note.files) {
+				images.push({
+					note,
+					file,
+				});
+			}
+		}
+		fetching = false;
+	});
+});
+</script>
+
+<style lang="scss" scoped>
+.ujigsodd {
+	padding: 8px;
+
+	> .stream {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+		grid-gap: 6px;
+
+		> .img {
+			height: 128px;
+			border-radius: 6px;
+			overflow: clip;
+		}
+	}
+
+	> .empty {
+		margin: 0;
+		padding: 16px;
+		text-align: center;
+
+		> i {
+			margin-right: 4px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue
new file mode 100644
index 0000000000..41983a5ae8
--- /dev/null
+++ b/packages/frontend/src/pages/user/index.timeline.vue
@@ -0,0 +1,45 @@
+<template>
+<MkStickyContainer>
+	<template #header>
+		<MkTab v-model="include" :class="$style.tab">
+			<option :value="null">{{ i18n.ts.notes }}</option>
+			<option value="replies">{{ i18n.ts.notesAndReplies }}</option>
+			<option value="files">{{ i18n.ts.withFiles }}</option>
+		</MkTab>
+	</template>
+	<XNotes :no-gap="true" :pagination="pagination"/>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import * as misskey from 'misskey-js';
+import XNotes from '@/components/MkNotes.vue';
+import MkTab from '@/components/MkTab.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	user: misskey.entities.UserDetailed;
+}>();
+
+const include = ref<string | null>(null);
+
+const pagination = {
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => ({
+		userId: props.user.id,
+		includeReplies: include.value === 'replies',
+		withFiles: include.value === 'files',
+	})),
+};
+</script>
+
+<style lang="scss" module>
+.tab {
+	margin: calc(var(--margin) / 2) 0;
+	padding: calc(var(--margin) / 2) 0;
+	background: var(--bg);
+}
+</style>
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
new file mode 100644
index 0000000000..6e895cd8d7
--- /dev/null
+++ b/packages/frontend/src/pages/user/index.vue
@@ -0,0 +1,113 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<div>
+		<transition name="fade" mode="out-in">
+			<div v-if="user">
+				<XHome v-if="tab === 'home'" :user="user"/>
+				<XReactions v-else-if="tab === 'reactions'" :user="user"/>
+				<XClips v-else-if="tab === 'clips'" :user="user"/>
+				<XPages v-else-if="tab === 'pages'" :user="user"/>
+				<XGallery v-else-if="tab === 'gallery'" :user="user"/>
+			</div>
+			<MkError v-else-if="error" @retry="fetchUser()"/>
+			<MkLoading v-else/>
+		</transition>
+	</div>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import { getScrollPosition } from '@/scripts/scroll';
+import number from '@/filters/number';
+import { userPage, acct as getAcct } from '@/filters/user';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+
+const XHome = defineAsyncComponent(() => import('./home.vue'));
+const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
+const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XPages = defineAsyncComponent(() => import('./pages.vue'));
+const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
+
+const props = withDefaults(defineProps<{
+	acct: string;
+	page?: string;
+}>(), {
+	page: 'home',
+});
+
+const router = useRouter();
+
+let tab = $ref(props.page);
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+
+function fetchUser(): void {
+	if (props.acct == null) return;
+	user = null;
+	os.api('users/show', Acct.parse(props.acct)).then(u => {
+		user = u;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.acct, fetchUser, {
+	immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => user ? [{
+	key: 'home',
+	title: i18n.ts.overview,
+	icon: 'ti ti-home',
+}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
+	key: 'reactions',
+	title: i18n.ts.reaction,
+	icon: 'ti ti-mood-happy',
+}] : [], {
+	key: 'clips',
+	title: i18n.ts.clips,
+	icon: 'ti ti-paperclip',
+}, {
+	key: 'pages',
+	title: i18n.ts.pages,
+	icon: 'ti ti-news',
+}, {
+	key: 'gallery',
+	title: i18n.ts.gallery,
+	icon: 'ti ti-icons',
+}] : null);
+
+definePageMetadata(computed(() => user ? {
+	icon: 'ti ti-user',
+	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+	subtitle: `@${getAcct(user)}`,
+	userName: user,
+	avatar: user,
+	path: `/@${user.username}`,
+	share: {
+		title: user.name,
+	},
+} : null));
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+</style>
diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue
new file mode 100644
index 0000000000..7833d6c42c
--- /dev/null
+++ b/packages/frontend/src/pages/user/pages.vue
@@ -0,0 +1,30 @@
+<template>
+<MkSpacer :content-max="700">
+	<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
+		<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
+	</MkPagination>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkPagePreview from '@/components/MkPagePreview.vue';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+const pagination = {
+	endpoint: 'users/pages' as const,
+	limit: 20,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue
new file mode 100644
index 0000000000..ab3df34301
--- /dev/null
+++ b/packages/frontend/src/pages/user/reactions.vue
@@ -0,0 +1,61 @@
+<template>
+<MkSpacer :content-max="700">
+	<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
+		<div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb">
+			<div class="header">
+				<MkAvatar class="avatar" :user="user"/>
+				<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
+				<MkTime :time="item.createdAt" class="createdAt"/>
+			</div>
+			<MkNote :key="item.id" :note="item.note"/>
+		</div>
+	</MkPagination>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkPagination from '@/components/MkPagination.vue';
+import MkNote from '@/components/MkNote.vue';
+import MkReactionIcon from '@/components/MkReactionIcon.vue';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+const pagination = {
+	endpoint: 'users/reactions' as const,
+	limit: 20,
+	params: computed(() => ({
+		userId: props.user.id,
+	})),
+};
+</script>
+
+<style lang="scss" scoped>
+.afdcfbfb {
+	> .header {
+		display: flex;
+		align-items: center;
+		padding: 8px 16px;
+		margin-bottom: 8px;
+		border-bottom: solid 2px var(--divider);
+
+		> .avatar {
+			width: 24px;
+			height: 24px;
+			margin-right: 8px;
+		}
+
+		> .reaction {
+			width: 32px;
+			height: 32px;
+		}
+
+		> .createdAt {
+			margin-left: auto;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
new file mode 100644
index 0000000000..bfa54d39f2
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -0,0 +1,309 @@
+<template>
+<div v-if="meta" class="rsqzvsbo">
+	<div class="top">
+		<MkFeaturedPhotos class="bg"/>
+		<XTimeline class="tl"/>
+		<div class="shape1"></div>
+		<div class="shape2"></div>
+		<img src="/client-assets/misskey.svg" class="misskey"/>
+		<div class="emojis">
+			<MkEmoji :normal="true" :no-style="true" emoji="👍"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="❤"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="😆"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
+		</div>
+		<div class="main">
+			<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+			<button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
+			<div class="fg">
+				<h1>
+					<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
+					<!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
+					<span class="text">{{ instanceName }}</span>
+				</h1>
+				<div class="about">
+					<!-- eslint-disable-next-line vue/no-v-html -->
+					<div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
+				</div>
+				<div class="action">
+					<MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton>
+					<MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
+				</div>
+			</div>
+		</div>
+		<div v-if="instances" class="federation">
+			<MarqueeText :duration="40">
+				<MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window">
+					<!--<MkInstanceCardMini :instance="instance"/>-->
+					<img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
+					<span class="name _monospace">{{ instance.host }}</span>
+				</MkA>
+			</MarqueeText>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { toUnicode } from 'punycode/';
+import XTimeline from './welcome.timeline.vue';
+import MarqueeText from '@/components/MkMarquee.vue';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+import XNote from '@/components/MkNote.vue';
+import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+let meta = $ref();
+let stats = $ref();
+let tags = $ref();
+let onlineUsersCount = $ref();
+let instances = $ref();
+
+os.api('meta', { detail: true }).then(_meta => {
+	meta = _meta;
+});
+
+os.api('stats').then(_stats => {
+	stats = _stats;
+});
+
+os.api('get-online-users-count').then(res => {
+	onlineUsersCount = res.count;
+});
+
+os.api('hashtags/list', {
+	sort: '+mentionedLocalUsers',
+	limit: 8,
+}).then(_tags => {
+	tags = _tags;
+});
+
+os.api('federation/instances', {
+	sort: '+pubSub',
+	limit: 20,
+}).then(_instances => {
+	instances = _instances;
+});
+
+function signin() {
+	os.popup(XSigninDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
+
+function signup() {
+	os.popup(XSignupDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
+
+function showMenu(ev) {
+	os.popupMenu([{
+		text: i18n.ts.instanceInfo,
+		icon: 'ti ti-info-circle',
+		action: () => {
+			os.pageWindow('/about');
+		},
+	}, {
+		text: i18n.ts.aboutMisskey,
+		icon: 'ti ti-info-circle',
+		action: () => {
+			os.pageWindow('/about-misskey');
+		},
+	}, null, {
+		text: i18n.ts.help,
+		icon: 'ti ti-question-circle',
+		action: () => {
+			window.open('https://misskey-hub.net/help.md', '_blank');
+		},
+	}], ev.currentTarget ?? ev.target);
+}
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+	> .top {
+		display: flex;
+		text-align: center;
+		min-height: 100vh;
+		box-sizing: border-box;
+		padding: 16px;
+
+		> .bg {
+			position: absolute;
+			top: 0;
+			right: 0;
+			width: 80%; // 100%からshapeの幅を引いている
+			height: 100%;
+		}
+
+		> .tl {
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			right: 64px;
+			margin: auto;
+			width: 500px;
+			height: calc(100% - 128px);
+			overflow: hidden;
+			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+
+			@media (max-width: 1200px) {
+				display: none;
+			}
+		}
+
+		> .shape1 {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--accent);
+			clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%);
+		}
+		> .shape2 {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--accent);
+			clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%);
+			opacity: 0.5;
+		}
+
+		> .misskey {
+			position: absolute;
+			top: 42px;
+			left: 42px;
+			width: 140px;
+
+			@media (max-width: 450px) {
+				width: 130px;
+			}
+		}
+
+		> .emojis {
+			position: absolute;
+			bottom: 32px;
+			left: 35px;
+
+			> * {
+				margin-right: 8px;
+			}
+
+			@media (max-width: 1200px) {
+				display: none;
+			}
+		}
+
+		> .main {
+			position: relative;
+			width: min(480px, 100%);
+			margin: auto auto auto 128px;
+			background: var(--panel);
+			border-radius: var(--radius);
+			box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+			@media (max-width: 1200px) {
+				margin: auto;
+			}
+
+			> .icon {
+				width: 85px;
+				margin-top: -47px;
+				border-radius: 100%;
+				vertical-align: bottom;
+			}
+
+			> .menu {
+				position: absolute;
+				top: 16px;
+				right: 16px;
+				width: 32px;
+				height: 32px;
+				border-radius: 8px;
+				font-size: 18px;
+			}
+
+			> .fg {
+				position: relative;
+				z-index: 1;
+
+				> h1 {
+					display: block;
+					margin: 0;
+					padding: 16px 32px 24px 32px;
+					font-size: 1.4em;
+
+					> .logo {
+						vertical-align: bottom;
+						max-height: 120px;
+						max-width: min(100%, 300px);
+					}
+				}
+
+				> .about {
+					padding: 0 32px;
+				}
+
+				> .action {
+					padding: 32px;
+
+					> * {
+						line-height: 28px;
+					}
+				}
+			}
+		}
+
+		> .federation {
+			position: absolute;
+			bottom: 16px;
+			left: 0;
+			right: 0;
+			margin: auto;
+			background: var(--acrylicPanel);
+			-webkit-backdrop-filter: var(--blur, blur(15px));
+			backdrop-filter: var(--blur, blur(15px));
+			border-radius: 999px;
+			overflow: clip;
+			width: 800px;
+			padding: 8px 0;
+
+			@media (max-width: 900px) {
+				display: none;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss" module>
+.federationInstance {
+	display: inline-flex;
+	align-items: center;
+	vertical-align: bottom;
+	padding: 6px 12px 6px 6px;
+	margin: 0 10px 0 0;
+	background: var(--panel);
+	border-radius: 999px;
+
+	> :global(.icon) {
+		display: inline-block;
+		width: 20px;
+		height: 20px;
+		margin-right: 5px;
+		border-radius: 999px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue
new file mode 100644
index 0000000000..8230adaf1f
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.entrance.b.vue
@@ -0,0 +1,237 @@
+<template>
+<div v-if="meta" class="rsqzvsbo">
+	<div class="top">
+		<MkFeaturedPhotos class="bg"/>
+		<XTimeline class="tl"/>
+		<div class="shape"></div>
+		<div class="main">
+			<h1>
+				<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+			</h1>
+			<div class="about">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+			</div>
+			<div class="action">
+				<MkButton class="signup" inline gradate @click="signup()">{{ $ts.signup }}</MkButton>
+				<MkButton class="signin" inline @click="signin()">{{ $ts.login }}</MkButton>
+			</div>
+			<div v-if="onlineUsersCount && stats" class="status">
+				<div>
+					<I18n :src="$ts.nUsers" text-tag="span" class="users">
+						<template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
+					</I18n>
+					<I18n :src="$ts.nNotes" text-tag="span" class="notes">
+						<template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
+					</I18n>
+				</div>
+				<I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
+					<template #n><b>{{ onlineUsersCount }}</b></template>
+				</I18n>
+			</div>
+		</div>
+		<img src="/client-assets/misskey.svg" class="misskey"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+import XNote from '@/components/MkNote.vue';
+import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
+import XTimeline from './welcome.timeline.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		XNote,
+		XTimeline,
+		MkFeaturedPhotos,
+	},
+
+	data() {
+		return {
+			host: toUnicode(host),
+			instanceName,
+			meta: null,
+			stats: null,
+			tags: [],
+			onlineUsersCount: null,
+		};
+	},
+
+	created() {
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+
+		os.api('stats').then(stats => {
+			this.stats = stats;
+		});
+
+		os.api('get-online-users-count').then(res => {
+			this.onlineUsersCount = res.count;
+		});
+
+		os.api('hashtags/list', {
+			sort: '+mentionedLocalUsers',
+			limit: 8,
+		}).then(tags => {
+			this.tags = tags;
+		});
+	},
+
+	methods: {
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		showMenu(ev) {
+			os.popupMenu([{
+				text: this.$t('aboutX', { x: instanceName }),
+				icon: 'ti ti-info-circle',
+				action: () => {
+					os.pageWindow('/about');
+				},
+			}, {
+				text: this.$ts.aboutMisskey,
+				icon: 'ti ti-info-circle',
+				action: () => {
+					os.pageWindow('/about-misskey');
+				},
+			}, null, {
+				text: this.$ts.help,
+				icon: 'ti ti-question-circle',
+				action: () => {
+					window.open(`https://misskey-hub.net/help.md`, '_blank');
+				},
+			}], ev.currentTarget ?? ev.target);
+		},
+
+		number,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+	> .top {
+		min-height: 100vh;
+		box-sizing: border-box;
+
+		> .bg {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+		}
+
+		> .tl {
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			right: 64px;
+			margin: auto;
+			width: 500px;
+			height: calc(100% - 128px);
+			overflow: hidden;
+			-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+			mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+		}
+
+		> .shape {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--accent);
+			clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%);
+		}
+
+		> .misskey {
+			position: absolute;
+			bottom: 64px;
+			left: 64px;
+			width: 160px;
+		}
+
+		> .main {
+			position: relative;
+			width: min(450px, 100%);
+			padding: 64px;
+			color: #fff;
+			font-size: 1.1em;
+
+			@media (max-width: 1200px) {
+				margin: auto;
+			}
+
+			> h1 {
+				display: block;
+				margin: 0 0 32px 0;
+				padding: 0;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 100px;
+				}
+			}
+
+			> .about {
+				padding: 0;
+			}
+
+			> .action {
+				margin: 32px 0;
+
+				> * {
+					line-height: 32px;
+				}
+
+				> .signup {
+					background: var(--panel);
+					color: var(--fg);
+				}
+
+				> .signin {
+					background: var(--accent);
+					color: inherit;
+				}
+			}
+
+			> .status {
+				margin: 32px 0;
+				border-top: solid 1px rgba(255, 255, 255, 0.5);
+				font-size: 90%;
+
+				> div {
+					padding: 16px 0;
+
+					> span:not(:last-child) {
+						padding-right: 1em;
+						margin-right: 1em;
+						border-right: solid 1px rgba(255, 255, 255, 0.5);
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue
new file mode 100644
index 0000000000..d2d07bb1f0
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.entrance.c.vue
@@ -0,0 +1,306 @@
+<template>
+<div v-if="meta" class="rsqzvsbo">
+	<div class="top">
+		<MkFeaturedPhotos class="bg"/>
+		<div class="fade"></div>
+		<div class="emojis">
+			<MkEmoji :normal="true" :no-style="true" emoji="👍"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="❤"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="😆"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
+			<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
+		</div>
+		<div class="main">
+			<img src="/client-assets/misskey.svg" class="misskey"/>
+			<div class="form _panel">
+				<div class="bg">
+					<div class="fade"></div>
+				</div>
+				<div class="fg">
+					<h1>
+						<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+					</h1>
+					<div class="about">
+						<!-- eslint-disable-next-line vue/no-v-html -->
+						<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+					</div>
+					<div class="action">
+						<MkButton inline gradate @click="signup()">{{ $ts.signup }}</MkButton>
+						<MkButton inline @click="signin()">{{ $ts.login }}</MkButton>
+					</div>
+					<div v-if="onlineUsersCount && stats" class="status">
+						<div>
+							<I18n :src="$ts.nUsers" text-tag="span" class="users">
+								<template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
+							</I18n>
+							<I18n :src="$ts.nNotes" text-tag="span" class="notes">
+								<template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
+							</I18n>
+						</div>
+						<I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
+							<template #n><b>{{ onlineUsersCount }}</b></template>
+						</I18n>
+					</div>
+					<button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
+				</div>
+			</div>
+			<nav class="nav">
+				<MkA to="/announcements">{{ $ts.announcements }}</MkA>
+				<MkA to="/explore">{{ $ts.explore }}</MkA>
+				<MkA to="/channels">{{ $ts.channel }}</MkA>
+				<MkA to="/featured">{{ $ts.featured }}</MkA>
+			</nav>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+import XNote from '@/components/MkNote.vue';
+import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
+import XTimeline from './welcome.timeline.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		XNote,
+		MkFeaturedPhotos,
+		XTimeline,
+	},
+
+	data() {
+		return {
+			host: toUnicode(host),
+			instanceName,
+			meta: null,
+			stats: null,
+			tags: [],
+			onlineUsersCount: null,
+		};
+	},
+
+	created() {
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+
+		os.api('stats').then(stats => {
+			this.stats = stats;
+		});
+
+		os.api('get-online-users-count').then(res => {
+			this.onlineUsersCount = res.count;
+		});
+
+		os.api('hashtags/list', {
+			sort: '+mentionedLocalUsers',
+			limit: 8,
+		}).then(tags => {
+			this.tags = tags;
+		});
+	},
+
+	methods: {
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		showMenu(ev) {
+			os.popupMenu([{
+				text: this.$t('aboutX', { x: instanceName }),
+				icon: 'ti ti-info-circle',
+				action: () => {
+					os.pageWindow('/about');
+				},
+			}, {
+				text: this.$ts.aboutMisskey,
+				icon: 'ti ti-info-circle',
+				action: () => {
+					os.pageWindow('/about-misskey');
+				},
+			}, null, {
+				text: this.$ts.help,
+				icon: 'ti ti-question-circle',
+				action: () => {
+					window.open(`https://misskey-hub.net/help.md`, '_blank');
+				},
+			}], ev.currentTarget ?? ev.target);
+		},
+
+		number,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+	> .top {
+		display: flex;
+		text-align: center;
+		min-height: 100vh;
+		box-sizing: border-box;
+		padding: 16px;
+
+		> .bg {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+		}
+
+		> .fade {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: rgba(0, 0, 0, 0.25);
+		}
+
+		> .emojis {
+			position: absolute;
+			bottom: 32px;
+			left: 35px;
+
+			> * {
+				margin-right: 8px;
+			}
+
+			@media (max-width: 1200px) {
+				display: none;
+			}
+		}
+
+		> .main {
+			position: relative;
+			width: min(460px, 100%);
+			margin: auto;
+
+			> .misskey {
+				width: 150px;
+				margin-bottom: 16px;
+
+				@media (max-width: 450px) {
+					width: 130px;
+				}
+			}
+
+			> .form {
+				position: relative;
+				box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+				> .bg {
+					position: absolute;
+					top: 0;
+					left: 0;
+					width: 100%;
+					height: 128px;
+					background-position: center;
+					background-size: cover;
+					opacity: 0.75;
+
+					> .fade {
+						position: absolute;
+						bottom: 0;
+						left: 0;
+						width: 100%;
+						height: 128px;
+						background: linear-gradient(0deg, var(--panel), var(--X15));
+					}
+				}
+
+				> .fg {
+					position: relative;
+					z-index: 1;
+
+					> h1 {
+						display: block;
+						margin: 0;
+						padding: 32px 32px 24px 32px;
+
+						> .logo {
+							vertical-align: bottom;
+							max-height: 120px;
+						}
+					}
+
+					> .about {
+						padding: 0 32px;
+					}
+
+					> .action {
+						padding: 32px;
+
+						> * {
+							line-height: 28px;
+						}
+					}
+
+					> .status {
+						border-top: solid 0.5px var(--divider);
+						padding: 32px;
+						font-size: 90%;
+
+						> div {
+							> span:not(:last-child) {
+								padding-right: 1em;
+								margin-right: 1em;
+								border-right: solid 0.5px var(--divider);
+							}
+						}
+
+						> .online {
+							::v-deep(b) {
+								color: #41b781;
+							}
+
+							::v-deep(span) {
+								opacity: 0.7;
+							}
+						}
+					}
+
+					> .menu {
+						position: absolute;
+						top: 16px;
+						right: 16px;
+						width: 32px;
+						height: 32px;
+						border-radius: 8px;
+					}
+				}
+			}
+
+			> .nav {
+				position: relative;
+				z-index: 2;
+				margin-top: 20px;
+				color: #fff;
+				text-shadow: 0 0 8px black;
+				font-size: 0.9em;
+
+				> *:not(:last-child) {
+					margin-right: 1.5em;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue
new file mode 100644
index 0000000000..2729d30d4b
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.setup.vue
@@ -0,0 +1,89 @@
+<template>
+<form class="mk-setup" @submit.prevent="submit()">
+	<h1>Welcome to Misskey!</h1>
+	<div class="_formRoot">
+		<p>{{ $ts.intro }}</p>
+		<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
+			<template #label>{{ $ts.username }}</template>
+			<template #prefix>@</template>
+			<template #suffix>@{{ host }}</template>
+		</MkInput>
+		<MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock">
+			<template #label>{{ $ts.password }}</template>
+			<template #prefix><i class="ti ti-lock"></i></template>
+		</MkInput>
+		<div class="bottom _formBlock">
+			<MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok>
+				{{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/>
+			</MkButton>
+		</div>
+	</div>
+</form>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import { host } from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+import { i18n } from '@/i18n';
+
+let username = $ref('');
+let password = $ref('');
+let submitting = $ref(false);
+
+function submit() {
+	if (submitting) return;
+	submitting = true;
+
+	os.api('admin/accounts/create', {
+		username: username,
+		password: password,
+	}).then(res => {
+		return login(res.token);
+	}).catch(() => {
+		submitting = false;
+
+		os.alert({
+			type: 'error',
+			text: i18n.ts.somethingHappened,
+		});
+	});
+}
+</script>
+
+<style lang="scss" scoped>
+.mk-setup {
+	border-radius: var(--radius);
+	box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+	overflow: hidden;
+	max-width: 500px;
+	margin: 32px auto;
+
+	> h1 {
+		margin: 0;
+		font-size: 1.5em;
+		text-align: center;
+		padding: 32px;
+		background: var(--accent);
+		color: #fff;
+	}
+
+	> div {
+		padding: 32px;
+		background: var(--panel);
+
+		> p {
+			margin-top: 0;
+		}
+
+		> .bottom {
+			> * {
+				margin: 0 auto;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
new file mode 100644
index 0000000000..d6a88540d1
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -0,0 +1,99 @@
+<template>
+<div class="civpbkhh">
+	<div ref="scroll" class="scrollbox" v-bind:class="{ scroll: isScrolling }">
+		<div v-for="note in notes" class="note">
+			<div class="content _panel">
+				<div class="body">
+					<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+					<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+					<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+				</div>
+				<div v-if="note.files.length > 0" class="richcontent">
+					<XMediaList :media-list="note.files"/>
+				</div>
+				<div v-if="note.poll">
+					<XPoll :note="note" :readOnly="true"/>
+				</div>
+			</div>
+			<XReactionsViewer ref="reactionsViewer" :note="note"/>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XReactionsViewer from '@/components/MkReactionsViewer.vue';
+import XMediaList from '@/components/MkMediaList.vue';
+import XPoll from '@/components/MkPoll.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		XReactionsViewer,
+		XMediaList,
+		XPoll,
+	},
+
+	data() {
+		return {
+			notes: [],
+			isScrolling: false,
+		};
+	},
+
+	created() {
+		os.api('notes/featured').then(notes => {
+			this.notes = notes;
+		});
+	},
+
+	updated() {
+		if (this.$refs.scroll.clientHeight > window.innerHeight) {
+			this.isScrolling = true;
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes scroll {
+	0% {
+		transform: translate3d(0, 0, 0);
+	}
+	5% {
+		transform: translate3d(0, 0, 0);
+	}
+	75% {
+		transform: translate3d(0, calc(-100% + 90vh), 0);
+	}
+	90% {
+		transform: translate3d(0, calc(-100% + 90vh), 0);
+	}
+}
+
+.civpbkhh {
+	text-align: right;
+
+	> .scrollbox {
+		&.scroll {
+			animation: scroll 45s linear infinite;
+		}
+
+		> .note {
+			margin: 16px 0 16px auto;
+
+			> .content {
+				padding: 16px;
+				margin: 0 0 0 auto;
+				max-width: max-content;
+				border-radius: 16px;
+
+				> .richcontent {
+					min-width: 250px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue
new file mode 100644
index 0000000000..a1c3fc2abb
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.vue
@@ -0,0 +1,30 @@
+<template>
+<div v-if="meta">
+	<XSetup v-if="meta.requireSetup"/>
+	<XEntrance v-else/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XSetup from './welcome.setup.vue';
+import XEntrance from './welcome.entrance.a.vue';
+import { instanceName } from '@/config';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let meta = $ref(null);
+
+os.api('meta', { detail: true }).then(res => {
+	meta = res;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: instanceName,
+	icon: null,
+})));
+</script>
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts
new file mode 100644
index 0000000000..642e1f4f7f
--- /dev/null
+++ b/packages/frontend/src/pizzax.ts
@@ -0,0 +1,169 @@
+// PIZZAX --- A lightweight store
+
+import { onUnmounted, Ref, ref, watch } from 'vue';
+import { $i } from './account';
+import { api } from './os';
+import { stream } from './stream';
+
+type StateDef = Record<string, {
+	where: 'account' | 'device' | 'deviceAccount';
+	default: any;
+}>;
+
+type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
+
+const connection = $i && stream.useChannel('main');
+
+export class Storage<T extends StateDef> {
+	public readonly key: string;
+	public readonly keyForLocalStorage: string;
+
+	public readonly def: T;
+
+	// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
+	public readonly state: { [K in keyof T]: T[K]['default'] };
+	public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> };
+
+	constructor(key: string, def: T) {
+		this.key = key;
+		this.keyForLocalStorage = 'pizzax::' + key;
+		this.def = def;
+
+		// TODO: indexedDBにする
+		const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
+		const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {};
+		const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {};
+
+		const state = {};
+		const reactiveState = {};
+		for (const [k, v] of Object.entries(def)) {
+			if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
+				state[k] = deviceState[k];
+			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
+				state[k] = registryCache[k];
+			} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
+				state[k] = deviceAccountState[k];
+			} else {
+				state[k] = v.default;
+				if (_DEV_) console.log('Use default value', k, v.default);
+			}
+		}
+		for (const [k, v] of Object.entries(state)) {
+			reactiveState[k] = ref(v);
+		}
+		this.state = state as any;
+		this.reactiveState = reactiveState as any;
+	
+		if ($i) {
+			// なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう)
+			window.setTimeout(() => {
+				api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => {
+					const cache = {};
+					for (const [k, v] of Object.entries(def)) {
+						if (v.where === 'account') {
+							if (Object.prototype.hasOwnProperty.call(kvs, k)) {
+								state[k] = kvs[k];
+								reactiveState[k].value = kvs[k];
+								cache[k] = kvs[k];
+							} else {
+								state[k] = v.default;
+								reactiveState[k].value = v.default;
+							}
+						}
+					}
+					localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
+				});
+			}, 1);
+			// streamingのuser storage updateイベントを監視して更新
+			connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => {
+				if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return;
+
+				this.state[key] = value;
+				this.reactiveState[key].value = value;
+
+				const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
+				if (cache[key] !== value) {
+					cache[key] = value;
+					localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
+				}
+			});
+		}
+	}
+
+	public set<K extends keyof T>(key: K, value: T[K]['default']): void {
+		if (_DEV_) console.log('set', key, value);
+
+		this.state[key] = value;
+		this.reactiveState[key].value = value;
+
+		switch (this.def[key].where) {
+			case 'device': {
+				const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
+				deviceState[key] = value;
+				localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState));
+				break;
+			}
+			case 'deviceAccount': {
+				if ($i == null) break;
+				const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}');
+				deviceAccountState[key] = value;
+				localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState));
+				break;
+			}
+			case 'account': {
+				if ($i == null) break;
+				const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
+				cache[key] = value;
+				localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
+				api('i/registry/set', {
+					scope: ['client', this.key],
+					key: key,
+					value: value,
+				});
+				break;
+			}
+		}
+	}
+
+	public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void {
+		const currentState = this.state[key];
+		this.set(key, [...currentState, value]);
+	}
+
+	public reset(key: keyof T) {
+		this.set(key, this.def[key].default);
+	}
+
+	/**
+	 * 特定のキーの、簡易的なgetter/setterを作ります
+	 * 主にvue場で設定コントロールのmodelとして使う用
+	 */
+	public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) {
+		const valueRef = ref(this.state[key]);
+
+		const stop = watch(this.reactiveState[key], val => {
+			valueRef.value = val;
+		});
+
+		// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
+		onUnmounted(() => {
+			stop();
+		});
+
+		// TODO: VueのcustomRef使うと良い感じになるかも
+		return {
+			get: () => {
+				if (getter) {
+					return getter(valueRef.value);
+				} else {
+					return valueRef.value;
+				}
+			},
+			set: (value: unknown) => {
+				const val = setter ? setter(value) : value;
+				this.set(key, val);
+				valueRef.value = val;
+			},
+		};
+	}
+}
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
new file mode 100644
index 0000000000..3a00cd0455
--- /dev/null
+++ b/packages/frontend/src/plugin.ts
@@ -0,0 +1,123 @@
+import { AiScript, utils, values } from '@syuilo/aiscript';
+import { deserialize } from '@syuilo/aiscript/built/serializer';
+import { jsToVal } from '@syuilo/aiscript/built/interpreter/util';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { inputText } from '@/os';
+import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store';
+
+const pluginContexts = new Map<string, AiScript>();
+
+export function install(plugin) {
+	console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
+
+	const aiscript = new AiScript(createPluginEnv({
+		plugin: plugin,
+		storageKey: 'plugins:' + plugin.id,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			console.log(value);
+		},
+		log: (type, params) => {
+		},
+	});
+
+	initPlugin({ plugin, aiscript });
+
+	aiscript.exec(deserialize(plugin.ast));
+}
+
+function createPluginEnv(opts) {
+	const config = new Map();
+	for (const [k, v] of Object.entries(opts.plugin.config || {})) {
+		config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
+	}
+
+	return {
+		...createAiScriptEnv({ ...opts, token: opts.plugin.token }),
+		//#region Deprecated
+		'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
+			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
+			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
+			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		//#endregion
+		'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
+			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => {
+			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => {
+			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => {
+			registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler });
+		}),
+		'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => {
+			registerNotePostInterruptor({ pluginId: opts.plugin.id, handler });
+		}),
+		'Plugin:open_url': values.FN_NATIVE(([url]) => {
+			window.open(url.value, '_blank');
+		}),
+		'Plugin:config': values.OBJ(config),
+	};
+}
+
+function initPlugin({ plugin, aiscript }) {
+	pluginContexts.set(plugin.id, aiscript);
+}
+
+function registerPostFormAction({ pluginId, title, handler }) {
+	postFormActions.push({
+		title, handler: (form, update) => {
+			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
+				update(key.value, value.value);
+			})]);
+		},
+	});
+}
+
+function registerUserAction({ pluginId, title, handler }) {
+	userActions.push({
+		title, handler: (user) => {
+			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]);
+		},
+	});
+}
+
+function registerNoteAction({ pluginId, title, handler }) {
+	noteActions.push({
+		title, handler: (note) => {
+			pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]);
+		},
+	});
+}
+
+function registerNoteViewInterruptor({ pluginId, handler }) {
+	noteViewInterruptors.push({
+		handler: async (note) => {
+			return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
+		},
+	});
+}
+
+function registerNotePostInterruptor({ pluginId, handler }) {
+	notePostInterruptors.push({
+		handler: async (note) => {
+			return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
+		},
+	});
+}
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
new file mode 100644
index 0000000000..111b15e0a6
--- /dev/null
+++ b/packages/frontend/src/router.ts
@@ -0,0 +1,501 @@
+import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
+import { Router } from '@/nirax';
+import { $i, iAmModerator } from '@/account';
+import MkLoading from '@/pages/_loading_.vue';
+import MkError from '@/pages/_error_.vue';
+import { ui } from '@/config';
+
+const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
+	loader: loader,
+	loadingComponent: MkLoading,
+	errorComponent: MkError,
+});
+
+export const routes = [{
+	path: '/@:initUser/pages/:initPageName/view-source',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/@:username/pages/:pageName',
+	component: page(() => import('./pages/page.vue')),
+}, {
+	path: '/@:acct/following',
+	component: page(() => import('./pages/user/following.vue')),
+}, {
+	path: '/@:acct/followers',
+	component: page(() => import('./pages/user/followers.vue')),
+}, {
+	name: 'user',
+	path: '/@:acct/:page?',
+	component: page(() => import('./pages/user/index.vue')),
+}, {
+	name: 'note',
+	path: '/notes/:noteId',
+	component: page(() => import('./pages/note.vue')),
+}, {
+	path: '/clips/:clipId',
+	component: page(() => import('./pages/clip.vue')),
+}, {
+	path: '/user-info/:userId',
+	component: page(() => import('./pages/user-info.vue')),
+}, {
+	path: '/instance-info/:host',
+	component: page(() => import('./pages/instance-info.vue')),
+}, {
+	name: 'settings',
+	path: '/settings',
+	component: page(() => import('./pages/settings/index.vue')),
+	loginRequired: true,
+	children: [{
+		path: '/profile',
+		name: 'profile',
+		component: page(() => import('./pages/settings/profile.vue')),
+	}, {
+		path: '/privacy',
+		name: 'privacy',
+		component: page(() => import('./pages/settings/privacy.vue')),
+	}, {
+		path: '/reaction',
+		name: 'reaction',
+		component: page(() => import('./pages/settings/reaction.vue')),
+	}, {
+		path: '/drive',
+		name: 'drive',
+		component: page(() => import('./pages/settings/drive.vue')),
+	}, {
+		path: '/notifications',
+		name: 'notifications',
+		component: page(() => import('./pages/settings/notifications.vue')),
+	}, {
+		path: '/email',
+		name: 'email',
+		component: page(() => import('./pages/settings/email.vue')),
+	}, {
+		path: '/integration',
+		name: 'integration',
+		component: page(() => import('./pages/settings/integration.vue')),
+	}, {
+		path: '/security',
+		name: 'security',
+		component: page(() => import('./pages/settings/security.vue')),
+	}, {
+		path: '/general',
+		name: 'general',
+		component: page(() => import('./pages/settings/general.vue')),
+	}, {
+		path: '/theme/install',
+		name: 'theme',
+		component: page(() => import('./pages/settings/theme.install.vue')),
+	}, {
+		path: '/theme/manage',
+		name: 'theme',
+		component: page(() => import('./pages/settings/theme.manage.vue')),
+	}, {
+		path: '/theme',
+		name: 'theme',
+		component: page(() => import('./pages/settings/theme.vue')),
+	}, {
+		path: '/navbar',
+		name: 'navbar',
+		component: page(() => import('./pages/settings/navbar.vue')),
+	}, {
+		path: '/statusbar',
+		name: 'statusbar',
+		component: page(() => import('./pages/settings/statusbar.vue')),
+	}, {
+		path: '/sounds',
+		name: 'sounds',
+		component: page(() => import('./pages/settings/sounds.vue')),
+	}, {
+		path: '/plugin/install',
+		name: 'plugin',
+		component: page(() => import('./pages/settings/plugin.install.vue')),
+	}, {
+		path: '/plugin',
+		name: 'plugin',
+		component: page(() => import('./pages/settings/plugin.vue')),
+	}, {
+		path: '/import-export',
+		name: 'import-export',
+		component: page(() => import('./pages/settings/import-export.vue')),
+	}, {
+		path: '/instance-mute',
+		name: 'instance-mute',
+		component: page(() => import('./pages/settings/instance-mute.vue')),
+	}, {
+		path: '/mute-block',
+		name: 'mute-block',
+		component: page(() => import('./pages/settings/mute-block.vue')),
+	}, {
+		path: '/word-mute',
+		name: 'word-mute',
+		component: page(() => import('./pages/settings/word-mute.vue')),
+	}, {
+		path: '/api',
+		name: 'api',
+		component: page(() => import('./pages/settings/api.vue')),
+	}, {
+		path: '/apps',
+		name: 'api',
+		component: page(() => import('./pages/settings/apps.vue')),
+	}, {
+		path: '/webhook/edit/:webhookId',
+		name: 'webhook',
+		component: page(() => import('./pages/settings/webhook.edit.vue')),
+	}, {
+		path: '/webhook/new',
+		name: 'webhook',
+		component: page(() => import('./pages/settings/webhook.new.vue')),
+	}, {
+		path: '/webhook',
+		name: 'webhook',
+		component: page(() => import('./pages/settings/webhook.vue')),
+	}, {
+		path: '/deck',
+		name: 'deck',
+		component: page(() => import('./pages/settings/deck.vue')),
+	}, {
+		path: '/preferences-backups',
+		name: 'preferences-backups',
+		component: page(() => import('./pages/settings/preferences-backups.vue')),
+	}, {
+		path: '/custom-css',
+		name: 'general',
+		component: page(() => import('./pages/settings/custom-css.vue')),
+	}, {
+		path: '/accounts',
+		name: 'profile',
+		component: page(() => import('./pages/settings/accounts.vue')),
+	}, {
+		path: '/account-info',
+		name: 'other',
+		component: page(() => import('./pages/settings/account-info.vue')),
+	}, {
+		path: '/delete-account',
+		name: 'other',
+		component: page(() => import('./pages/settings/delete-account.vue')),
+	}, {
+		path: '/other',
+		name: 'other',
+		component: page(() => import('./pages/settings/other.vue')),
+	}, {
+		path: '/',
+		component: page(() => import('./pages/_empty_.vue')),
+	}],
+}, {
+	path: '/reset-password/:token?',
+	component: page(() => import('./pages/reset-password.vue')),
+}, {
+	path: '/signup-complete/:code',
+	component: page(() => import('./pages/signup-complete.vue')),
+}, {
+	path: '/announcements',
+	component: page(() => import('./pages/announcements.vue')),
+}, {
+	path: '/about',
+	component: page(() => import('./pages/about.vue')),
+	hash: 'initialTab',
+}, {
+	path: '/about-misskey',
+	component: page(() => import('./pages/about-misskey.vue')),
+}, {
+	path: '/theme-editor',
+	component: page(() => import('./pages/theme-editor.vue')),
+	loginRequired: true,
+}, {
+	path: '/explore/tags/:tag',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/explore',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/search',
+	component: page(() => import('./pages/search.vue')),
+	query: {
+		q: 'query',
+		channel: 'channel',
+	},
+}, {
+	path: '/authorize-follow',
+	component: page(() => import('./pages/follow.vue')),
+	loginRequired: true,
+}, {
+	path: '/share',
+	component: page(() => import('./pages/share.vue')),
+	loginRequired: true,
+}, {
+	path: '/api-console',
+	component: page(() => import('./pages/api-console.vue')),
+	loginRequired: true,
+}, {
+	path: '/mfm-cheat-sheet',
+	component: page(() => import('./pages/mfm-cheat-sheet.vue')),
+}, {
+	path: '/scratchpad',
+	component: page(() => import('./pages/scratchpad.vue')),
+}, {
+	path: '/preview',
+	component: page(() => import('./pages/preview.vue')),
+}, {
+	path: '/auth/:token',
+	component: page(() => import('./pages/auth.vue')),
+}, {
+	path: '/miauth/:session',
+	component: page(() => import('./pages/miauth.vue')),
+	query: {
+		callback: 'callback',
+		name: 'name',
+		icon: 'icon',
+		permission: 'permission',
+	},
+}, {
+	path: '/tags/:tag',
+	component: page(() => import('./pages/tag.vue')),
+}, {
+	path: '/pages/new',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+	loginRequired: true,
+}, {
+	path: '/pages/edit/:initPageId',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+	loginRequired: true,
+}, {
+	path: '/pages',
+	component: page(() => import('./pages/pages.vue')),
+}, {
+	path: '/gallery/:postId/edit',
+	component: page(() => import('./pages/gallery/edit.vue')),
+	loginRequired: true,
+}, {
+	path: '/gallery/new',
+	component: page(() => import('./pages/gallery/edit.vue')),
+	loginRequired: true,
+}, {
+	path: '/gallery/:postId',
+	component: page(() => import('./pages/gallery/post.vue')),
+}, {
+	path: '/gallery',
+	component: page(() => import('./pages/gallery/index.vue')),
+}, {
+	path: '/channels/:channelId/edit',
+	component: page(() => import('./pages/channel-editor.vue')),
+	loginRequired: true,
+}, {
+	path: '/channels/new',
+	component: page(() => import('./pages/channel-editor.vue')),
+	loginRequired: true,
+}, {
+	path: '/channels/:channelId',
+	component: page(() => import('./pages/channel.vue')),
+}, {
+	path: '/channels',
+	component: page(() => import('./pages/channels.vue')),
+}, {
+	path: '/registry/keys/system/:path(*)?',
+	component: page(() => import('./pages/registry.keys.vue')),
+}, {
+	path: '/registry/value/system/:path(*)?',
+	component: page(() => import('./pages/registry.value.vue')),
+}, {
+	path: '/registry',
+	component: page(() => import('./pages/registry.vue')),
+}, {
+	path: '/admin/file/:fileId',
+	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
+}, {
+	path: '/admin',
+	component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
+	children: [{
+		path: '/overview',
+		name: 'overview',
+		component: page(() => import('./pages/admin/overview.vue')),
+	}, {
+		path: '/users',
+		name: 'users',
+		component: page(() => import('./pages/admin/users.vue')),
+	}, {
+		path: '/emojis',
+		name: 'emojis',
+		component: page(() => import('./pages/admin/emojis.vue')),
+	}, {
+		path: '/queue',
+		name: 'queue',
+		component: page(() => import('./pages/admin/queue.vue')),
+	}, {
+		path: '/files',
+		name: 'files',
+		component: page(() => import('./pages/admin/files.vue')),
+	}, {
+		path: '/announcements',
+		name: 'announcements',
+		component: page(() => import('./pages/admin/announcements.vue')),
+	}, {
+		path: '/ads',
+		name: 'ads',
+		component: page(() => import('./pages/admin/ads.vue')),
+	}, {
+		path: '/database',
+		name: 'database',
+		component: page(() => import('./pages/admin/database.vue')),
+	}, {
+		path: '/abuses',
+		name: 'abuses',
+		component: page(() => import('./pages/admin/abuses.vue')),
+	}, {
+		path: '/settings',
+		name: 'settings',
+		component: page(() => import('./pages/admin/settings.vue')),
+	}, {
+		path: '/email-settings',
+		name: 'email-settings',
+		component: page(() => import('./pages/admin/email-settings.vue')),
+	}, {
+		path: '/object-storage',
+		name: 'object-storage',
+		component: page(() => import('./pages/admin/object-storage.vue')),
+	}, {
+		path: '/security',
+		name: 'security',
+		component: page(() => import('./pages/admin/security.vue')),
+	}, {
+		path: '/relays',
+		name: 'relays',
+		component: page(() => import('./pages/admin/relays.vue')),
+	}, {
+		path: '/integrations',
+		name: 'integrations',
+		component: page(() => import('./pages/admin/integrations.vue')),
+	}, {
+		path: '/instance-block',
+		name: 'instance-block',
+		component: page(() => import('./pages/admin/instance-block.vue')),
+	}, {
+		path: '/proxy-account',
+		name: 'proxy-account',
+		component: page(() => import('./pages/admin/proxy-account.vue')),
+	}, {
+		path: '/other-settings',
+		name: 'other-settings',
+		component: page(() => import('./pages/admin/other-settings.vue')),
+	}, {
+		path: '/',
+		component: page(() => import('./pages/_empty_.vue')),
+	}],
+}, {
+	path: '/my/notifications',
+	component: page(() => import('./pages/notifications.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/favorites',
+	component: page(() => import('./pages/favorites.vue')),
+	loginRequired: true,
+}, {
+	name: 'messaging',
+	path: '/my/messaging',
+	component: page(() => import('./pages/messaging/index.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/messaging/:userAcct',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/messaging/group/:groupId',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/drive/folder/:folder',
+	component: page(() => import('./pages/drive.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/drive',
+	component: page(() => import('./pages/drive.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/follow-requests',
+	component: page(() => import('./pages/follow-requests.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/lists/:listId',
+	component: page(() => import('./pages/my-lists/list.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/lists',
+	component: page(() => import('./pages/my-lists/index.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/clips',
+	component: page(() => import('./pages/my-clips/index.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/antennas/create',
+	component: page(() => import('./pages/my-antennas/create.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/antennas/:antennaId',
+	component: page(() => import('./pages/my-antennas/edit.vue')),
+	loginRequired: true,
+}, {
+	path: '/my/antennas',
+	component: page(() => import('./pages/my-antennas/index.vue')),
+	loginRequired: true,
+}, {
+	path: '/timeline/list/:listId',
+	component: page(() => import('./pages/user-list-timeline.vue')),
+	loginRequired: true,
+}, {
+	path: '/timeline/antenna/:antennaId',
+	component: page(() => import('./pages/antenna-timeline.vue')),
+	loginRequired: true,
+}, {
+	name: 'index',
+	path: '/',
+	component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')),
+	globalCacheKey: 'index',
+}, {
+	path: '/:(*)',
+	component: page(() => import('./pages/not-found.vue')),
+}];
+
+export const mainRouter = new Router(routes, location.pathname + location.search + location.hash);
+
+window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
+
+// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする
+// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも
+
+const scrollPosStore = new Map<string, number>();
+
+window.setInterval(() => {
+	scrollPosStore.set(window.history.state?.key, window.scrollY);
+}, 1000);
+
+mainRouter.addListener('push', ctx => {
+	window.history.pushState({ key: ctx.key }, '', ctx.path);
+	const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	if (scrollPos !== 0) {
+		window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+			window.scroll({ top: scrollPos, behavior: 'instant' });
+		}, 100);
+	}
+});
+
+mainRouter.addListener('replace', ctx => {
+	window.history.replaceState({ key: ctx.key }, '', ctx.path);
+});
+
+mainRouter.addListener('same', () => {
+	window.scroll({ top: 0, behavior: 'smooth' });
+});
+
+window.addEventListener('popstate', (event) => {
+	mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false);
+	const scrollPos = scrollPosStore.get(event.state?.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+		window.scroll({ top: scrollPos, behavior: 'instant' });
+	}, 100);
+});
+
+export function useRouter(): Router {
+	return inject<Router | null>('router', null) ?? mainRouter;
+}
diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts
new file mode 100644
index 0000000000..62a38ff02a
--- /dev/null
+++ b/packages/frontend/src/scripts/2fa.ts
@@ -0,0 +1,33 @@
+export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
+	switch (encoding) {
+		case 'ascii':
+			return Uint8Array.from(string, c => c.charCodeAt(0));
+		case 'base64':
+			return Uint8Array.from(
+				atob(
+					string
+						.replace(/-/g, '+')
+						.replace(/_/g, '/'),
+				),
+				c => c.charCodeAt(0),
+			);
+		case 'hex':
+			return new Uint8Array(
+				string
+					.match(/.{1,2}/g)
+					.map(byte => parseInt(byte, 16)),
+			);
+	}
+}
+
+export function hexify(buffer: ArrayBuffer) {
+	return Array.from(new Uint8Array(buffer))
+		.reduce(
+			(str, byte) => str + byte.toString(16).padStart(2, '0'),
+			'',
+		);
+}
+
+export function stringify(buffer: ArrayBuffer) {
+	return String.fromCharCode(... new Uint8Array(buffer));
+}
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
new file mode 100644
index 0000000000..6debcb8a13
--- /dev/null
+++ b/packages/frontend/src/scripts/aiscript/api.ts
@@ -0,0 +1,43 @@
+import { utils, values } from '@syuilo/aiscript';
+import * as os from '@/os';
+import { $i } from '@/account';
+
+export function createAiScriptEnv(opts) {
+	let apiRequests = 0;
+	return {
+		USER_ID: $i ? values.STR($i.id) : values.NULL,
+		USER_NAME: $i ? values.STR($i.name) : values.NULL,
+		USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
+		'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
+			await os.alert({
+				type: type ? type.value : 'info',
+				title: title.value,
+				text: text.value,
+			});
+		}),
+		'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
+			const confirm = await os.confirm({
+				type: type ? type.value : 'question',
+				title: title.value,
+				text: text.value,
+			});
+			return confirm.canceled ? values.FALSE : values.TRUE;
+		}),
+		'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
+			if (token) utils.assertString(token);
+			apiRequests++;
+			if (apiRequests > 16) return values.NULL;
+			const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null));
+			return utils.jsToVal(res);
+		}),
+		'Mk:save': values.FN_NATIVE(([key, value]) => {
+			utils.assertString(key);
+			localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value)));
+			return values.NULL;
+		}),
+		'Mk:load': values.FN_NATIVE(([key]) => {
+			utils.assertString(key);
+			return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value)));
+		}),
+	};
+}
diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts
new file mode 100644
index 0000000000..4620c8b735
--- /dev/null
+++ b/packages/frontend/src/scripts/array.ts
@@ -0,0 +1,149 @@
+import { EndoRelation, Predicate } from './relation';
+
+/**
+ * Count the number of elements that satisfy the predicate
+ */
+
+export function countIf<T>(f: Predicate<T>, xs: T[]): number {
+	return xs.filter(f).length;
+}
+
+/**
+ * Count the number of elements that is equal to the element
+ */
+export function count<T>(a: T, xs: T[]): number {
+	return countIf(x => x === a, xs);
+}
+
+/**
+ * Concatenate an array of arrays
+ */
+export function concat<T>(xss: T[][]): T[] {
+	return ([] as T[]).concat(...xss);
+}
+
+/**
+ * Intersperse the element between the elements of the array
+ * @param sep The element to be interspersed
+ */
+export function intersperse<T>(sep: T, xs: T[]): T[] {
+	return concat(xs.map(x => [sep, x])).slice(1);
+}
+
+/**
+ * Returns the array of elements that is not equal to the element
+ */
+export function erase<T>(a: T, xs: T[]): T[] {
+	return xs.filter(x => x !== a);
+}
+
+/**
+ * Finds the array of all elements in the first array not contained in the second array.
+ * The order of result values are determined by the first array.
+ */
+export function difference<T>(xs: T[], ys: T[]): T[] {
+	return xs.filter(x => !ys.includes(x));
+}
+
+/**
+ * Remove all but the first element from every group of equivalent elements
+ */
+export function unique<T>(xs: T[]): T[] {
+	return [...new Set(xs)];
+}
+
+export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
+	const map = new Map<TKey, TValue>();
+
+	for (const value of values) {
+		const key = keySelector(value);
+		if (!map.has(key)) map.set(key, value);
+	}
+
+	return [...map.values()];
+}
+
+export function sum(xs: number[]): number {
+	return xs.reduce((a, b) => a + b, 0);
+}
+
+export function maximum(xs: number[]): number {
+	return Math.max(...xs);
+}
+
+/**
+ * Splits an array based on the equivalence relation.
+ * The concatenation of the result is equal to the argument.
+ */
+export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
+	const groups = [] as T[][];
+	for (const x of xs) {
+		if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
+			groups[groups.length - 1].push(x);
+		} else {
+			groups.push([x]);
+		}
+	}
+	return groups;
+}
+
+/**
+ * Splits an array based on the equivalence relation induced by the function.
+ * The concatenation of the result is equal to the argument.
+ */
+export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] {
+	return groupBy((a, b) => f(a) === f(b), xs);
+}
+
+export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
+	return collections.reduce((obj: Record<string, T[]>, item: T) => {
+		const key = keySelector(item);
+		if (typeof obj[key] === 'undefined') {
+			obj[key] = [];
+		}
+
+		obj[key].push(item);
+
+		return obj;
+	}, {});
+}
+
+/**
+ * Compare two arrays by lexicographical order
+ */
+export function lessThan(xs: number[], ys: number[]): boolean {
+	for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
+		if (xs[i] < ys[i]) return true;
+		if (xs[i] > ys[i]) return false;
+	}
+	return xs.length < ys.length;
+}
+
+/**
+ * Returns the longest prefix of elements that satisfy the predicate
+ */
+export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
+	const ys: T[] = [];
+	for (const x of xs) {
+		if (f(x)) {
+			ys.push(x);
+		} else {
+			break;
+		}
+	}
+	return ys;
+}
+
+export function cumulativeSum(xs: number[]): number[] {
+	const ys = Array.from(xs); // deep copy
+	for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
+	return ys;
+}
+
+export function toArray<T>(x: T | T[] | undefined): T[] {
+	return Array.isArray(x) ? x : x != null ? [x] : [];
+}
+
+export function toSingle<T>(x: T | T[] | undefined): T | undefined {
+	return Array.isArray(x) ? x[0] : x;
+}
diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts
new file mode 100644
index 0000000000..1bae3790f5
--- /dev/null
+++ b/packages/frontend/src/scripts/autocomplete.ts
@@ -0,0 +1,276 @@
+import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
+import getCaretCoordinates from 'textarea-caret';
+import { toASCII } from 'punycode/';
+import { popup } from '@/os';
+
+export class Autocomplete {
+	private suggestion: {
+		x: Ref<number>;
+		y: Ref<number>;
+		q: Ref<string | null>;
+		close: () => void;
+	} | null;
+	private textarea: HTMLInputElement | HTMLTextAreaElement;
+	private currentType: string;
+	private textRef: Ref<string>;
+	private opening: boolean;
+
+	private get text(): string {
+		// Use raw .value to get the latest value
+		// (Because v-model does not update while composition)
+		return this.textarea.value;
+	}
+
+	private set text(text: string) {
+		// Use ref value to notify other watchers
+		// (Because .value setter never fires input/change events)
+		this.textRef.value = text;
+	}
+
+	/**
+	 * 対象のテキストエリアを与えてインスタンスを初期化します。
+	 */
+	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
+		//#region BIND
+		this.onInput = this.onInput.bind(this);
+		this.complete = this.complete.bind(this);
+		this.close = this.close.bind(this);
+		//#endregion
+
+		this.suggestion = null;
+		this.textarea = textarea;
+		this.textRef = textRef;
+		this.opening = false;
+
+		this.attach();
+	}
+
+	/**
+	 * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
+	 */
+	public attach() {
+		this.textarea.addEventListener('input', this.onInput);
+	}
+
+	/**
+	 * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
+	 */
+	public detach() {
+		this.textarea.removeEventListener('input', this.onInput);
+		this.close();
+	}
+
+	/**
+	 * テキスト入力時
+	 */
+	private onInput() {
+		const caretPos = this.textarea.selectionStart;
+		const text = this.text.substr(0, caretPos).split('\n').pop()!;
+
+		const mentionIndex = text.lastIndexOf('@');
+		const hashtagIndex = text.lastIndexOf('#');
+		const emojiIndex = text.lastIndexOf(':');
+		const mfmTagIndex = text.lastIndexOf('$');
+
+		const max = Math.max(
+			mentionIndex,
+			hashtagIndex,
+			emojiIndex,
+			mfmTagIndex);
+
+		if (max === -1) {
+			this.close();
+			return;
+		}
+
+		const isMention = mentionIndex !== -1;
+		const isHashtag = hashtagIndex !== -1;
+		const isMfmTag = mfmTagIndex !== -1;
+		const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
+
+		let opened = false;
+
+		if (isMention) {
+			const username = text.substr(mentionIndex + 1);
+			if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
+				this.open('user', username);
+				opened = true;
+			} else if (username === '') {
+				this.open('user', null);
+				opened = true;
+			}
+		}
+
+		if (isHashtag && !opened) {
+			const hashtag = text.substr(hashtagIndex + 1);
+			if (!hashtag.includes(' ')) {
+				this.open('hashtag', hashtag);
+				opened = true;
+			}
+		}
+
+		if (isEmoji && !opened) {
+			const emoji = text.substr(emojiIndex + 1);
+			if (!emoji.includes(' ')) {
+				this.open('emoji', emoji);
+				opened = true;
+			}
+		}
+
+		if (isMfmTag && !opened) {
+			const mfmTag = text.substr(mfmTagIndex + 1);
+			if (!mfmTag.includes(' ')) {
+				this.open('mfmTag', mfmTag.replace('[', ''));
+				opened = true;
+			}
+		}
+
+		if (!opened) {
+			this.close();
+		}
+	}
+
+	/**
+	 * サジェストを提示します。
+	 */
+	private async open(type: string, q: string | null) {
+		if (type !== this.currentType) {
+			this.close();
+		}
+		if (this.opening) return;
+		this.opening = true;
+		this.currentType = type;
+
+		//#region サジェストを表示すべき位置を計算
+		const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
+
+		const rect = this.textarea.getBoundingClientRect();
+
+		const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
+		const y = rect.top + caretPosition.top - this.textarea.scrollTop;
+		//#endregion
+
+		if (this.suggestion) {
+			this.suggestion.x.value = x;
+			this.suggestion.y.value = y;
+			this.suggestion.q.value = q;
+
+			this.opening = false;
+		} else {
+			const _x = ref(x);
+			const _y = ref(y);
+			const _q = ref(q);
+
+			const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
+				textarea: this.textarea,
+				close: this.close,
+				type: type,
+				q: _q,
+				x: _x,
+				y: _y,
+			}, {
+				done: (res) => {
+					this.complete(res);
+				},
+			});
+
+			this.suggestion = {
+				q: _q,
+				x: _x,
+				y: _y,
+				close: () => dispose(),
+			};
+
+			this.opening = false;
+		}
+	}
+
+	/**
+	 * サジェストを閉じます。
+	 */
+	private close() {
+		if (this.suggestion == null) return;
+
+		this.suggestion.close();
+		this.suggestion = null;
+
+		this.textarea.focus();
+	}
+
+	/**
+	 * オートコンプリートする
+	 */
+	private complete({ type, value }) {
+		this.close();
+
+		const caret = this.textarea.selectionStart;
+
+		if (type === 'user') {
+			const source = this.text;
+
+			const before = source.substr(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
+			const after = source.substr(caret);
+
+			const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
+
+			// 挿入
+			this.text = `${trimmedBefore}@${acct} ${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (acct.length + 2);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'hashtag') {
+			const source = this.text;
+
+			const before = source.substr(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
+			const after = source.substr(caret);
+
+			// 挿入
+			this.text = `${trimmedBefore}#${value} ${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (value.length + 2);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'emoji') {
+			const source = this.text;
+
+			const before = source.substr(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
+			const after = source.substr(caret);
+
+			// 挿入
+			this.text = trimmedBefore + value + after;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + value.length;
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'mfmTag') {
+			const source = this.text;
+
+			const before = source.substr(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
+			const after = source.substr(caret);
+
+			// 挿入
+			this.text = `${trimmedBefore}$[${value} ]${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (value.length + 3);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		}
+	}
+}
diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts
new file mode 100644
index 0000000000..8e3c4436b2
--- /dev/null
+++ b/packages/frontend/src/scripts/chart-vline.ts
@@ -0,0 +1,21 @@
+export const chartVLine = (vLineColor: string) => ({
+	id: 'vLine',
+	beforeDraw(chart, args, options) {
+		if (chart.tooltip?._active?.length) {
+			const activePoint = chart.tooltip._active[0];
+			const ctx = chart.ctx;
+			const x = activePoint.element.x;
+			const topY = chart.scales.y.top;
+			const bottomY = chart.scales.y.bottom;
+
+			ctx.save();
+			ctx.beginPath();
+			ctx.moveTo(x, bottomY);
+			ctx.lineTo(x, topY);
+			ctx.lineWidth = 1;
+			ctx.strokeStyle = vLineColor;
+			ctx.stroke();
+			ctx.restore();
+		}
+	},
+});
diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts
new file mode 100644
index 0000000000..35d40a6e08
--- /dev/null
+++ b/packages/frontend/src/scripts/check-word-mute.ts
@@ -0,0 +1,37 @@
+export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean {
+	// 自分自身
+	if (me && (note.userId === me.id)) return false;
+
+	if (mutedWords.length > 0) {
+		const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
+
+		if (text === '') return false;
+
+		const matched = mutedWords.some(filter => {
+			if (Array.isArray(filter)) {
+				// Clean up
+				const filteredFilter = filter.filter(keyword => keyword !== '');
+				if (filteredFilter.length === 0) return false;
+
+				return filteredFilter.every(keyword => text.includes(keyword));
+			} else {
+				// represents RegExp
+				const regexp = filter.match(/^\/(.+)\/(.*)$/);
+
+				// This should never happen due to input sanitisation.
+				if (!regexp) return false;
+
+				try {
+					return new RegExp(regexp[1], regexp[2]).test(text);
+				} catch (err) {
+					// This should never happen due to input sanitisation.
+					return false;
+				}
+			}
+		});
+
+		if (matched) return true;
+	}
+
+	return false;
+}
diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts
new file mode 100644
index 0000000000..16fad24129
--- /dev/null
+++ b/packages/frontend/src/scripts/clone.ts
@@ -0,0 +1,18 @@
+// structredCloneが遅いため
+// SEE: http://var.blog.jp/archives/86038606.html
+
+type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
+
+export function deepClone<T extends Cloneable>(x: T): T {
+	if (typeof x === 'object') {
+		if (x === null) return x;
+		if (Array.isArray(x)) return x.map(deepClone) as T;
+		const obj = {} as Record<string, Cloneable>;
+		for (const [k, v] of Object.entries(x)) {
+			obj[k] = deepClone(v);
+		}
+		return obj as T;
+	} else {
+		return x;
+	}
+}
diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts
new file mode 100644
index 0000000000..76b68beaf6
--- /dev/null
+++ b/packages/frontend/src/scripts/collect-page-vars.ts
@@ -0,0 +1,68 @@
+interface StringPageVar {
+	name: string,
+	type: 'string',
+	value: string
+}
+
+interface NumberPageVar {
+	name: string,
+	type: 'number',
+	value: number
+}
+
+interface BooleanPageVar {
+	name: string,
+	type: 'boolean',
+	value: boolean
+}
+
+type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
+
+export function collectPageVars(content): PageVar[] {
+	const pageVars: PageVar[] = [];
+	const collect = (xs: any[]): void => {
+		for (const x of xs) {
+			if (x.type === 'textInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.type === 'textareaInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.type === 'numberInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'number',
+					value: x.default || 0,
+				});
+			} else if (x.type === 'switch') {
+				pageVars.push({
+					name: x.name,
+					type: 'boolean',
+					value: x.default || false,
+				});
+			} else if (x.type === 'counter') {
+				pageVars.push({
+					name: x.name,
+					type: 'number',
+					value: 0,
+				});
+			} else if (x.type === 'radioButton') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.children) {
+				collect(x.children);
+			}
+		}
+	};
+	collect(content);
+	return pageVars;
+}
diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts
new file mode 100644
index 0000000000..256e09d293
--- /dev/null
+++ b/packages/frontend/src/scripts/contains.ts
@@ -0,0 +1,9 @@
+export default (parent, child, checkSame = true) => {
+	if (checkSame && parent === child) return true;
+	let node = child.parentNode;
+	while (node) {
+		if (node === parent) return true;
+		node = node.parentNode;
+	}
+	return false;
+};
diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts
new file mode 100644
index 0000000000..ab13cab970
--- /dev/null
+++ b/packages/frontend/src/scripts/copy-to-clipboard.ts
@@ -0,0 +1,33 @@
+/**
+ * Clipboardに値をコピー(TODO: 文字列以外も対応)
+ */
+export default val => {
+	// 空div 生成
+	const tmp = document.createElement('div');
+	// 選択用のタグ生成
+	const pre = document.createElement('pre');
+
+	// 親要素のCSSで user-select: none だとコピーできないので書き換える
+	pre.style.webkitUserSelect = 'auto';
+	pre.style.userSelect = 'auto';
+
+	tmp.appendChild(pre).textContent = val;
+
+	// 要素を画面外へ
+	const s = tmp.style;
+	s.position = 'fixed';
+	s.right = '200%';
+
+	// body に追加
+	document.body.appendChild(tmp);
+	// 要素を選択
+	document.getSelection().selectAllChildren(tmp);
+
+	// クリップボードにコピー
+	const result = document.execCommand('copy');
+
+	// 要素削除
+	document.body.removeChild(tmp);
+
+	return result;
+};
diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts
new file mode 100644
index 0000000000..544cac0604
--- /dev/null
+++ b/packages/frontend/src/scripts/device-kind.ts
@@ -0,0 +1,10 @@
+import { defaultStore } from '@/store';
+
+const ua = navigator.userAgent.toLowerCase();
+const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
+const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
+
+export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
+	: isSmartphone ? 'smartphone'
+	: isTablet ? 'tablet'
+	: 'desktop';
diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend/src/scripts/emoji-base.ts
new file mode 100644
index 0000000000..3f05642d57
--- /dev/null
+++ b/packages/frontend/src/scripts/emoji-base.ts
@@ -0,0 +1,20 @@
+const twemojiSvgBase = '/twemoji';
+const fluentEmojiPngBase = '/fluent-emoji';
+
+export function char2twemojiFilePath(char: string): string {
+	let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
+	if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
+	codes = codes.filter(x => x && x.length);
+	const fileName = codes.join('-');
+	return `${twemojiSvgBase}/${fileName}.svg`;
+}
+
+export function char2fluentEmojiFilePath(char: string): string {
+	let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
+	// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
+	if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
+	if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
+	codes = codes.filter(x => x && x.length);
+	const fileName = codes.map(x => x!.padStart(4, '0')).join('-');
+	return `${fluentEmojiPngBase}/${fileName}.png`;
+}
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts
new file mode 100644
index 0000000000..bc52fa7a43
--- /dev/null
+++ b/packages/frontend/src/scripts/emojilist.ts
@@ -0,0 +1,17 @@
+export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const;
+
+export type UnicodeEmojiDef = {
+	name: string;
+	keywords: string[];
+	char: string;
+	category: typeof unicodeEmojiCategories[number];
+}
+
+// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
+import _emojilist from '../emojilist.json';
+
+export const emojilist = _emojilist as UnicodeEmojiDef[];
+
+export function getEmojiName(char: string): string | undefined {
+	return emojilist.find(emo => emo.char === char)?.name;
+}
diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts
new file mode 100644
index 0000000000..af517f2672
--- /dev/null
+++ b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts
@@ -0,0 +1,9 @@
+export function extractAvgColorFromBlurhash(hash: string) {
+	return typeof hash === 'string'
+		? '#' + [...hash.slice(2, 6)]
+				.map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x))
+				.reduce((a, c) => a * 83 + c, 0)
+				.toString(16)
+				.padStart(6, '0')
+		: undefined;
+}
diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts
new file mode 100644
index 0000000000..cc19b161a8
--- /dev/null
+++ b/packages/frontend/src/scripts/extract-mentions.ts
@@ -0,0 +1,11 @@
+// test is located in test/extract-mentions
+
+import * as mfm from 'mfm-js';
+
+export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
+	// TODO: 重複を削除
+	const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
+	const mentions = mentionNodes.map(x => x.props);
+
+	return mentions;
+}
diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts
new file mode 100644
index 0000000000..34e3eb6c19
--- /dev/null
+++ b/packages/frontend/src/scripts/extract-url-from-mfm.ts
@@ -0,0 +1,19 @@
+import * as mfm from 'mfm-js';
+import { unique } from '@/scripts/array';
+
+// unique without hash
+// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
+const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
+
+export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
+	const urlNodes = mfm.extract(nodes, (node) => {
+		return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
+	});
+	const urls: string[] = unique(urlNodes.map(x => x.props.url));
+
+	return urls.reduce((array, url) => {
+		const urlWithoutHash = removeHash(url);
+		if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
+		return array;
+	}, [] as string[]);
+}
diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts
new file mode 100644
index 0000000000..d6802fa322
--- /dev/null
+++ b/packages/frontend/src/scripts/focus.ts
@@ -0,0 +1,27 @@
+export function focusPrev(el: Element | null, self = false, scroll = true) {
+	if (el == null) return;
+	if (!self) el = el.previousElementSibling;
+	if (el) {
+		if (el.hasAttribute('tabindex')) {
+			(el as HTMLElement).focus({
+				preventScroll: !scroll,
+			});
+		} else {
+			focusPrev(el.previousElementSibling, true);
+		}
+	}
+}
+
+export function focusNext(el: Element | null, self = false, scroll = true) {
+	if (el == null) return;
+	if (!self) el = el.nextElementSibling;
+	if (el) {
+		if (el.hasAttribute('tabindex')) {
+			(el as HTMLElement).focus({
+				preventScroll: !scroll,
+			});
+		} else {
+			focusPrev(el.nextElementSibling, true);
+		}
+	}
+}
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
new file mode 100644
index 0000000000..7f321cc0ae
--- /dev/null
+++ b/packages/frontend/src/scripts/form.ts
@@ -0,0 +1,59 @@
+export type FormItem = {
+	label?: string;
+	type: 'string';
+	default: string | null;
+	hidden?: boolean;
+	multiline?: boolean;
+} | {
+	label?: string;
+	type: 'number';
+	default: number | null;
+	hidden?: boolean;
+	step?: number;
+} | {
+	label?: string;
+	type: 'boolean';
+	default: boolean | null;
+	hidden?: boolean;
+} | {
+	label?: string;
+	type: 'enum';
+	default: string | null;
+	hidden?: boolean;
+	enum: string[];
+} | {
+	label?: string;
+	type: 'radio';
+	default: unknown | null;
+	hidden?: boolean;
+	options: {
+		label: string;
+		value: unknown;
+	}[];
+} | {
+	label?: string;
+	type: 'object';
+	default: Record<string, unknown> | null;
+	hidden: true;
+} | {
+	label?: string;
+	type: 'array';
+	default: unknown[] | null;
+	hidden: true;
+};
+
+export type Form = Record<string, FormItem>;
+
+type GetItemType<Item extends FormItem> =
+	Item['type'] extends 'string' ? string :
+	Item['type'] extends 'number' ? number :
+	Item['type'] extends 'boolean' ? boolean :
+	Item['type'] extends 'radio' ? unknown :
+	Item['type'] extends 'enum' ? string :
+	Item['type'] extends 'array' ? unknown[] :
+	Item['type'] extends 'object' ? Record<string, unknown>
+	: never;
+
+export type GetFormResultType<F extends Form> = {
+	[P in keyof F]: GetItemType<F[P]>;
+};
diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts
new file mode 100644
index 0000000000..c20db5e827
--- /dev/null
+++ b/packages/frontend/src/scripts/format-time-string.ts
@@ -0,0 +1,50 @@
+const defaultLocaleStringFormats: {[index: string]: string} = {
+	'weekday': 'narrow',
+	'era': 'narrow',
+	'year': 'numeric',
+	'month': 'numeric',
+	'day': 'numeric',
+	'hour': 'numeric',
+	'minute': 'numeric',
+	'second': 'numeric',
+	'timeZoneName': 'short',
+};
+
+function formatLocaleString(date: Date, format: string): string {
+	return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
+		if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
+			return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] });
+		} else {
+			return match;
+		}
+	});
+}
+
+export function formatDateTimeString(date: Date, format: string): string {
+	return format
+		.replace(/yyyy/g, date.getFullYear().toString())
+		.replace(/yy/g, date.getFullYear().toString().slice(-2))
+		.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' }))
+		.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' }))
+		.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
+		.replace(/M/g, (date.getMonth() + 1).toString())
+		.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
+		.replace(/d/g, date.getDate().toString())
+		.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
+		.replace(/H/g, date.getHours().toString())
+		.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
+		.replace(/h/g, ((date.getHours() % 12) || 12).toString())
+		.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
+		.replace(/m/g, date.getMinutes().toString())
+		.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
+		.replace(/s/g, date.getSeconds().toString())
+		.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
+}
+
+export function formatTimeString(date: Date, format: string): string {
+	return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
+		if (localeformat) return formatLocaleString(date, localeformat);
+		if (datetimeformat) return formatDateTimeString(date, datetimeformat);
+		return match;
+	});
+}
diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts
new file mode 100644
index 0000000000..da7d622632
--- /dev/null
+++ b/packages/frontend/src/scripts/gen-search-query.ts
@@ -0,0 +1,30 @@
+import * as Acct from 'misskey-js/built/acct';
+import { host as localHost } from '@/config';
+
+export async function genSearchQuery(v: any, q: string) {
+	let host: string;
+	let userId: string;
+	if (q.split(' ').some(x => x.startsWith('@'))) {
+		for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) {
+			if (at.includes('.')) {
+				if (at === localHost || at === '.') {
+					host = null;
+				} else {
+					host = at;
+				}
+			} else {
+				const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null);
+				if (user) {
+					userId = user.id;
+				} else {
+					// todo: show error
+				}
+			}
+		}
+	}
+	return {
+		query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
+		host: host,
+		userId: userId,
+	};
+}
diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts
new file mode 100644
index 0000000000..1da897f176
--- /dev/null
+++ b/packages/frontend/src/scripts/get-account-from-id.ts
@@ -0,0 +1,7 @@
+import { get } from '@/scripts/idb-proxy';
+
+export async function getAccountFromId(id: string) {
+	const accounts = await get('accounts') as { token: string; id: string; }[];
+	if (!accounts) console.log('Accounts are not recorded');
+	return accounts.find(account => account.id === id);
+}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
new file mode 100644
index 0000000000..7656770894
--- /dev/null
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -0,0 +1,341 @@
+import { defineAsyncComponent, Ref, inject } from 'vue';
+import * as misskey from 'misskey-js';
+import { pleaseLogin } from './please-login';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import { noteActions } from '@/store';
+import { notePage } from '@/filters/note';
+
+export function getNoteMenu(props: {
+	note: misskey.entities.Note;
+	menuButton: Ref<HTMLElement>;
+	translation: Ref<any>;
+	translating: Ref<boolean>;
+	isDeleted: Ref<boolean>;
+	currentClipPage?: Ref<misskey.entities.Clip>;
+}) {
+	const isRenote = (
+		props.note.renote != null &&
+		props.note.text == null &&
+		props.note.fileIds.length === 0 &&
+		props.note.poll == null
+	);
+
+	const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
+
+	function del(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.ts.noteDeleteConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			os.api('notes/delete', {
+				noteId: appearNote.id,
+			});
+		});
+	}
+
+	function delEdit(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.ts.deleteAndEditConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			os.api('notes/delete', {
+				noteId: appearNote.id,
+			});
+
+			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+		});
+	}
+
+	function toggleFavorite(favorite: boolean): void {
+		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+			noteId: appearNote.id,
+		});
+	}
+
+	function toggleThreadMute(mute: boolean): void {
+		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+			noteId: appearNote.id,
+		});
+	}
+
+	function copyContent(): void {
+		copyToClipboard(appearNote.text);
+		os.success();
+	}
+
+	function copyLink(): void {
+		copyToClipboard(`${url}/notes/${appearNote.id}`);
+		os.success();
+	}
+
+	function togglePin(pin: boolean): void {
+		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+			noteId: appearNote.id,
+		}, undefined, null, res => {
+			if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+				os.alert({
+					type: 'error',
+					text: i18n.ts.pinLimitExceeded,
+				});
+			}
+		});
+	}
+
+	async function clip(): Promise<void> {
+		const clips = await os.api('clips/list');
+		os.popupMenu([{
+			icon: 'ti ti-plus',
+			text: i18n.ts.createNew,
+			action: async () => {
+				const { canceled, result } = await os.form(i18n.ts.createNewClip, {
+					name: {
+						type: 'string',
+						label: i18n.ts.name,
+					},
+					description: {
+						type: 'string',
+						required: false,
+						multiline: true,
+						label: i18n.ts.description,
+					},
+					isPublic: {
+						type: 'boolean',
+						label: i18n.ts.public,
+						default: false,
+					},
+				});
+				if (canceled) return;
+
+				const clip = await os.apiWithDialog('clips/create', result);
+
+				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+			},
+		}, null, ...clips.map(clip => ({
+			text: clip.name,
+			action: () => {
+				os.promiseDialog(
+					os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
+					null,
+					async (err) => {
+						if (err.id === '734806c4-542c-463a-9311-15c512803965') {
+							const confirm = await os.confirm({
+								type: 'warning',
+								text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
+							});
+							if (!confirm.canceled) {
+								os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
+								if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
+							}
+						} else {
+							os.alert({
+								type: 'error',
+								text: err.message + '\n' + err.id,
+							});
+						}
+					},
+				);
+			},
+		}))], props.menuButton.value, {
+		}).then(focus);
+	}
+
+	async function unclip(): Promise<void> {
+		os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
+		props.isDeleted.value = true;
+	}
+
+	async function promote(): Promise<void> {
+		const { canceled, result: days } = await os.inputNumber({
+			title: i18n.ts.numberOfDays,
+		});
+
+		if (canceled) return;
+
+		os.apiWithDialog('admin/promo/create', {
+			noteId: appearNote.id,
+			expiresAt: Date.now() + (86400000 * days),
+		});
+	}
+
+	function share(): void {
+		navigator.share({
+			title: i18n.t('noteOf', { user: appearNote.user.name }),
+			text: appearNote.text,
+			url: `${url}/notes/${appearNote.id}`,
+		});
+	}
+	function notedetails(): void {
+		os.pageWindow(`/notes/${appearNote.id}`);
+	}
+	async function translate(): Promise<void> {
+		if (props.translation.value != null) return;
+		props.translating.value = true;
+		const res = await os.api('notes/translate', {
+			noteId: appearNote.id,
+			targetLang: localStorage.getItem('lang') || navigator.language,
+		});
+		props.translating.value = false;
+		props.translation.value = res;
+	}
+
+	let menu;
+	if ($i) {
+		const statePromise = os.api('notes/state', {
+			noteId: appearNote.id,
+		});
+
+		menu = [
+			...(
+				props.currentClipPage?.value.userId === $i.id ? [{
+					icon: 'ti ti-backspace',
+					text: i18n.ts.unclip,
+					danger: true,
+					action: unclip,
+				}, null] : []
+			), {
+				icon: 'ti ti-external-link',
+				text: i18n.ts.details,
+				action: notedetails,
+			}, {
+				icon: 'ti ti-copy',
+				text: i18n.ts.copyContent,
+				action: copyContent,
+			}, {
+				icon: 'ti ti-link',
+				text: i18n.ts.copyLink,
+				action: copyLink,
+			}, (appearNote.url || appearNote.uri) ? {
+				icon: 'ti ti-external-link',
+				text: i18n.ts.showOnRemote,
+				action: () => {
+					window.open(appearNote.url || appearNote.uri, '_blank');
+				},
+			} : undefined,
+			{
+				icon: 'ti ti-share',
+				text: i18n.ts.share,
+				action: share,
+			},
+			instance.translatorAvailable ? {
+				icon: 'ti ti-language-hiragana',
+				text: i18n.ts.translate,
+				action: translate,
+			} : undefined,
+			null,
+			statePromise.then(state => state.isFavorited ? {
+				icon: 'ti ti-star-off',
+				text: i18n.ts.unfavorite,
+				action: () => toggleFavorite(false),
+			} : {
+				icon: 'ti ti-star',
+				text: i18n.ts.favorite,
+				action: () => toggleFavorite(true),
+			}),
+			{
+				icon: 'ti ti-paperclip',
+				text: i18n.ts.clip,
+				action: () => clip(),
+			},
+			statePromise.then(state => state.isMutedThread ? {
+				icon: 'ti ti-message-off',
+				text: i18n.ts.unmuteThread,
+				action: () => toggleThreadMute(false),
+			} : {
+				icon: 'ti ti-message-off',
+				text: i18n.ts.muteThread,
+				action: () => toggleThreadMute(true),
+			}),
+			appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
+				icon: 'ti ti-pinned-off',
+				text: i18n.ts.unpin,
+				action: () => togglePin(false),
+			} : {
+				icon: 'ti ti-pin',
+				text: i18n.ts.pin,
+				action: () => togglePin(true),
+			} : undefined,
+			/*
+		...($i.isModerator || $i.isAdmin ? [
+			null,
+			{
+				icon: 'fas fa-bullhorn',
+				text: i18n.ts.promote,
+				action: promote
+			}]
+			: []
+		),*/
+			...(appearNote.userId !== $i.id ? [
+				null,
+				{
+					icon: 'ti ti-exclamation-circle',
+					text: i18n.ts.reportAbuse,
+					action: () => {
+						const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`;
+						os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+							user: appearNote.user,
+							initialComment: `Note: ${u}\n-----\n`,
+						}, {}, 'closed');
+					},
+				}]
+			: []
+			),
+			...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
+				null,
+				appearNote.userId === $i.id ? {
+					icon: 'ti ti-edit',
+					text: i18n.ts.deleteAndEdit,
+					action: delEdit,
+				} : undefined,
+				{
+					icon: 'ti ti-trash',
+					text: i18n.ts.delete,
+					danger: true,
+					action: del,
+				}]
+			: []
+			)]
+			.filter(x => x !== undefined);
+	} else {
+		menu = [{
+			icon: 'ti ti-external-link',
+			text: i18n.ts.detailed,
+			action: openDetail,
+		}, {
+			icon: 'ti ti-copy',
+			text: i18n.ts.copyContent,
+			action: copyContent,
+		}, {
+			icon: 'ti ti-link',
+			text: i18n.ts.copyLink,
+			action: copyLink,
+		}, (appearNote.url || appearNote.uri) ? {
+			icon: 'ti ti-external-link',
+			text: i18n.ts.showOnRemote,
+			action: () => {
+				window.open(appearNote.url || appearNote.uri, '_blank');
+			},
+		} : undefined]
+			.filter(x => x !== undefined);
+	}
+
+	if (noteActions.length > 0) {
+		menu = menu.concat([null, ...noteActions.map(action => ({
+			icon: 'ti ti-plug',
+			text: action.title,
+			action: () => {
+				action.handler(appearNote);
+			},
+		}))]);
+	}
+
+	return menu;
+}
diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts
new file mode 100644
index 0000000000..d57e1c3029
--- /dev/null
+++ b/packages/frontend/src/scripts/get-note-summary.ts
@@ -0,0 +1,55 @@
+import * as misskey from 'misskey-js';
+import { i18n } from '@/i18n';
+
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} note (packされた)投稿
+ */
+export const getNoteSummary = (note: misskey.entities.Note): string => {
+	if (note.deletedAt) {
+		return `(${i18n.ts.deletedNote})`;
+	}
+
+	if (note.isHidden) {
+		return `(${i18n.ts.invisibleNote})`;
+	}
+
+	let summary = '';
+
+	// 本文
+	if (note.cw != null) {
+		summary += note.cw;
+	} else {
+		summary += note.text ? note.text : '';
+	}
+
+	// ファイルが添付されているとき
+	if ((note.files || []).length !== 0) {
+		summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
+	}
+
+	// 投票が添付されているとき
+	if (note.poll) {
+		summary += ` (${i18n.ts.poll})`;
+	}
+
+	// 返信のとき
+	if (note.replyId) {
+		if (note.reply) {
+			summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
+		} else {
+			summary += '\n\nRE: ...';
+		}
+	}
+
+	// Renoteのとき
+	if (note.renoteId) {
+		if (note.renote) {
+			summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
+		} else {
+			summary += '\n\nRN: ...';
+		}
+	}
+
+	return summary.trim();
+};
diff --git a/packages/frontend/src/scripts/get-static-image-url.ts b/packages/frontend/src/scripts/get-static-image-url.ts
new file mode 100644
index 0000000000..cbd1761983
--- /dev/null
+++ b/packages/frontend/src/scripts/get-static-image-url.ts
@@ -0,0 +1,19 @@
+import { url as instanceUrl } from '@/config';
+import * as url from '@/scripts/url';
+
+export function getStaticImageUrl(baseUrl: string): string {
+	const u = new URL(baseUrl);
+	if (u.href.startsWith(`${instanceUrl}/proxy/`)) {
+		// もう既にproxyっぽそうだったらsearchParams付けるだけ
+		u.searchParams.set('static', '1');
+		return u.href;
+	}
+
+	// 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する
+	const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`;
+
+	return `${instanceUrl}/proxy/${dummy}?${url.query({
+		url: u.href,
+		static: '1',
+	})}`;
+}
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
new file mode 100644
index 0000000000..2faacffdfc
--- /dev/null
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -0,0 +1,253 @@
+import * as Acct from 'misskey-js/built/acct';
+import { defineAsyncComponent } from 'vue';
+import { i18n } from '@/i18n';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { host } from '@/config';
+import * as os from '@/os';
+import { userActions } from '@/store';
+import { $i, iAmModerator } from '@/account';
+import { mainRouter } from '@/router';
+import { Router } from '@/nirax';
+
+export function getUserMenu(user, router: Router = mainRouter) {
+	const meId = $i ? $i.id : null;
+
+	async function pushList() {
+		const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
+		const lists = await os.api('users/lists/list');
+		if (lists.length === 0) {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.youHaveNoLists,
+			});
+			return;
+		}
+		const { canceled, result: listId } = await os.select({
+			title: t,
+			items: lists.map(list => ({
+				value: list.id, text: list.name,
+			})),
+		});
+		if (canceled) return;
+		os.apiWithDialog('users/lists/push', {
+			listId: listId,
+			userId: user.id,
+		});
+	}
+
+	async function inviteGroup() {
+		const groups = await os.api('users/groups/owned');
+		if (groups.length === 0) {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.youHaveNoGroups,
+			});
+			return;
+		}
+		const { canceled, result: groupId } = await os.select({
+			title: i18n.ts.group,
+			items: groups.map(group => ({
+				value: group.id, text: group.name,
+			})),
+		});
+		if (canceled) return;
+		os.apiWithDialog('users/groups/invite', {
+			groupId: groupId,
+			userId: user.id,
+		});
+	}
+
+	async function toggleMute() {
+		if (user.isMuted) {
+			os.apiWithDialog('mute/delete', {
+				userId: user.id,
+			}).then(() => {
+				user.isMuted = false;
+			});
+		} else {
+			const { canceled, result: period } = await os.select({
+				title: i18n.ts.mutePeriod,
+				items: [{
+					value: 'indefinitely', text: i18n.ts.indefinitely,
+				}, {
+					value: 'tenMinutes', text: i18n.ts.tenMinutes,
+				}, {
+					value: 'oneHour', text: i18n.ts.oneHour,
+				}, {
+					value: 'oneDay', text: i18n.ts.oneDay,
+				}, {
+					value: 'oneWeek', text: i18n.ts.oneWeek,
+				}],
+				default: 'indefinitely',
+			});
+			if (canceled) return;
+
+			const expiresAt = period === 'indefinitely' ? null
+				: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
+				: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+				: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+				: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+				: null;
+
+			os.apiWithDialog('mute/create', {
+				userId: user.id,
+				expiresAt,
+			}).then(() => {
+				user.isMuted = true;
+			});
+		}
+	}
+
+	async function toggleBlock() {
+		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
+
+		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
+			userId: user.id,
+		}).then(() => {
+			user.isBlocking = !user.isBlocking;
+		});
+	}
+
+	async function toggleSilence() {
+		if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
+
+		os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
+			userId: user.id,
+		}).then(() => {
+			user.isSilenced = !user.isSilenced;
+		});
+	}
+
+	async function toggleSuspend() {
+		if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
+
+		os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
+			userId: user.id,
+		}).then(() => {
+			user.isSuspended = !user.isSuspended;
+		});
+	}
+
+	function reportAbuse() {
+		os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+			user: user,
+		}, {}, 'closed');
+	}
+
+	async function getConfirmed(text: string): Promise<boolean> {
+		const confirm = await os.confirm({
+			type: 'warning',
+			title: 'confirm',
+			text,
+		});
+
+		return !confirm.canceled;
+	}
+
+	async function invalidateFollow() {
+		os.apiWithDialog('following/invalidate', {
+			userId: user.id,
+		}).then(() => {
+			user.isFollowed = !user.isFollowed;
+		});
+	}
+
+	let menu = [{
+		icon: 'ti ti-at',
+		text: i18n.ts.copyUsername,
+		action: () => {
+			copyToClipboard(`@${user.username}@${user.host || host}`);
+		},
+	}, {
+		icon: 'ti ti-rss',
+		text: i18n.ts.copyRSS,
+		action: () => {
+			copyToClipboard(`${user.host || host}/@${user.username}.atom`);
+		}
+	}, {
+		icon: 'ti ti-info-circle',
+		text: i18n.ts.info,
+		action: () => {
+			router.push(`/user-info/${user.id}`);
+		},
+	}, {
+		icon: 'ti ti-mail',
+		text: i18n.ts.sendMessage,
+		action: () => {
+			os.post({ specified: user });
+		},
+	}, meId !== user.id ? {
+		type: 'link',
+		icon: 'ti ti-messages',
+		text: i18n.ts.startMessaging,
+		to: '/my/messaging/' + Acct.toString(user),
+	} : undefined, null, {
+		icon: 'ti ti-list',
+		text: i18n.ts.addToList,
+		action: pushList,
+	}, meId !== user.id ? {
+		icon: 'ti ti-users',
+		text: i18n.ts.inviteToGroup,
+		action: inviteGroup,
+	} : undefined] as any;
+
+	if ($i && meId !== user.id) {
+		menu = menu.concat([null, {
+			icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
+			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
+			action: toggleMute,
+		}, {
+			icon: 'ti ti-ban',
+			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
+			action: toggleBlock,
+		}]);
+
+		if (user.isFollowed) {
+			menu = menu.concat([{
+				icon: 'ti ti-link-off',
+				text: i18n.ts.breakFollow,
+				action: invalidateFollow,
+			}]);
+		}
+
+		menu = menu.concat([null, {
+			icon: 'ti ti-exclamation-circle',
+			text: i18n.ts.reportAbuse,
+			action: reportAbuse,
+		}]);
+
+		if (iAmModerator) {
+			menu = menu.concat([null, {
+				icon: 'ti ti-microphone-2-off',
+				text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
+				action: toggleSilence,
+			}, {
+				icon: 'ti ti-snowflake',
+				text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
+				action: toggleSuspend,
+			}]);
+		}
+	}
+
+	if ($i && meId === user.id) {
+		menu = menu.concat([null, {
+			icon: 'ti ti-pencil',
+			text: i18n.ts.editProfile,
+			action: () => {
+				router.push('/settings/profile');
+			},
+		}]);
+	}
+
+	if (userActions.length > 0) {
+		menu = menu.concat([null, ...userActions.map(action => ({
+			icon: 'ti ti-plug',
+			text: action.title,
+			action: () => {
+				action.handler(user);
+			},
+		}))]);
+	}
+
+	return menu;
+}
diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts
new file mode 100644
index 0000000000..d499ea0203
--- /dev/null
+++ b/packages/frontend/src/scripts/get-user-name.ts
@@ -0,0 +1,3 @@
+export default function(user: { name?: string | null, username: string }): string {
+	return user.name || user.username;
+}
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
new file mode 100644
index 0000000000..4a0ded637d
--- /dev/null
+++ b/packages/frontend/src/scripts/hotkey.ts
@@ -0,0 +1,90 @@
+import keyCode from './keycode';
+
+type Callback = (ev: KeyboardEvent) => void;
+
+type Keymap = Record<string, Callback>;
+
+type Pattern = {
+	which: string[];
+	ctrl?: boolean;
+	shift?: boolean;
+	alt?: boolean;
+};
+
+type Action = {
+	patterns: Pattern[];
+	callback: Callback;
+	allowRepeat: boolean;
+};
+
+const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
+	const result = {
+		patterns: [],
+		callback,
+		allowRepeat: true,
+	} as Action;
+
+	if (patterns.match(/^\(.*\)$/) !== null) {
+		result.allowRepeat = false;
+		patterns = patterns.slice(1, -1);
+	}
+
+	result.patterns = patterns.split('|').map(part => {
+		const pattern = {
+			which: [],
+			ctrl: false,
+			alt: false,
+			shift: false,
+		} as Pattern;
+
+		const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
+		for (const key of keys) {
+			switch (key) {
+				case 'ctrl': pattern.ctrl = true; break;
+				case 'alt': pattern.alt = true; break;
+				case 'shift': pattern.shift = true; break;
+				default: pattern.which = keyCode(key).map(k => k.toLowerCase());
+			}
+		}
+
+		return pattern;
+	});
+
+	return result;
+});
+
+const ignoreElemens = ['input', 'textarea'];
+
+function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
+	const key = ev.code.toLowerCase();
+	return patterns.some(pattern => pattern.which.includes(key) &&
+		pattern.ctrl === ev.ctrlKey &&
+		pattern.shift === ev.shiftKey &&
+		pattern.alt === ev.altKey &&
+		!ev.metaKey,
+	);
+}
+
+export const makeHotkey = (keymap: Keymap) => {
+	const actions = parseKeymap(keymap);
+
+	return (ev: KeyboardEvent) => {
+		if (document.activeElement) {
+			if (ignoreElemens.some(el => document.activeElement!.matches(el))) return;
+			if (document.activeElement.attributes['contenteditable']) return;
+		}
+
+		for (const action of actions) {
+			const matched = match(ev, action.patterns);
+
+			if (matched) {
+				if (!action.allowRepeat && ev.repeat) return;
+
+				ev.preventDefault();
+				ev.stopPropagation();
+				action.callback(ev);
+				break;
+			}
+		}
+	};
+};
diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts
new file mode 100644
index 0000000000..804c5c1124
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/block.ts
@@ -0,0 +1,109 @@
+// blocks
+
+export type BlockBase = {
+	id: string;
+	type: string;
+};
+
+export type TextBlock = BlockBase & {
+	type: 'text';
+	text: string;
+};
+
+export type SectionBlock = BlockBase & {
+	type: 'section';
+	title: string;
+	children: (Block | VarBlock)[];
+};
+
+export type ImageBlock = BlockBase & {
+	type: 'image';
+	fileId: string | null;
+};
+
+export type ButtonBlock = BlockBase & {
+	type: 'button';
+	text: any;
+	primary: boolean;
+	action: string;
+	content: string;
+	event: string;
+	message: string;
+	var: string;
+	fn: string;
+};
+
+export type IfBlock = BlockBase & {
+	type: 'if';
+	var: string;
+	children: Block[];
+};
+
+export type TextareaBlock = BlockBase & {
+	type: 'textarea';
+	text: string;
+};
+
+export type PostBlock = BlockBase & {
+	type: 'post';
+	text: string;
+	attachCanvasImage: boolean;
+	canvasId: string;
+};
+
+export type CanvasBlock = BlockBase & {
+	type: 'canvas';
+	name: string; // canvas id
+	width: number;
+	height: number;
+};
+
+export type NoteBlock = BlockBase & {
+	type: 'note';
+	detailed: boolean;
+	note: string | null;
+};
+
+export type Block =
+	TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock;
+
+// variable blocks
+
+export type VarBlockBase = BlockBase & {
+	name: string;
+};
+
+export type NumberInputVarBlock = VarBlockBase & {
+	type: 'numberInput';
+	text: string;
+};
+
+export type TextInputVarBlock = VarBlockBase & {
+	type: 'textInput';
+	text: string;
+};
+
+export type SwitchVarBlock = VarBlockBase & {
+	type: 'switch';
+	text: string;
+};
+
+export type RadioButtonVarBlock = VarBlockBase & {
+	type: 'radioButton';
+	title: string;
+	values: string[];
+};
+
+export type CounterVarBlock = VarBlockBase & {
+	type: 'counter';
+	text: string;
+	inc: number;
+};
+
+export type VarBlock =
+	NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock;
+
+const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter'];
+export function isVarBlock(block: Block): block is VarBlock {
+	return varBlock.includes(block.type);
+}
diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts
new file mode 100644
index 0000000000..196b3142a1
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/evaluator.ts
@@ -0,0 +1,232 @@
+import autobind from 'autobind-decorator';
+import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.';
+import { version } from '@/config';
+import { AiScript, utils, values } from '@syuilo/aiscript';
+import { createAiScriptEnv } from '../aiscript/api';
+import { collectPageVars } from '../collect-page-vars';
+import { initHpmlLib, initAiLib } from './lib';
+import * as os from '@/os';
+import { markRaw, ref, Ref, unref } from 'vue';
+import { Expr, isLiteralValue, Variable } from './expr';
+
+/**
+ * Hpml evaluator
+ */
+export class Hpml {
+	private variables: Variable[];
+	private pageVars: PageVar[];
+	private envVars: Record<keyof typeof envVarsDef, any>;
+	public aiscript?: AiScript;
+	public pageVarUpdatedCallback?: values.VFn;
+	public canvases: Record<string, HTMLCanvasElement> = {};
+	public vars: Ref<Record<string, any>> = ref({});
+	public page: Record<string, any>;
+
+	private opts: {
+		randomSeed: string; visitor?: any; url?: string;
+		enableAiScript: boolean;
+	};
+
+	constructor(page: Hpml['page'], opts: Hpml['opts']) {
+		this.page = page;
+		this.variables = this.page.variables;
+		this.pageVars = collectPageVars(this.page.content);
+		this.opts = opts;
+
+		if (this.opts.enableAiScript) {
+			this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({
+				storageKey: 'pages:' + this.page.id,
+			}), ...initAiLib(this) }, {
+				in: (q) => {
+					return new Promise(ok => {
+						os.inputText({
+							title: q,
+						}).then(({ canceled, result: a }) => {
+							ok(a);
+						});
+					});
+				},
+				out: (value) => {
+					console.log(value);
+				},
+				log: (type, params) => {
+				},
+			}));
+
+			this.aiscript.scope.opts.onUpdated = (name, value) => {
+				this.eval();
+			};
+		}
+
+		const date = new Date();
+
+		this.envVars = {
+			AI: 'kawaii',
+			VERSION: version,
+			URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '',
+			LOGIN: opts.visitor != null,
+			NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '',
+			USERNAME: opts.visitor ? opts.visitor.username : '',
+			USERID: opts.visitor ? opts.visitor.id : '',
+			NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
+			FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
+			FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
+			IS_CAT: opts.visitor ? opts.visitor.isCat : false,
+			SEED: opts.randomSeed ? opts.randomSeed : '',
+			YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
+			AISCRIPT_DISABLED: !this.opts.enableAiScript,
+			NULL: null,
+		};
+
+		this.eval();
+	}
+
+	@autobind
+	public eval() {
+		try {
+			this.vars.value = this.evaluateVars();
+		} catch (err) {
+			//this.onError(e);
+		}
+	}
+
+	@autobind
+	public interpolate(str: string) {
+		if (str == null) return null;
+		return str.replace(/{(.+?)}/g, match => {
+			const v = unref(this.vars)[match.slice(1, -1).trim()];
+			return v == null ? 'NULL' : v.toString();
+		});
+	}
+
+	@autobind
+	public callAiScript(fn: string) {
+		try {
+			if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []);
+		} catch (err) {}
+	}
+
+	@autobind
+	public registerCanvas(id: string, canvas: any) {
+		this.canvases[id] = canvas;
+	}
+
+	@autobind
+	public updatePageVar(name: string, value: any) {
+		const pageVar = this.pageVars.find(v => v.name === name);
+		if (pageVar !== undefined) {
+			pageVar.value = value;
+			if (this.pageVarUpdatedCallback) {
+				if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]);
+			}
+		} else {
+			throw new HpmlError(`No such page var '${name}'`);
+		}
+	}
+
+	@autobind
+	public updateRandomSeed(seed: string) {
+		this.opts.randomSeed = seed;
+		this.envVars.SEED = seed;
+	}
+
+	@autobind
+	private _interpolateScope(str: string, scope: HpmlScope) {
+		return str.replace(/{(.+?)}/g, match => {
+			const v = scope.getState(match.slice(1, -1).trim());
+			return v == null ? 'NULL' : v.toString();
+		});
+	}
+
+	@autobind
+	public evaluateVars(): Record<string, any> {
+		const values: Record<string, any> = {};
+
+		for (const [k, v] of Object.entries(this.envVars)) {
+			values[k] = v;
+		}
+
+		for (const v of this.pageVars) {
+			values[v.name] = v.value;
+		}
+
+		for (const v of this.variables) {
+			values[v.name] = this.evaluate(v, new HpmlScope([values]));
+		}
+
+		return values;
+	}
+
+	@autobind
+	private evaluate(expr: Expr, scope: HpmlScope): any {
+		if (isLiteralValue(expr)) {
+			if (expr.type === null) {
+				return null;
+			}
+
+			if (expr.type === 'number') {
+				return parseInt((expr.value as any), 10);
+			}
+
+			if (expr.type === 'text' || expr.type === 'multiLineText') {
+				return this._interpolateScope(expr.value || '', scope);
+			}
+
+			if (expr.type === 'textList') {
+				return this._interpolateScope(expr.value || '', scope).trim().split('\n');
+			}
+
+			if (expr.type === 'ref') {
+				return scope.getState(expr.value);
+			}
+
+			if (expr.type === 'aiScriptVar') {
+				if (this.aiscript) {
+					try {
+						return utils.valToJs(this.aiscript.scope.get(expr.value));
+					} catch (err) {
+						return null;
+					}
+				} else {
+					return null;
+				}
+			}
+
+			// Define user function
+			if (expr.type === 'fn') {
+				return {
+					slots: expr.value.slots.map(x => x.name),
+					exec: (slotArg: Record<string, any>) => {
+						return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id));
+					},
+				} as Fn;
+			}
+			return;
+		}
+
+		// Call user function
+		if (expr.type.startsWith('fn:')) {
+			const fnName = expr.type.split(':')[1];
+			const fn = scope.getState(fnName);
+			const args = {} as Record<string, any>;
+			for (let i = 0; i < fn.slots.length; i++) {
+				const name = fn.slots[i];
+				args[name] = this.evaluate(expr.args[i], scope);
+			}
+			return fn.exec(args);
+		}
+
+		if (expr.args === undefined) return null;
+
+		const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor);
+
+		// Call function
+		const fnName = expr.type;
+		const fn = (funcs as any)[fnName];
+		if (fn == null) {
+			throw new HpmlError(`No such function '${fnName}'`);
+		} else {
+			return fn(...expr.args.map(x => this.evaluate(x, scope)));
+		}
+	}
+}
diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts
new file mode 100644
index 0000000000..18c7c2a14b
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/expr.ts
@@ -0,0 +1,79 @@
+import { literalDefs, Type } from '.';
+
+export type ExprBase = {
+	id: string;
+};
+
+// value
+
+export type EmptyValue = ExprBase & {
+	type: null;
+	value: null;
+};
+
+export type TextValue = ExprBase & {
+	type: 'text';
+	value: string;
+};
+
+export type MultiLineTextValue = ExprBase & {
+	type: 'multiLineText';
+	value: string;
+};
+
+export type TextListValue = ExprBase & {
+	type: 'textList';
+	value: string;
+};
+
+export type NumberValue = ExprBase & {
+	type: 'number';
+	value: number;
+};
+
+export type RefValue = ExprBase & {
+	type: 'ref';
+	value: string; // value is variable name
+};
+
+export type AiScriptRefValue = ExprBase & {
+	type: 'aiScriptVar';
+	value: string; // value is variable name
+};
+
+export type UserFnValue = ExprBase & {
+	type: 'fn';
+	value: UserFnInnerValue;
+};
+type UserFnInnerValue = {
+	slots: {
+		name: string;
+		type: Type;
+	}[];
+	expression: Expr;
+};
+
+export type Value =
+	EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue;
+
+export function isLiteralValue(expr: Expr): expr is Value {
+	if (expr.type == null) return true;
+	if (literalDefs[expr.type]) return true;
+	return false;
+}
+
+// call function
+
+export type CallFn = ExprBase & { // "fn:hoge" or string
+	type: string;
+	args: Expr[];
+	value: null;
+};
+
+// variable
+export type Variable = (Value | CallFn) & {
+	name: string;
+};
+
+// expression
+export type Expr = Variable | Value | CallFn;
diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts
new file mode 100644
index 0000000000..9a55a5c286
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/index.ts
@@ -0,0 +1,103 @@
+/**
+ * Hpml
+ */
+
+import autobind from 'autobind-decorator';
+import { Hpml } from './evaluator';
+import { funcDefs } from './lib';
+
+export type Fn = {
+	slots: string[];
+	exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>;
+};
+
+export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
+
+export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
+	text: { out: 'string', category: 'value', icon: 'ti ti-quote' },
+	multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' },
+	textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' },
+	number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' },
+	ref: { out: null, category: 'value', icon: 'fas fa-magic' },
+	aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' },
+	fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' },
+};
+
+export const blockDefs = [
+	...Object.entries(literalDefs).map(([k, v]) => ({
+		type: k, out: v.out, category: v.category, icon: v.icon,
+	})),
+	...Object.entries(funcDefs).map(([k, v]) => ({
+		type: k, out: v.out, category: v.category, icon: v.icon,
+	})),
+];
+
+export type PageVar = { name: string; value: any; type: Type; };
+
+export const envVarsDef: Record<string, Type> = {
+	AI: 'string',
+	URL: 'string',
+	VERSION: 'string',
+	LOGIN: 'boolean',
+	NAME: 'string',
+	USERNAME: 'string',
+	USERID: 'string',
+	NOTES_COUNT: 'number',
+	FOLLOWERS_COUNT: 'number',
+	FOLLOWING_COUNT: 'number',
+	IS_CAT: 'boolean',
+	SEED: null,
+	YMD: 'string',
+	AISCRIPT_DISABLED: 'boolean',
+	NULL: null,
+};
+
+export class HpmlScope {
+	private layerdStates: Record<string, any>[];
+	public name: string;
+
+	constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
+		this.layerdStates = layerdStates;
+		this.name = name || 'anonymous';
+	}
+
+	@autobind
+	public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
+		const layer = [states, ...this.layerdStates];
+		return new HpmlScope(layer, name);
+	}
+
+	/**
+	 * 指定した名前の変数の値を取得します
+	 * @param name 変数名
+	 */
+	@autobind
+	public getState(name: string): any {
+		for (const later of this.layerdStates) {
+			const state = later[name];
+			if (state !== undefined) {
+				return state;
+			}
+		}
+
+		throw new HpmlError(
+			`No such variable '${name}' in scope '${this.name}'`, {
+				scope: this.layerdStates,
+			});
+	}
+}
+
+export class HpmlError extends Error {
+	public info?: any;
+
+	constructor(message: string, info?: any) {
+		super(message);
+
+		this.info = info;
+
+		// Maintains proper stack trace for where our error was thrown (only available on V8)
+		if (Error.captureStackTrace) {
+			Error.captureStackTrace(this, HpmlError);
+		}
+	}
+}
diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts
new file mode 100644
index 0000000000..b684876a7f
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/lib.ts
@@ -0,0 +1,247 @@
+import tinycolor from 'tinycolor2';
+import { Hpml } from './evaluator';
+import { values, utils } from '@syuilo/aiscript';
+import { Fn, HpmlScope } from '.';
+import { Expr } from './expr';
+import seedrandom from 'seedrandom';
+
+/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
+// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
+Chart.pluginService.register({
+	beforeDraw: (chart, easing) => {
+		if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
+			const ctx = chart.chart.ctx;
+			ctx.save();
+			ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
+			ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
+			ctx.restore();
+		}
+	}
+});
+*/
+
+export function initAiLib(hpml: Hpml) {
+	return {
+		'MkPages:updated': values.FN_NATIVE(([callback]) => {
+			hpml.pageVarUpdatedCallback = (callback as values.VFn);
+		}),
+		'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
+			utils.assertString(id);
+			const canvas = hpml.canvases[id.value];
+			const ctx = canvas.getContext('2d');
+			return values.OBJ(new Map([
+				['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })],
+				['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })],
+				['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })],
+				['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })],
+				['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })],
+				['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })],
+				['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })],
+				['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })],
+				['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })],
+				['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })],
+				['close_path', values.FN_NATIVE(() => { ctx.closePath(); })],
+				['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })],
+				['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })],
+				['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })],
+				['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })],
+				['fill', values.FN_NATIVE(() => { ctx.fill(); })],
+				['stroke', values.FN_NATIVE(() => { ctx.stroke(); })],
+			]));
+		}),
+		'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
+			/* TODO
+			utils.assertString(id);
+			utils.assertObject(opts);
+			const canvas = hpml.canvases[id.value];
+			const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
+			Chart.defaults.color = '#555';
+			const chart = new Chart(canvas, {
+				type: opts.value.get('type').value,
+				data: {
+					labels: opts.value.get('labels').value.map(x => x.value),
+					datasets: opts.value.get('datasets').value.map(x => ({
+						label: x.value.has('label') ? x.value.get('label').value : '',
+						data: x.value.get('data').value.map(x => x.value),
+						pointRadius: 0,
+						lineTension: 0,
+						borderWidth: 2,
+						borderColor: x.value.has('color') ? x.value.get('color') : color,
+						backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
+					}))
+				},
+				options: {
+					responsive: false,
+					devicePixelRatio: 1.5,
+					title: {
+						display: opts.value.has('title'),
+						text: opts.value.has('title') ? opts.value.get('title').value : '',
+						fontSize: 14,
+					},
+					layout: {
+						padding: {
+							left: 32,
+							right: 32,
+							top: opts.value.has('title') ? 16 : 32,
+							bottom: 16
+						}
+					},
+					legend: {
+						display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
+						position: 'bottom',
+						labels: {
+							boxWidth: 16,
+						}
+					},
+					tooltips: {
+						enabled: false,
+					},
+					chartArea: {
+						backgroundColor: '#fff'
+					},
+					...(opts.value.get('type').value === 'radar' ? {
+						scale: {
+							ticks: {
+								display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
+								min: opts.value.has('min') ? opts.value.get('min').value : undefined,
+								max: opts.value.has('max') ? opts.value.get('max').value : undefined,
+								maxTicksLimit: 8,
+							},
+							pointLabels: {
+								fontSize: 12
+							}
+						}
+					} : {
+						scales: {
+							yAxes: [{
+								ticks: {
+									display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
+									min: opts.value.has('min') ? opts.value.get('min').value : undefined,
+									max: opts.value.has('max') ? opts.value.get('max').value : undefined,
+								}
+							}]
+						}
+					})
+				}
+			});
+			*/
+		}),
+	};
+}
+
+export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
+	if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' },
+	for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' },
+	not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
+	or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
+	and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
+	add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' },
+	subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' },
+	multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' },
+	divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
+	mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
+	round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' },
+	eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' },
+	notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' },
+	gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' },
+	lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' },
+	gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' },
+	ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' },
+	strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' },
+	strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' },
+	strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
+	strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
+	join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
+	stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' },
+	numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' },
+	splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' },
+	pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' },
+	listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' },
+	rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
+	dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
+	seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
+	random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
+	dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
+	seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
+	randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
+	dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
+	seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' },
+	DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping
+};
+
+export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) {
+	const date = new Date();
+	const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
+
+	// SHOULD be fine to ignore since it's intended + function shape isn't defined
+	// eslint-disable-next-line @typescript-eslint/ban-types
+	const funcs: Record<string, Function> = {
+		not: (a: boolean) => !a,
+		or: (a: boolean, b: boolean) => a || b,
+		and: (a: boolean, b: boolean) => a && b,
+		eq: (a: any, b: any) => a === b,
+		notEq: (a: any, b: any) => a !== b,
+		gt: (a: number, b: number) => a > b,
+		lt: (a: number, b: number) => a < b,
+		gtEq: (a: number, b: number) => a >= b,
+		ltEq: (a: number, b: number) => a <= b,
+		if: (bool: boolean, a: any, b: any) => bool ? a : b,
+		for: (times: number, fn: Fn) => {
+			const result: any[] = [];
+			for (let i = 0; i < times; i++) {
+				result.push(fn.exec({
+					[fn.slots[0]]: i + 1,
+				}));
+			}
+			return result;
+		},
+		add: (a: number, b: number) => a + b,
+		subtract: (a: number, b: number) => a - b,
+		multiply: (a: number, b: number) => a * b,
+		divide: (a: number, b: number) => a / b,
+		mod: (a: number, b: number) => a % b,
+		round: (a: number) => Math.round(a),
+		strLen: (a: string) => a.length,
+		strPick: (a: string, b: number) => a[b - 1],
+		strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
+		strReverse: (a: string) => a.split('').reverse().join(''),
+		join: (texts: string[], separator: string) => texts.join(separator || ''),
+		stringToNumber: (a: string) => parseInt(a),
+		numberToString: (a: number) => a.toString(),
+		splitStrByLine: (a: string) => a.split('\n'),
+		pick: (list: any[], i: number) => list[i - 1],
+		listLen: (list: any[]) => list.length,
+		random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability,
+		rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)),
+		randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)],
+		dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
+		dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
+		dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
+		seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability,
+		seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)),
+		seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)],
+		DRPWPM: (list: string[]) => {
+			const xs: any[] = [];
+			let totalFactor = 0;
+			for (const x of list) {
+				const parts = x.split(' ');
+				const factor = parseInt(parts.pop()!, 10);
+				const text = parts.join(' ');
+				totalFactor += factor;
+				xs.push({ factor, text });
+			}
+			const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
+			let stackedFactor = 0;
+			for (const x of xs) {
+				if (r >= stackedFactor && r <= stackedFactor + x.factor) {
+					return x.text;
+				} else {
+					stackedFactor += x.factor;
+				}
+			}
+			return xs[0].text;
+		},
+	};
+
+	return funcs;
+}
diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts
new file mode 100644
index 0000000000..24c9ed8bcb
--- /dev/null
+++ b/packages/frontend/src/scripts/hpml/type-checker.ts
@@ -0,0 +1,191 @@
+import autobind from 'autobind-decorator';
+import { isLiteralValue } from './expr';
+import { funcDefs } from './lib';
+import { envVarsDef } from '.';
+import type { Type, PageVar } from '.';
+import type { Expr, Variable } from './expr';
+
+type TypeError = {
+	arg: number;
+	expect: Type;
+	actual: Type;
+};
+
+/**
+ * Hpml type checker
+ */
+export class HpmlTypeChecker {
+	public variables: Variable[];
+	public pageVars: PageVar[];
+
+	constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
+		this.variables = variables;
+		this.pageVars = pageVars;
+	}
+
+	@autobind
+	public typeCheck(v: Expr): TypeError | null {
+		if (isLiteralValue(v)) return null;
+
+		const def = funcDefs[v.type || ''];
+		if (def == null) {
+			throw new Error('Unknown type: ' + v.type);
+		}
+
+		const generic: Type[] = [];
+
+		for (let i = 0; i < def.in.length; i++) {
+			const arg = def.in[i];
+			const type = this.infer(v.args[i]);
+			if (type === null) continue;
+
+			if (typeof arg === 'number') {
+				if (generic[arg] === undefined) {
+					generic[arg] = type;
+				} else if (type !== generic[arg]) {
+					return {
+						arg: i,
+						expect: generic[arg],
+						actual: type,
+					};
+				}
+			} else if (type !== arg) {
+				return {
+					arg: i,
+					expect: arg,
+					actual: type,
+				};
+			}
+		}
+
+		return null;
+	}
+
+	@autobind
+	public getExpectedType(v: Expr, slot: number): Type {
+		const def = funcDefs[v.type || ''];
+		if (def == null) {
+			throw new Error('Unknown type: ' + v.type);
+		}
+
+		const generic: Type[] = [];
+
+		for (let i = 0; i < def.in.length; i++) {
+			const arg = def.in[i];
+			const type = this.infer(v.args[i]);
+			if (type === null) continue;
+
+			if (typeof arg === 'number') {
+				if (generic[arg] === undefined) {
+					generic[arg] = type;
+				}
+			}
+		}
+
+		if (typeof def.in[slot] === 'number') {
+			return generic[def.in[slot]] ?? null;
+		} else {
+			return def.in[slot];
+		}
+	}
+
+	@autobind
+	public infer(v: Expr): Type {
+		if (v.type === null) return null;
+		if (v.type === 'text') return 'string';
+		if (v.type === 'multiLineText') return 'string';
+		if (v.type === 'textList') return 'stringArray';
+		if (v.type === 'number') return 'number';
+		if (v.type === 'ref') {
+			const variable = this.variables.find(va => va.name === v.value);
+			if (variable) {
+				return this.infer(variable);
+			}
+
+			const pageVar = this.pageVars.find(va => va.name === v.value);
+			if (pageVar) {
+				return pageVar.type;
+			}
+
+			const envVar = envVarsDef[v.value || ''];
+			if (envVar !== undefined) {
+				return envVar;
+			}
+
+			return null;
+		}
+		if (v.type === 'aiScriptVar') return null;
+		if (v.type === 'fn') return null; // todo
+		if (v.type.startsWith('fn:')) return null; // todo
+
+		const generic: Type[] = [];
+
+		const def = funcDefs[v.type];
+
+		for (let i = 0; i < def.in.length; i++) {
+			const arg = def.in[i];
+			if (typeof arg === 'number') {
+				const type = this.infer(v.args[i]);
+
+				if (generic[arg] === undefined) {
+					generic[arg] = type;
+				} else {
+					if (type !== generic[arg]) {
+						generic[arg] = null;
+					}
+				}
+			}
+		}
+
+		if (typeof def.out === 'number') {
+			return generic[def.out];
+		} else {
+			return def.out;
+		}
+	}
+
+	@autobind
+	public getVarByName(name: string): Variable {
+		const v = this.variables.find(x => x.name === name);
+		if (v !== undefined) {
+			return v;
+		} else {
+			throw new Error(`No such variable '${name}'`);
+		}
+	}
+
+	@autobind
+	public getVarsByType(type: Type): Variable[] {
+		if (type == null) return this.variables;
+		return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
+	}
+
+	@autobind
+	public getEnvVarsByType(type: Type): string[] {
+		if (type == null) return Object.keys(envVarsDef);
+		return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
+	}
+
+	@autobind
+	public getPageVarsByType(type: Type): string[] {
+		if (type == null) return this.pageVars.map(v => v.name);
+		return this.pageVars.filter(v => type === v.type).map(v => v.name);
+	}
+
+	@autobind
+	public isUsedName(name: string) {
+		if (this.variables.some(v => v.name === name)) {
+			return true;
+		}
+
+		if (this.pageVars.some(v => v.name === name)) {
+			return true;
+		}
+
+		if (envVarsDef[name]) {
+			return true;
+		}
+
+		return false;
+	}
+}
diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts
new file mode 100644
index 0000000000..54184386da
--- /dev/null
+++ b/packages/frontend/src/scripts/i18n.ts
@@ -0,0 +1,29 @@
+export class I18n<T extends Record<string, any>> {
+	public ts: T;
+
+	constructor(locale: T) {
+		this.ts = locale;
+
+		//#region BIND
+		this.t = this.t.bind(this);
+		//#endregion
+	}
+
+	// string にしているのは、ドット区切りでのパス指定を許可するため
+	// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
+	public t(key: string, args?: Record<string, string | number>): string {
+		try {
+			let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
+
+			if (args) {
+				for (const [k, v] of Object.entries(args)) {
+					str = str.replace(`{${k}}`, v.toString());
+				}
+			}
+			return str;
+		} catch (err) {
+			console.warn(`missing localization '${key}'`);
+			return key;
+		}
+	}
+}
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
new file mode 100644
index 0000000000..77bb84463c
--- /dev/null
+++ b/packages/frontend/src/scripts/idb-proxy.ts
@@ -0,0 +1,36 @@
+// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
+// indexedDBが使えない環境ではlocalStorageを使う
+import {
+	get as iget,
+	set as iset,
+	del as idel,
+} from 'idb-keyval';
+
+const fallbackName = (key: string) => `idbfallback::${key}`;
+
+let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true;
+
+if (idbAvailable) {
+	iset('idb-test', 'test').catch(err => {
+		console.error('idb error', err);
+		console.error('indexedDB is unavailable. It will use localStorage.');
+		idbAvailable = false;
+	});
+} else {
+	console.error('indexedDB is unavailable. It will use localStorage.');
+}
+
+export async function get(key: string) {
+	if (idbAvailable) return iget(key);
+	return JSON.parse(localStorage.getItem(fallbackName(key)));
+}
+
+export async function set(key: string, val: any) {
+	if (idbAvailable) return iset(key, val);
+	return localStorage.setItem(fallbackName(key), JSON.stringify(val));
+}
+
+export async function del(key: string) {
+	if (idbAvailable) return idel(key);
+	return localStorage.removeItem(fallbackName(key));
+}
diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts
new file mode 100644
index 0000000000..de52f30523
--- /dev/null
+++ b/packages/frontend/src/scripts/initialize-sw.ts
@@ -0,0 +1,13 @@
+import { lang } from '@/config';
+
+export async function initializeSw() {
+	if (!('serviceWorker' in navigator)) return;
+
+	navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' });
+	navigator.serviceWorker.ready.then(registration => {
+		registration.active?.postMessage({
+			msg: 'initialize',
+			lang,
+		});
+	});
+}
diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts
new file mode 100644
index 0000000000..854f38e517
--- /dev/null
+++ b/packages/frontend/src/scripts/is-device-darkmode.ts
@@ -0,0 +1,3 @@
+export function isDeviceDarkmode() {
+	return window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts
new file mode 100644
index 0000000000..69f6a82803
--- /dev/null
+++ b/packages/frontend/src/scripts/keycode.ts
@@ -0,0 +1,33 @@
+export default (input: string): string[] => {
+	if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) {
+		const codes = aliases[input];
+		return Array.isArray(codes) ? codes : [codes];
+	} else {
+		return [input];
+	}
+};
+
+export const aliases = {
+	'esc': 'Escape',
+	'enter': ['Enter', 'NumpadEnter'],
+	'up': 'ArrowUp',
+	'down': 'ArrowDown',
+	'left': 'ArrowLeft',
+	'right': 'ArrowRight',
+	'plus': ['NumpadAdd', 'Semicolon'],
+};
+
+/*!
+* Programmatically add the following
+*/
+
+// lower case chars
+for (let i = 97; i < 123; i++) {
+	const char = String.fromCharCode(i);
+	aliases[char] = `Key${char.toUpperCase()}`;
+}
+
+// numbers
+for (let i = 0; i < 10; i++) {
+	aliases[i] = [`Numpad${i}`, `Digit${i}`];
+}
diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts
new file mode 100644
index 0000000000..25f5b366c8
--- /dev/null
+++ b/packages/frontend/src/scripts/langmap.ts
@@ -0,0 +1,666 @@
+// TODO: sharedに置いてバックエンドのと統合したい
+export const langmap = {
+	'ach': {
+		nativeName: 'Lwo',
+	},
+	'ady': {
+		nativeName: 'Адыгэбзэ',
+	},
+	'af': {
+		nativeName: 'Afrikaans',
+	},
+	'af-NA': {
+		nativeName: 'Afrikaans (Namibia)',
+	},
+	'af-ZA': {
+		nativeName: 'Afrikaans (South Africa)',
+	},
+	'ak': {
+		nativeName: 'Tɕɥi',
+	},
+	'ar': {
+		nativeName: 'العربية',
+	},
+	'ar-AR': {
+		nativeName: 'العربية',
+	},
+	'ar-MA': {
+		nativeName: 'العربية',
+	},
+	'ar-SA': {
+		nativeName: 'العربية (السعودية)',
+	},
+	'ay-BO': {
+		nativeName: 'Aymar aru',
+	},
+	'az': {
+		nativeName: 'Azərbaycan dili',
+	},
+	'az-AZ': {
+		nativeName: 'Azərbaycan dili',
+	},
+	'be-BY': {
+		nativeName: 'Беларуская',
+	},
+	'bg': {
+		nativeName: 'Български',
+	},
+	'bg-BG': {
+		nativeName: 'Български',
+	},
+	'bn': {
+		nativeName: 'বাংলা',
+	},
+	'bn-IN': {
+		nativeName: 'বাংলা (ভারত)',
+	},
+	'bn-BD': {
+		nativeName: 'বাংলা(বাংলাদেশ)',
+	},
+	'br': {
+		nativeName: 'Brezhoneg',
+	},
+	'bs-BA': {
+		nativeName: 'Bosanski',
+	},
+	'ca': {
+		nativeName: 'Català',
+	},
+	'ca-ES': {
+		nativeName: 'Català',
+	},
+	'cak': {
+		nativeName: 'Maya Kaqchikel',
+	},
+	'ck-US': {
+		nativeName: 'ᏣᎳᎩ (tsalagi)',
+	},
+	'cs': {
+		nativeName: 'Čeština',
+	},
+	'cs-CZ': {
+		nativeName: 'Čeština',
+	},
+	'cy': {
+		nativeName: 'Cymraeg',
+	},
+	'cy-GB': {
+		nativeName: 'Cymraeg',
+	},
+	'da': {
+		nativeName: 'Dansk',
+	},
+	'da-DK': {
+		nativeName: 'Dansk',
+	},
+	'de': {
+		nativeName: 'Deutsch',
+	},
+	'de-AT': {
+		nativeName: 'Deutsch (Österreich)',
+	},
+	'de-DE': {
+		nativeName: 'Deutsch (Deutschland)',
+	},
+	'de-CH': {
+		nativeName: 'Deutsch (Schweiz)',
+	},
+	'dsb': {
+		nativeName: 'Dolnoserbšćina',
+	},
+	'el': {
+		nativeName: 'Ελληνικά',
+	},
+	'el-GR': {
+		nativeName: 'Ελληνικά',
+	},
+	'en': {
+		nativeName: 'English',
+	},
+	'en-GB': {
+		nativeName: 'English (UK)',
+	},
+	'en-AU': {
+		nativeName: 'English (Australia)',
+	},
+	'en-CA': {
+		nativeName: 'English (Canada)',
+	},
+	'en-IE': {
+		nativeName: 'English (Ireland)',
+	},
+	'en-IN': {
+		nativeName: 'English (India)',
+	},
+	'en-PI': {
+		nativeName: 'English (Pirate)',
+	},
+	'en-SG': {
+		nativeName: 'English (Singapore)',
+	},
+	'en-UD': {
+		nativeName: 'English (Upside Down)',
+	},
+	'en-US': {
+		nativeName: 'English (US)',
+	},
+	'en-ZA': {
+		nativeName: 'English (South Africa)',
+	},
+	'en@pirate': {
+		nativeName: 'English (Pirate)',
+	},
+	'eo': {
+		nativeName: 'Esperanto',
+	},
+	'eo-EO': {
+		nativeName: 'Esperanto',
+	},
+	'es': {
+		nativeName: 'Español',
+	},
+	'es-AR': {
+		nativeName: 'Español (Argentine)',
+	},
+	'es-419': {
+		nativeName: 'Español (Latinoamérica)',
+	},
+	'es-CL': {
+		nativeName: 'Español (Chile)',
+	},
+	'es-CO': {
+		nativeName: 'Español (Colombia)',
+	},
+	'es-EC': {
+		nativeName: 'Español (Ecuador)',
+	},
+	'es-ES': {
+		nativeName: 'Español (España)',
+	},
+	'es-LA': {
+		nativeName: 'Español (Latinoamérica)',
+	},
+	'es-NI': {
+		nativeName: 'Español (Nicaragua)',
+	},
+	'es-MX': {
+		nativeName: 'Español (México)',
+	},
+	'es-US': {
+		nativeName: 'Español (Estados Unidos)',
+	},
+	'es-VE': {
+		nativeName: 'Español (Venezuela)',
+	},
+	'et': {
+		nativeName: 'eesti keel',
+	},
+	'et-EE': {
+		nativeName: 'Eesti (Estonia)',
+	},
+	'eu': {
+		nativeName: 'Euskara',
+	},
+	'eu-ES': {
+		nativeName: 'Euskara',
+	},
+	'fa': {
+		nativeName: 'فارسی',
+	},
+	'fa-IR': {
+		nativeName: 'فارسی',
+	},
+	'fb-LT': {
+		nativeName: 'Leet Speak',
+	},
+	'ff': {
+		nativeName: 'Fulah',
+	},
+	'fi': {
+		nativeName: 'Suomi',
+	},
+	'fi-FI': {
+		nativeName: 'Suomi',
+	},
+	'fo': {
+		nativeName: 'Føroyskt',
+	},
+	'fo-FO': {
+		nativeName: 'Føroyskt (Færeyjar)',
+	},
+	'fr': {
+		nativeName: 'Français',
+	},
+	'fr-CA': {
+		nativeName: 'Français (Canada)',
+	},
+	'fr-FR': {
+		nativeName: 'Français (France)',
+	},
+	'fr-BE': {
+		nativeName: 'Français (Belgique)',
+	},
+	'fr-CH': {
+		nativeName: 'Français (Suisse)',
+	},
+	'fy-NL': {
+		nativeName: 'Frysk',
+	},
+	'ga': {
+		nativeName: 'Gaeilge',
+	},
+	'ga-IE': {
+		nativeName: 'Gaeilge',
+	},
+	'gd': {
+		nativeName: 'Gàidhlig',
+	},
+	'gl': {
+		nativeName: 'Galego',
+	},
+	'gl-ES': {
+		nativeName: 'Galego',
+	},
+	'gn-PY': {
+		nativeName: 'Avañe\'ẽ',
+	},
+	'gu-IN': {
+		nativeName: 'ગુજરાતી',
+	},
+	'gv': {
+		nativeName: 'Gaelg',
+	},
+	'gx-GR': {
+		nativeName: 'Ἑλληνική ἀρχαία',
+	},
+	'he': {
+		nativeName: 'עברית‏',
+	},
+	'he-IL': {
+		nativeName: 'עברית‏',
+	},
+	'hi': {
+		nativeName: 'हिन्दी',
+	},
+	'hi-IN': {
+		nativeName: 'हिन्दी',
+	},
+	'hr': {
+		nativeName: 'Hrvatski',
+	},
+	'hr-HR': {
+		nativeName: 'Hrvatski',
+	},
+	'hsb': {
+		nativeName: 'Hornjoserbšćina',
+	},
+	'ht': {
+		nativeName: 'Kreyòl',
+	},
+	'hu': {
+		nativeName: 'Magyar',
+	},
+	'hu-HU': {
+		nativeName: 'Magyar',
+	},
+	'hy': {
+		nativeName: 'Հայերեն',
+	},
+	'hy-AM': {
+		nativeName: 'Հայերեն (Հայաստան)',
+	},
+	'id': {
+		nativeName: 'Bahasa Indonesia',
+	},
+	'id-ID': {
+		nativeName: 'Bahasa Indonesia',
+	},
+	'is': {
+		nativeName: 'Íslenska',
+	},
+	'is-IS': {
+		nativeName: 'Íslenska (Iceland)',
+	},
+	'it': {
+		nativeName: 'Italiano',
+	},
+	'it-IT': {
+		nativeName: 'Italiano',
+	},
+	'ja': {
+		nativeName: '日本語',
+	},
+	'ja-JP': {
+		nativeName: '日本語 (日本)',
+	},
+	'jv-ID': {
+		nativeName: 'Basa Jawa',
+	},
+	'ka-GE': {
+		nativeName: 'ქართული',
+	},
+	'kk-KZ': {
+		nativeName: 'Қазақша',
+	},
+	'km': {
+		nativeName: 'ភាសាខ្មែរ',
+	},
+	'kl': {
+		nativeName: 'kalaallisut',
+	},
+	'km-KH': {
+		nativeName: 'ភាសាខ្មែរ',
+	},
+	'kab': {
+		nativeName: 'Taqbaylit',
+	},
+	'kn': {
+		nativeName: 'ಕನ್ನಡ',
+	},
+	'kn-IN': {
+		nativeName: 'ಕನ್ನಡ (India)',
+	},
+	'ko': {
+		nativeName: '한국어',
+	},
+	'ko-KR': {
+		nativeName: '한국어 (한국)',
+	},
+	'ku-TR': {
+		nativeName: 'Kurdî',
+	},
+	'kw': {
+		nativeName: 'Kernewek',
+	},
+	'la': {
+		nativeName: 'Latin',
+	},
+	'la-VA': {
+		nativeName: 'Latin',
+	},
+	'lb': {
+		nativeName: 'Lëtzebuergesch',
+	},
+	'li-NL': {
+		nativeName: 'Lèmbörgs',
+	},
+	'lt': {
+		nativeName: 'Lietuvių',
+	},
+	'lt-LT': {
+		nativeName: 'Lietuvių',
+	},
+	'lv': {
+		nativeName: 'Latviešu',
+	},
+	'lv-LV': {
+		nativeName: 'Latviešu',
+	},
+	'mai': {
+		nativeName: 'मैथिली, মৈথিলী',
+	},
+	'mg-MG': {
+		nativeName: 'Malagasy',
+	},
+	'mk': {
+		nativeName: 'Македонски',
+	},
+	'mk-MK': {
+		nativeName: 'Македонски (Македонски)',
+	},
+	'ml': {
+		nativeName: 'മലയാളം',
+	},
+	'ml-IN': {
+		nativeName: 'മലയാളം',
+	},
+	'mn-MN': {
+		nativeName: 'Монгол',
+	},
+	'mr': {
+		nativeName: 'मराठी',
+	},
+	'mr-IN': {
+		nativeName: 'मराठी',
+	},
+	'ms': {
+		nativeName: 'Bahasa Melayu',
+	},
+	'ms-MY': {
+		nativeName: 'Bahasa Melayu',
+	},
+	'mt': {
+		nativeName: 'Malti',
+	},
+	'mt-MT': {
+		nativeName: 'Malti',
+	},
+	'my': {
+		nativeName: 'ဗမာစကာ',
+	},
+	'no': {
+		nativeName: 'Norsk',
+	},
+	'nb': {
+		nativeName: 'Norsk (bokmål)',
+	},
+	'nb-NO': {
+		nativeName: 'Norsk (bokmål)',
+	},
+	'ne': {
+		nativeName: 'नेपाली',
+	},
+	'ne-NP': {
+		nativeName: 'नेपाली',
+	},
+	'nl': {
+		nativeName: 'Nederlands',
+	},
+	'nl-BE': {
+		nativeName: 'Nederlands (België)',
+	},
+	'nl-NL': {
+		nativeName: 'Nederlands (Nederland)',
+	},
+	'nn-NO': {
+		nativeName: 'Norsk (nynorsk)',
+	},
+	'oc': {
+		nativeName: 'Occitan',
+	},
+	'or-IN': {
+		nativeName: 'ଓଡ଼ିଆ',
+	},
+	'pa': {
+		nativeName: 'ਪੰਜਾਬੀ',
+	},
+	'pa-IN': {
+		nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
+	},
+	'pl': {
+		nativeName: 'Polski',
+	},
+	'pl-PL': {
+		nativeName: 'Polski',
+	},
+	'ps-AF': {
+		nativeName: 'پښتو',
+	},
+	'pt': {
+		nativeName: 'Português',
+	},
+	'pt-BR': {
+		nativeName: 'Português (Brasil)',
+	},
+	'pt-PT': {
+		nativeName: 'Português (Portugal)',
+	},
+	'qu-PE': {
+		nativeName: 'Qhichwa',
+	},
+	'rm-CH': {
+		nativeName: 'Rumantsch',
+	},
+	'ro': {
+		nativeName: 'Română',
+	},
+	'ro-RO': {
+		nativeName: 'Română',
+	},
+	'ru': {
+		nativeName: 'Русский',
+	},
+	'ru-RU': {
+		nativeName: 'Русский',
+	},
+	'sa-IN': {
+		nativeName: 'संस्कृतम्',
+	},
+	'se-NO': {
+		nativeName: 'Davvisámegiella',
+	},
+	'sh': {
+		nativeName: 'српскохрватски',
+	},
+	'si-LK': {
+		nativeName: 'සිංහල',
+	},
+	'sk': {
+		nativeName: 'Slovenčina',
+	},
+	'sk-SK': {
+		nativeName: 'Slovenčina (Slovakia)',
+	},
+	'sl': {
+		nativeName: 'Slovenščina',
+	},
+	'sl-SI': {
+		nativeName: 'Slovenščina',
+	},
+	'so-SO': {
+		nativeName: 'Soomaaliga',
+	},
+	'sq': {
+		nativeName: 'Shqip',
+	},
+	'sq-AL': {
+		nativeName: 'Shqip',
+	},
+	'sr': {
+		nativeName: 'Српски',
+	},
+	'sr-RS': {
+		nativeName: 'Српски (Serbia)',
+	},
+	'su': {
+		nativeName: 'Basa Sunda',
+	},
+	'sv': {
+		nativeName: 'Svenska',
+	},
+	'sv-SE': {
+		nativeName: 'Svenska',
+	},
+	'sw': {
+		nativeName: 'Kiswahili',
+	},
+	'sw-KE': {
+		nativeName: 'Kiswahili',
+	},
+	'ta': {
+		nativeName: 'தமிழ்',
+	},
+	'ta-IN': {
+		nativeName: 'தமிழ்',
+	},
+	'te': {
+		nativeName: 'తెలుగు',
+	},
+	'te-IN': {
+		nativeName: 'తెలుగు',
+	},
+	'tg': {
+		nativeName: 'забо́ни тоҷикӣ́',
+	},
+	'tg-TJ': {
+		nativeName: 'тоҷикӣ',
+	},
+	'th': {
+		nativeName: 'ภาษาไทย',
+	},
+	'th-TH': {
+		nativeName: 'ภาษาไทย (ประเทศไทย)',
+	},
+	'fil': {
+		nativeName: 'Filipino',
+	},
+	'tlh': {
+		nativeName: 'tlhIngan-Hol',
+	},
+	'tr': {
+		nativeName: 'Türkçe',
+	},
+	'tr-TR': {
+		nativeName: 'Türkçe',
+	},
+	'tt-RU': {
+		nativeName: 'татарча',
+	},
+	'uk': {
+		nativeName: 'Українська',
+	},
+	'uk-UA': {
+		nativeName: 'Українська',
+	},
+	'ur': {
+		nativeName: 'اردو',
+	},
+	'ur-PK': {
+		nativeName: 'اردو',
+	},
+	'uz': {
+		nativeName: 'O\'zbek',
+	},
+	'uz-UZ': {
+		nativeName: 'O\'zbek',
+	},
+	'vi': {
+		nativeName: 'Tiếng Việt',
+	},
+	'vi-VN': {
+		nativeName: 'Tiếng Việt',
+	},
+	'xh-ZA': {
+		nativeName: 'isiXhosa',
+	},
+	'yi': {
+		nativeName: 'ייִדיש',
+	},
+	'yi-DE': {
+		nativeName: 'ייִדיש (German)',
+	},
+	'zh': {
+		nativeName: '中文',
+	},
+	'zh-Hans': {
+		nativeName: '中文简体',
+	},
+	'zh-Hant': {
+		nativeName: '中文繁體',
+	},
+	'zh-CN': {
+		nativeName: '中文（中国大陆）',
+	},
+	'zh-HK': {
+		nativeName: '中文（香港）',
+	},
+	'zh-SG': {
+		nativeName: '中文（新加坡）',
+	},
+	'zh-TW': {
+		nativeName: '中文（台灣）',
+	},
+	'zu-ZA': {
+		nativeName: 'isiZulu',
+	},
+};
diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts
new file mode 100644
index 0000000000..0f9c6be4a9
--- /dev/null
+++ b/packages/frontend/src/scripts/login-id.ts
@@ -0,0 +1,11 @@
+export function getUrlWithLoginId(url: string, loginId: string) {
+	const u = new URL(url, origin);
+	u.searchParams.append('loginId', loginId);
+	return u.toString();
+}
+
+export function getUrlWithoutLoginId(url: string) {
+	const u = new URL(url);
+	u.searchParams.delete('loginId');
+	return u.toString();
+}
diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/lookup-user.ts
new file mode 100644
index 0000000000..3ab9d55300
--- /dev/null
+++ b/packages/frontend/src/scripts/lookup-user.ts
@@ -0,0 +1,36 @@
+import * as Acct from 'misskey-js/built/acct';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+export async function lookupUser() {
+	const { canceled, result } = await os.inputText({
+		title: i18n.ts.usernameOrUserId,
+	});
+	if (canceled) return;
+
+	const show = (user) => {
+		os.pageWindow(`/user-info/${user.id}`);
+	};
+
+	const usernamePromise = os.api('users/show', Acct.parse(result));
+	const idPromise = os.api('users/show', { userId: result });
+	let _notFound = false;
+	const notFound = () => {
+		if (_notFound) {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.noSuchUser,
+			});
+		} else {
+			_notFound = true;
+		}
+	};
+	usernamePromise.then(show).catch(err => {
+		if (err.code === 'NO_SUCH_USER') {
+			notFound();
+		}
+	});
+	idPromise.then(show).catch(err => {
+		notFound();
+	});
+}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
new file mode 100644
index 0000000000..aaf7f9e610
--- /dev/null
+++ b/packages/frontend/src/scripts/media-proxy.ts
@@ -0,0 +1,15 @@
+import { query } from '@/scripts/url';
+import { url } from '@/config';
+
+export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
+	return `${url}/proxy/image.webp?${query({
+		url: imageUrl,
+		fallback: '1',
+		...(type ? { [type]: '1' } : {}),
+	})}`;
+}
+
+export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
+	if (imageUrl == null) return null;
+	return getProxiedImageUrl(imageUrl, type);
+}
diff --git a/packages/frontend/src/scripts/mfm-tags.ts b/packages/frontend/src/scripts/mfm-tags.ts
new file mode 100644
index 0000000000..18e8d7038a
--- /dev/null
+++ b/packages/frontend/src/scripts/mfm-tags.ts
@@ -0,0 +1 @@
+export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts
new file mode 100644
index 0000000000..0db8369f9d
--- /dev/null
+++ b/packages/frontend/src/scripts/page-metadata.ts
@@ -0,0 +1,41 @@
+import * as misskey from 'misskey-js';
+import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
+
+export const setPageMetadata = Symbol('setPageMetadata');
+export const pageMetadataProvider = Symbol('pageMetadataProvider');
+
+export type PageMetadata = {
+	title: string;
+	subtitle?: string;
+	icon?: string | null;
+	avatar?: misskey.entities.User | null;
+	userName?: misskey.entities.User | null;
+	bg?: string;
+};
+
+export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
+	const _metadata = isRef(metadata) ? metadata : ref(metadata);
+
+	provide(pageMetadataProvider, _metadata);
+
+	const set = inject(setPageMetadata) as any;
+	if (set) {
+		set(_metadata);
+
+		onMounted(() => {
+			set(_metadata);
+		});
+
+		onActivated(() => {
+			set(_metadata);
+		});
+	}
+}
+
+export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
+	provide(setPageMetadata, callback);
+}
+
+export function injectPageMetadata(): PageMetadata | undefined {
+	return inject(pageMetadataProvider);
+}
diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts
new file mode 100644
index 0000000000..efda80f074
--- /dev/null
+++ b/packages/frontend/src/scripts/physics.ts
@@ -0,0 +1,152 @@
+import * as Matter from 'matter-js';
+
+export function physics(container: HTMLElement) {
+	const containerWidth = container.offsetWidth;
+	const containerHeight = container.offsetHeight;
+	const containerCenterX = containerWidth / 2;
+
+	// サイズ固定化(要らないかも？)
+	container.style.position = 'relative';
+	container.style.boxSizing = 'border-box';
+	container.style.width = `${containerWidth}px`;
+	container.style.height = `${containerHeight}px`;
+
+	// create engine
+	const engine = Matter.Engine.create({
+		constraintIterations: 4,
+		positionIterations: 8,
+		velocityIterations: 8,
+	});
+
+	const world = engine.world;
+
+	// create renderer
+	const render = Matter.Render.create({
+		engine: engine,
+		//element: document.getElementById('debug'),
+		options: {
+			width: containerWidth,
+			height: containerHeight,
+			background: 'transparent', // transparent to hide
+			wireframeBackground: 'transparent', // transparent to hide
+		},
+	});
+
+	// Disable to hide debug
+	Matter.Render.run(render);
+
+	// create runner
+	const runner = Matter.Runner.create();
+	Matter.Runner.run(runner, engine);
+
+	const groundThickness = 1024;
+	const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
+		isStatic: true,
+		restitution: 0.1,
+		friction: 2,
+	});
+
+	//const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+	//const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+
+	Matter.World.add(world, [
+		ground,
+		//wallRight,
+		//wallLeft,
+	]);
+
+	const objEls = Array.from(container.children) as HTMLElement[];
+	const objs: Matter.Body[] = [];
+	for (const objEl of objEls) {
+		const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
+		const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
+
+		let obj: Matter.Body;
+		if (objEl.classList.contains('_physics_circle_')) {
+			obj = Matter.Bodies.circle(
+				left + (objEl.offsetWidth / 2),
+				top + (objEl.offsetHeight / 2),
+				Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
+				{
+					restitution: 0.5,
+				},
+			);
+		} else {
+			const style = window.getComputedStyle(objEl);
+			obj = Matter.Bodies.rectangle(
+				left + (objEl.offsetWidth / 2),
+				top + (objEl.offsetHeight / 2),
+				objEl.offsetWidth,
+				objEl.offsetHeight,
+				{
+					chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
+					restitution: 0.5,
+				},
+			);
+		}
+		objEl.id = obj.id.toString();
+		objs.push(obj);
+	}
+
+	Matter.World.add(engine.world, objs);
+
+	// Add mouse control
+
+	const mouse = Matter.Mouse.create(container);
+	const mouseConstraint = Matter.MouseConstraint.create(engine, {
+		mouse: mouse,
+		constraint: {
+			stiffness: 0.1,
+			render: {
+				visible: false,
+			},
+		},
+	});
+
+	Matter.World.add(engine.world, mouseConstraint);
+
+	// keep the mouse in sync with rendering
+	render.mouse = mouse;
+
+	for (const objEl of objEls) {
+		objEl.style.position = 'absolute';
+		objEl.style.top = '0';
+		objEl.style.left = '0';
+		objEl.style.margin = '0';
+	}
+
+	window.requestAnimationFrame(update);
+
+	let stop = false;
+
+	function update() {
+		for (const objEl of objEls) {
+			const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
+			if (obj == null) continue;
+
+			const x = (obj.position.x - objEl.offsetWidth / 2);
+			const y = (obj.position.y - objEl.offsetHeight / 2);
+			const angle = obj.angle;
+			objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
+		}
+
+		if (!stop) {
+			window.requestAnimationFrame(update);
+		}
+	}
+
+	// 奈落に落ちたオブジェクトは消す
+	const intervalId = window.setInterval(() => {
+		for (const obj of objs) {
+			if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
+		}
+	}, 1000 * 10);
+
+	return {
+		stop: () => {
+			stop = true;
+			Matter.Runner.stop(runner);
+			window.clearInterval(intervalId);
+		},
+	};
+}
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
new file mode 100644
index 0000000000..b8fb853cc1
--- /dev/null
+++ b/packages/frontend/src/scripts/please-login.ts
@@ -0,0 +1,21 @@
+import { defineAsyncComponent } from 'vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { popup } from '@/os';
+
+export function pleaseLogin(path?: string) {
+	if ($i) return;
+
+	popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
+		autoSet: true,
+		message: i18n.ts.signinRequired,
+	}, {
+		cancelled: () => {
+			if (path) {
+				window.location.href = path;
+			}
+		},
+	}, 'closed');
+
+	if (!path) throw new Error('signin required');
+}
diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts
new file mode 100644
index 0000000000..580031d0a3
--- /dev/null
+++ b/packages/frontend/src/scripts/popout.ts
@@ -0,0 +1,23 @@
+import * as config from '@/config';
+import { appendQuery } from './url';
+
+export function popout(path: string, w?: HTMLElement) {
+	let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
+	url = appendQuery(url, 'zen');
+	if (w) {
+		const position = w.getBoundingClientRect();
+		const width = parseInt(getComputedStyle(w, '').width, 10);
+		const height = parseInt(getComputedStyle(w, '').height, 10);
+		const x = window.screenX + position.left;
+		const y = window.screenY + position.top;
+		window.open(url, url,
+			`width=${width}, height=${height}, top=${y}, left=${x}`);
+	} else {
+		const width = 400;
+		const height = 500;
+		const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
+		const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
+		window.open(url, url,
+			`width=${width}, height=${height}, top=${x}, left=${y}`);
+	}
+}
diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts
new file mode 100644
index 0000000000..e84eebf103
--- /dev/null
+++ b/packages/frontend/src/scripts/popup-position.ts
@@ -0,0 +1,158 @@
+import { Ref } from 'vue';
+
+export function calcPopupPosition(el: HTMLElement, props: {
+	anchorElement: HTMLElement | null;
+	innerMargin: number;
+	direction: 'top' | 'bottom' | 'left' | 'right';
+	align: 'top' | 'bottom' | 'left' | 'right' | 'center';
+	alignOffset?: number;
+	x?: number;
+	y?: number;
+}): { top: number; left: number; transformOrigin: string; } {
+	const contentWidth = el.offsetWidth;
+	const contentHeight = el.offsetHeight;
+
+	let rect: DOMRect;
+
+	if (props.anchorElement) {
+		rect = props.anchorElement.getBoundingClientRect();
+	}
+
+	const calcPosWhenTop = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
+		} else {
+			left = props.x;
+			top = (props.y - contentHeight) - props.innerMargin;
+		}
+
+		left -= (el.offsetWidth / 2);
+
+		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenBottom = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
+		} else {
+			left = props.x;
+			top = (props.y) + props.innerMargin;
+		}
+
+		left -= (el.offsetWidth / 2);
+
+		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenLeft = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
+			top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+		} else {
+			left = (props.x - contentWidth) - props.innerMargin;
+			top = props.y;
+		}
+
+		top -= (el.offsetHeight / 2);
+
+		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenRight = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
+
+			if (props.align === 'top') {
+				top = rect.top + window.pageYOffset;
+				if (props.alignOffset != null) top += props.alignOffset;
+			} else if (props.align === 'bottom') {
+				// TODO
+			} else { // center
+				top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+				top -= (el.offsetHeight / 2);
+			}
+		} else {
+			left = props.x + props.innerMargin;
+			top = props.y;
+			top -= (el.offsetHeight / 2);
+		}
+
+		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calc = (): {
+		left: number;
+		top: number;
+		transformOrigin: string;
+	} => {
+		switch (props.direction) {
+			case 'top': {
+				const [left, top] = calcPosWhenTop();
+
+				// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
+				if (top - window.pageYOffset < 0) {
+					const [left, top] = calcPosWhenBottom();
+					return { left, top, transformOrigin: 'center top' };
+				}
+
+				return { left, top, transformOrigin: 'center bottom' };
+			}
+
+			case 'bottom': {
+				const [left, top] = calcPosWhenBottom();
+				// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
+				return { left, top, transformOrigin: 'center top' };
+			}
+
+			case 'left': {
+				const [left, top] = calcPosWhenLeft();
+
+				// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
+				if (left - window.pageXOffset < 0) {
+					const [left, top] = calcPosWhenRight();
+					return { left, top, transformOrigin: 'left center' };
+				}
+
+				return { left, top, transformOrigin: 'right center' };
+			}
+
+			case 'right': {
+				const [left, top] = calcPosWhenRight();
+				// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
+				return { left, top, transformOrigin: 'left center' };
+			}
+		}
+	};
+
+	return calc();
+}
diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts
new file mode 100644
index 0000000000..fe32e719da
--- /dev/null
+++ b/packages/frontend/src/scripts/reaction-picker.ts
@@ -0,0 +1,41 @@
+import { defineAsyncComponent, Ref, ref } from 'vue';
+import { popup } from '@/os';
+
+class ReactionPicker {
+	private src: Ref<HTMLElement | null> = ref(null);
+	private manualShowing = ref(false);
+	private onChosen?: (reaction: string) => void;
+	private onClosed?: () => void;
+
+	constructor() {
+		// nop
+	}
+
+	public async init() {
+		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+			src: this.src,
+			asReactionPicker: true,
+			manualShowing: this.manualShowing,
+		}, {
+			done: reaction => {
+				this.onChosen!(reaction);
+			},
+			close: () => {
+				this.manualShowing.value = false;
+			},
+			closed: () => {
+				this.src.value = null;
+				this.onClosed!();
+			},
+		});
+	}
+
+	public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
+		this.src.value = src;
+		this.manualShowing.value = true;
+		this.onChosen = onChosen;
+		this.onClosed = onClosed;
+	}
+}
+
+export const reactionPicker = new ReactionPicker();
diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts
new file mode 100644
index 0000000000..301b56d7fd
--- /dev/null
+++ b/packages/frontend/src/scripts/safe-uri-decode.ts
@@ -0,0 +1,7 @@
+export function safeURIDecode(str: string): string {
+	try {
+		return decodeURIComponent(str);
+	} catch {
+		return str;
+	}
+}
diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts
new file mode 100644
index 0000000000..f5bc6bf9ce
--- /dev/null
+++ b/packages/frontend/src/scripts/scroll.ts
@@ -0,0 +1,85 @@
+type ScrollBehavior = 'auto' | 'smooth' | 'instant';
+
+export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
+	if (el == null || el.tagName === 'HTML') return null;
+	const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y');
+	if (overflow === 'scroll' || overflow === 'auto') {
+		return el;
+	} else {
+		return getScrollContainer(el.parentElement);
+	}
+}
+
+export function getScrollPosition(el: Element | null): number {
+	const container = getScrollContainer(el);
+	return container == null ? window.scrollY : container.scrollTop;
+}
+
+export function isTopVisible(el: Element | null): boolean {
+	const scrollTop = getScrollPosition(el);
+	const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
+
+	return scrollTop <= topPosition;
+}
+
+export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
+	if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
+	return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
+}
+
+export function onScrollTop(el: Element, cb) {
+	const container = getScrollContainer(el) || window;
+	const onScroll = ev => {
+		if (!document.body.contains(el)) return;
+		if (isTopVisible(el)) {
+			cb();
+			container.removeEventListener('scroll', onScroll);
+		}
+	};
+	container.addEventListener('scroll', onScroll, { passive: true });
+}
+
+export function onScrollBottom(el: Element, cb) {
+	const container = getScrollContainer(el) || window;
+	const onScroll = ev => {
+		if (!document.body.contains(el)) return;
+		const pos = getScrollPosition(el);
+		if (pos + el.clientHeight > el.scrollHeight - 1) {
+			cb();
+			container.removeEventListener('scroll', onScroll);
+		}
+	};
+	container.addEventListener('scroll', onScroll, { passive: true });
+}
+
+export function scroll(el: Element, options: {
+	top?: number;
+	left?: number;
+	behavior?: ScrollBehavior;
+}) {
+	const container = getScrollContainer(el);
+	if (container == null) {
+		window.scroll(options);
+	} else {
+		container.scroll(options);
+	}
+}
+
+export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
+	scroll(el, { top: 0, ...options });
+}
+
+export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
+	scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
+}
+
+export function isBottom(el: Element, asobi = 0) {
+	const container = getScrollContainer(el);
+	const current = container
+		? el.scrollTop + el.offsetHeight
+		: window.scrollY + window.innerHeight;
+	const max = container
+		? el.scrollHeight
+		: document.body.offsetHeight;
+	return current >= (max - asobi);
+}
diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts
new file mode 100644
index 0000000000..64914d3d65
--- /dev/null
+++ b/packages/frontend/src/scripts/search.ts
@@ -0,0 +1,63 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
+
+export async function search() {
+	const { canceled, result: query } = await os.inputText({
+		title: i18n.ts.search,
+	});
+	if (canceled || query == null || query === '') return;
+
+	const q = query.trim();
+
+	if (q.startsWith('@') && !q.includes(' ')) {
+		mainRouter.push(`/${q}`);
+		return;
+	}
+
+	if (q.startsWith('#')) {
+		mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
+		return;
+	}
+
+	// like 2018/03/12
+	if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
+		const date = new Date(q.replace(/-/g, '/'));
+
+		// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
+		// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
+		// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
+		// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
+		if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
+			date.setHours(23, 59, 59, 999);
+		}
+
+		// TODO
+		//v.$root.$emit('warp', date);
+		os.alert({
+			icon: 'fas fa-history',
+			iconOnly: true, autoClose: true,
+		});
+		return;
+	}
+
+	if (q.startsWith('https://')) {
+		const promise = os.api('ap/show', {
+			uri: q,
+		});
+
+		os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+
+		const res = await promise;
+
+		if (res.type === 'User') {
+			mainRouter.push(`/@${res.object.username}@${res.object.host}`);
+		} else if (res.type === 'Note') {
+			mainRouter.push(`/notes/${res.object.id}`);
+		}
+
+		return;
+	}
+
+	mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
+}
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
new file mode 100644
index 0000000000..ec5f8f65e9
--- /dev/null
+++ b/packages/frontend/src/scripts/select-file.ts
@@ -0,0 +1,103 @@
+import { ref } from 'vue';
+import { DriveFile } from 'misskey-js/built/entities';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+import { uploadFile } from '@/scripts/upload';
+
+function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
+	return new Promise((res, rej) => {
+		const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
+
+		const chooseFileFromPc = () => {
+			const input = document.createElement('input');
+			input.type = 'file';
+			input.multiple = multiple;
+			input.onchange = () => {
+				const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
+
+				Promise.all(promises).then(driveFiles => {
+					res(multiple ? driveFiles : driveFiles[0]);
+				}).catch(err => {
+					// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
+				});
+
+				// 一応廃棄
+				(window as any).__misskey_input_ref__ = null;
+			};
+
+			// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+			// iOS Safari で正常に動かす為のおまじない
+			(window as any).__misskey_input_ref__ = input;
+
+			input.click();
+		};
+
+		const chooseFileFromDrive = () => {
+			os.selectDriveFile(multiple).then(files => {
+				res(files);
+			});
+		};
+
+		const chooseFileFromUrl = () => {
+			os.inputText({
+				title: i18n.ts.uploadFromUrl,
+				type: 'url',
+				placeholder: i18n.ts.uploadFromUrlDescription,
+			}).then(({ canceled, result: url }) => {
+				if (canceled) return;
+
+				const marker = Math.random().toString(); // TODO: UUIDとか使う
+
+				const connection = stream.useChannel('main');
+				connection.on('urlUploadFinished', urlResponse => {
+					if (urlResponse.marker === marker) {
+						res(multiple ? [urlResponse.file] : urlResponse.file);
+						connection.dispose();
+					}
+				});
+
+				os.api('drive/files/upload-from-url', {
+					url: url,
+					folderId: defaultStore.state.uploadFolder,
+					marker,
+				});
+
+				os.alert({
+					title: i18n.ts.uploadFromUrlRequested,
+					text: i18n.ts.uploadFromUrlMayTakeTime,
+				});
+			});
+		};
+
+		os.popupMenu([label ? {
+			text: label,
+			type: 'label',
+		} : undefined, {
+			type: 'switch',
+			text: i18n.ts.keepOriginalUploading,
+			ref: keepOriginal,
+		}, {
+			text: i18n.ts.upload,
+			icon: 'ti ti-upload',
+			action: chooseFileFromPc,
+		}, {
+			text: i18n.ts.fromDrive,
+			icon: 'ti ti-cloud',
+			action: chooseFileFromDrive,
+		}, {
+			text: i18n.ts.fromUrl,
+			icon: 'ti ti-link',
+			action: chooseFileFromUrl,
+		}], src);
+	});
+}
+
+export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
+	return select(src, label, false) as Promise<DriveFile>;
+}
+
+export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
+	return select(src, label, true) as Promise<DriveFile[]>;
+}
diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts
new file mode 100644
index 0000000000..e11569ecd4
--- /dev/null
+++ b/packages/frontend/src/scripts/show-suspended-dialog.ts
@@ -0,0 +1,10 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+export function showSuspendedDialog() {
+	return os.alert({
+		type: 'error',
+		title: i18n.ts.yourAccountSuspendedTitle,
+		text: i18n.ts.yourAccountSuspendedDescription,
+	});
+}
diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts
new file mode 100644
index 0000000000..05e6cdfbcf
--- /dev/null
+++ b/packages/frontend/src/scripts/shuffle.ts
@@ -0,0 +1,19 @@
+/**
+ * 配列をシャッフル (破壊的)
+ */
+export function shuffle<T extends any[]>(array: T): T {
+	let currentIndex = array.length, randomIndex;
+
+	// While there remain elements to shuffle.
+	while (currentIndex !== 0) {
+		// Pick a remaining element.
+		randomIndex = Math.floor(Math.random() * currentIndex);
+		currentIndex--;
+
+		// And swap it with the current element.
+		[array[currentIndex], array[randomIndex]] = [
+			array[randomIndex], array[currentIndex]];
+	}
+
+	return array;
+}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
new file mode 100644
index 0000000000..9d1f603235
--- /dev/null
+++ b/packages/frontend/src/scripts/sound.ts
@@ -0,0 +1,66 @@
+import { ColdDeviceStorage } from '@/store';
+
+const cache = new Map<string, HTMLAudioElement>();
+
+export const soundsTypes = [
+	null,
+	'syuilo/up',
+	'syuilo/down',
+	'syuilo/pope1',
+	'syuilo/pope2',
+	'syuilo/waon',
+	'syuilo/popo',
+	'syuilo/triple',
+	'syuilo/poi1',
+	'syuilo/poi2',
+	'syuilo/pirori',
+	'syuilo/pirori-wet',
+	'syuilo/pirori-square-wet',
+	'syuilo/square-pico',
+	'syuilo/reverved',
+	'syuilo/ryukyu',
+	'syuilo/kick',
+	'syuilo/snare',
+	'syuilo/queue-jammed',
+	'aisha/1',
+	'aisha/2',
+	'aisha/3',
+	'noizenecio/kick_gaba1',
+	'noizenecio/kick_gaba2',
+	'noizenecio/kick_gaba3',
+	'noizenecio/kick_gaba4',
+	'noizenecio/kick_gaba5',
+	'noizenecio/kick_gaba6',
+	'noizenecio/kick_gaba7',
+] as const;
+
+export function getAudio(file: string, useCache = true): HTMLAudioElement {
+	let audio: HTMLAudioElement;
+	if (useCache && cache.has(file)) {
+		audio = cache.get(file);
+	} else {
+		audio = new Audio(`/client-assets/sounds/${file}.mp3`);
+		if (useCache) cache.set(file, audio);
+	}
+	return audio;
+}
+
+export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
+	const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
+	audio.volume = masterVolume - ((1 - volume) * masterVolume);
+	return audio;
+}
+
+export function play(type: string) {
+	const sound = ColdDeviceStorage.get('sound_' + type as any);
+	if (sound.type == null) return;
+	playFile(sound.type, sound.volume);
+}
+
+export function playFile(file: string, volume: number) {
+	const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
+	if (masterVolume === 0) return;
+
+	const audio = setVolume(getAudio(file), volume);
+	audio.play();
+}
diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts
new file mode 100644
index 0000000000..c67b8f37ac
--- /dev/null
+++ b/packages/frontend/src/scripts/sticky-sidebar.ts
@@ -0,0 +1,50 @@
+export class StickySidebar {
+	private lastScrollTop = 0;
+	private container: HTMLElement;
+	private el: HTMLElement;
+	private spacer: HTMLElement;
+	private marginTop: number;
+	private isTop = false;
+	private isBottom = false;
+	private offsetTop: number;
+	private globalHeaderHeight: number = 59;
+
+	constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
+		this.container = container;
+		this.el = this.container.children[0] as HTMLElement;
+		this.el.style.position = 'sticky';
+		this.spacer = document.createElement('div');
+		this.container.prepend(this.spacer);
+		this.marginTop = marginTop;
+		this.offsetTop = this.container.getBoundingClientRect().top;
+		this.globalHeaderHeight = globalHeaderHeight;
+	}
+
+	public calc(scrollTop: number) {
+		if (scrollTop > this.lastScrollTop) { // downscroll
+			const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
+			this.el.style.bottom = null;
+			this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
+
+			this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
+
+			if (this.isTop) {
+				this.isTop = false;
+				this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
+			}
+		} else { // upscroll
+			const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
+			this.el.style.top = null;
+			this.el.style.bottom = `${-overflow}px`;
+
+			this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
+
+			if (this.isBottom) {
+				this.isBottom = false;
+				this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
+			}
+		}
+
+		this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
+	}
+}
diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts
new file mode 100644
index 0000000000..944875ff15
--- /dev/null
+++ b/packages/frontend/src/scripts/theme-editor.ts
@@ -0,0 +1,81 @@
+import { v4 as uuid } from 'uuid';
+
+import { themeProps, Theme } from './theme';
+
+export type Default = null;
+export type Color = string;
+export type FuncName = 'alpha' | 'darken' | 'lighten';
+export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
+export type RefProp = { type: 'refProp'; key: string; };
+export type RefConst = { type: 'refConst'; key: string; };
+export type Css = { type: 'css'; value: string; };
+
+export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
+
+export type ThemeViewModel = [ string, ThemeValue ][];
+
+export const fromThemeString = (str?: string) : ThemeValue => {
+	if (!str) return null;
+	if (str.startsWith(':')) {
+		const parts = str.slice(1).split('<');
+		const name = parts[0] as FuncName;
+		const arg = parseFloat(parts[1]);
+		const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
+		return { type: 'func', name, arg, value };
+	} else if (str.startsWith('@')) {
+		return {
+			type: 'refProp',
+			key: str.slice(1),
+		};
+	} else if (str.startsWith('$')) {
+		return {
+			type: 'refConst',
+			key: str.slice(1),
+		};
+	} else if (str.startsWith('"')) {
+		return {
+			type: 'css',
+			value: str.substr(1).trim(),
+		};
+	} else {
+		return str;
+	}
+};
+
+export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
+	if (typeof value === 'string') return value;
+	switch (value.type) {
+		case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
+		case 'refProp': return `@${value.key}`;
+		case 'refConst': return `$${value.key}`;
+		case 'css': return `" ${value.value}`;
+	}
+};
+
+export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
+	const props = { } as { [key: string]: string };
+	for (const [key, value] of vm) {
+		if (value === null) continue;
+		props[key] = toThemeString(value);
+	}
+
+	return {
+		id: uuid(),
+		name, desc, author, props, base,
+	};
+};
+
+export const convertToViewModel = (theme: Theme): ThemeViewModel => {
+	const vm: ThemeViewModel = [];
+	// プロパティの登録
+	vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
+
+	// 定数の登録
+	const consts = Object
+		.keys(theme.props)
+		.filter(k => k.startsWith('$'))
+		.map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
+
+		vm.push(...consts);
+	return vm;
+};
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
new file mode 100644
index 0000000000..62a2b9459a
--- /dev/null
+++ b/packages/frontend/src/scripts/theme.ts
@@ -0,0 +1,148 @@
+import { ref } from 'vue';
+import tinycolor from 'tinycolor2';
+import { globalEvents } from '@/events';
+
+export type Theme = {
+	id: string;
+	name: string;
+	author: string;
+	desc?: string;
+	base?: 'dark' | 'light';
+	props: Record<string, string>;
+};
+
+import lightTheme from '@/themes/_light.json5';
+import darkTheme from '@/themes/_dark.json5';
+import { deepClone } from './clone';
+
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
+export const getBuiltinThemes = () => Promise.all(
+	[
+		'l-light',
+		'l-coffee',
+		'l-apricot',
+		'l-rainy',
+		'l-vivid',
+		'l-cherry',
+		'l-sushi',
+		'l-u0',
+
+		'd-dark',
+		'd-persimmon',
+		'd-astro',
+		'd-future',
+		'd-botanical',
+		'd-green-lime',
+		'd-green-orange',
+		'd-cherry',
+		'd-ice',
+		'd-u0',
+	].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
+);
+
+export const getBuiltinThemesRef = () => {
+	const builtinThemes = ref<Theme[]>([]);
+	getBuiltinThemes().then(themes => builtinThemes.value = themes);
+	return builtinThemes;
+};
+
+let timeout = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+	if (timeout) window.clearTimeout(timeout);
+
+	document.documentElement.classList.add('_themeChanging_');
+
+	timeout = window.setTimeout(() => {
+		document.documentElement.classList.remove('_themeChanging_');
+	}, 1000);
+
+	const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
+
+	// Deep copy
+	const _theme = deepClone(theme);
+
+	if (_theme.base) {
+		const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
+		if (base) _theme.props = Object.assign({}, base.props, _theme.props);
+	}
+
+	const props = compile(_theme);
+
+	for (const tag of document.head.children) {
+		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
+			tag.setAttribute('content', props['htmlThemeColor']);
+			break;
+		}
+	}
+
+	for (const [k, v] of Object.entries(props)) {
+		document.documentElement.style.setProperty(`--${k}`, v.toString());
+	}
+
+	document.documentElement.style.setProperty('color-schema', colorSchema);
+
+	if (persist) {
+		localStorage.setItem('theme', JSON.stringify(props));
+		localStorage.setItem('colorSchema', colorSchema);
+	}
+
+	// 色計算など再度行えるようにクライアント全体に通知
+	globalEvents.emit('themeChanged');
+}
+
+function compile(theme: Theme): Record<string, string> {
+	function getColor(val: string): tinycolor.Instance {
+		// ref (prop)
+		if (val[0] === '@') {
+			return getColor(theme.props[val.substr(1)]);
+		}
+
+		// ref (const)
+		else if (val[0] === '$') {
+			return getColor(theme.props[val]);
+		}
+
+		// func
+		else if (val[0] === ':') {
+			const parts = val.split('<');
+			const func = parts.shift().substr(1);
+			const arg = parseFloat(parts.shift());
+			const color = getColor(parts.join('<'));
+
+			switch (func) {
+				case 'darken': return color.darken(arg);
+				case 'lighten': return color.lighten(arg);
+				case 'alpha': return color.setAlpha(arg);
+				case 'hue': return color.spin(arg);
+				case 'saturate': return color.saturate(arg);
+			}
+		}
+
+		// other case
+		return tinycolor(val);
+	}
+
+	const props = {};
+
+	for (const [k, v] of Object.entries(theme.props)) {
+		if (k.startsWith('$')) continue; // ignore const
+
+		props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
+	}
+
+	return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+	return c.toRgbString();
+}
+
+export function validateTheme(theme: Record<string, any>): boolean {
+	if (theme.id == null || typeof theme.id !== 'string') return false;
+	if (theme.name == null || typeof theme.name !== 'string') return false;
+	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
+	if (theme.props == null || typeof theme.props !== 'object') return false;
+	return true;
+}
diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts
new file mode 100644
index 0000000000..34e8b6b17c
--- /dev/null
+++ b/packages/frontend/src/scripts/time.ts
@@ -0,0 +1,39 @@
+const dateTimeIntervals = {
+	'day': 86400000,
+	'hour': 3600000,
+	'ms': 1,
+};
+
+export function dateUTC(time: number[]): Date {
+	const d = time.length === 2 ? Date.UTC(time[0], time[1])
+					: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
+					: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
+					: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
+					: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
+					: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
+					: null;
+
+	if (!d) throw 'wrong number of arguments';
+
+	return new Date(d);
+}
+
+export function isTimeSame(a: Date, b: Date): boolean {
+	return a.getTime() === b.getTime();
+}
+
+export function isTimeBefore(a: Date, b: Date): boolean {
+	return (a.getTime() - b.getTime()) < 0;
+}
+
+export function isTimeAfter(a: Date, b: Date): boolean {
+	return (a.getTime() - b.getTime()) > 0;
+}
+
+export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+	return new Date(x.getTime() + (value * dateTimeIntervals[span]));
+}
+
+export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+	return new Date(x.getTime() - (value * dateTimeIntervals[span]));
+}
diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts
new file mode 100644
index 0000000000..8ce07323f6
--- /dev/null
+++ b/packages/frontend/src/scripts/timezones.ts
@@ -0,0 +1,49 @@
+export const timezones = [{
+	name: 'UTC',
+	abbrev: 'UTC',
+	offset: 0,
+}, {
+	name: 'Europe/Berlin',
+	abbrev: 'CET',
+	offset: 60,
+}, {
+	name: 'Asia/Tokyo',
+	abbrev: 'JST',
+	offset: 540,
+}, {
+	name: 'Asia/Seoul',
+	abbrev: 'KST',
+	offset: 540,
+}, {
+	name: 'Asia/Shanghai',
+	abbrev: 'CST',
+	offset: 480,
+}, {
+	name: 'Australia/Sydney',
+	abbrev: 'AEST',
+	offset: 600,
+}, {
+	name: 'Australia/Darwin',
+	abbrev: 'ACST',
+	offset: 570,
+}, {
+	name: 'Australia/Perth',
+	abbrev: 'AWST',
+	offset: 480,
+}, {
+	name: 'America/New_York',
+	abbrev: 'EST',
+	offset: -300,
+}, {
+	name: 'America/Mexico_City',
+	abbrev: 'CST',
+	offset: -360,
+}, {
+	name: 'America/Phoenix',
+	abbrev: 'MST',
+	offset: -420,
+}, {
+	name: 'America/Los_Angeles',
+	abbrev: 'PST',
+	offset: -480,
+}];
diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts
new file mode 100644
index 0000000000..5251bc2e27
--- /dev/null
+++ b/packages/frontend/src/scripts/touch.ts
@@ -0,0 +1,23 @@
+const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
+
+export let isTouchUsing = false;
+
+export let isScreenTouching = false;
+
+if (isTouchSupported) {
+	window.addEventListener('touchstart', () => {
+		// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
+		// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
+		isTouchUsing = true;
+
+		isScreenTouching = true;
+	}, { passive: true });
+	
+	window.addEventListener('touchend', () => {
+		// 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、
+		// touchendイベントでもtouchstartイベントと同様にtrueにする
+		isTouchUsing = true;
+
+		isScreenTouching = false;
+	}, { passive: true });
+}
diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts
new file mode 100644
index 0000000000..59af584c1b
--- /dev/null
+++ b/packages/frontend/src/scripts/unison-reload.ts
@@ -0,0 +1,15 @@
+// SafariがBroadcastChannel未実装なのでライブラリを使う
+import { BroadcastChannel } from 'broadcast-channel';
+
+export const reloadChannel = new BroadcastChannel<string | null>('reload');
+
+// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
+export function unisonReload(path?: string) {
+	if (path !== undefined) {
+		reloadChannel.postMessage(path);
+		location.href = path;
+	} else {
+		reloadChannel.postMessage(null);
+		location.reload();
+	}
+}
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
new file mode 100644
index 0000000000..9a39652ef5
--- /dev/null
+++ b/packages/frontend/src/scripts/upload.ts
@@ -0,0 +1,137 @@
+import { reactive, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { readAndCompressImage } from 'browser-image-resizer';
+import { getCompressionConfig } from './upload/compress-config';
+import { defaultStore } from '@/store';
+import { apiUrl } from '@/config';
+import { $i } from '@/account';
+import { alert } from '@/os';
+import { i18n } from '@/i18n';
+
+type Uploading = {
+	id: string;
+	name: string;
+	progressMax: number | undefined;
+	progressValue: number | undefined;
+	img: string;
+};
+export const uploads = ref<Uploading[]>([]);
+
+const mimeTypeMap = {
+	'image/webp': 'webp',
+	'image/jpeg': 'jpg',
+	'image/png': 'png',
+} as const;
+
+export function uploadFile(
+	file: File,
+	folder?: any,
+	name?: string,
+	keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
+): Promise<Misskey.entities.DriveFile> {
+	if ($i == null) throw new Error('Not logged in');
+
+	if (folder && typeof folder === 'object') folder = folder.id;
+
+	return new Promise((resolve, reject) => {
+		const id = Math.random().toString();
+
+		const reader = new FileReader();
+		reader.onload = async (): Promise<void> => {
+			const ctx = reactive<Uploading>({
+				id: id,
+				name: name ?? file.name ?? 'untitled',
+				progressMax: undefined,
+				progressValue: undefined,
+				img: window.URL.createObjectURL(file),
+			});
+
+			uploads.value.push(ctx);
+
+			const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
+			let resizedImage: Blob | undefined;
+			if (config) {
+				try {
+					const resized = await readAndCompressImage(file, config);
+					if (resized.size < file.size || file.type === 'image/webp') {
+						// The compression may not always reduce the file size
+						// (and WebP is not browser safe yet)
+						resizedImage = resized;
+					}
+					if (_DEV_) {
+						const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
+						console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
+					}
+
+					ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
+				} catch (err) {
+					console.error('Failed to resize image', err);
+				}
+			}
+
+			const formData = new FormData();
+			formData.append('i', $i.token);
+			formData.append('force', 'true');
+			formData.append('file', resizedImage ?? file);
+			formData.append('name', ctx.name);
+			if (folder) formData.append('folderId', folder);
+
+			const xhr = new XMLHttpRequest();
+			xhr.open('POST', apiUrl + '/drive/files/create', true);
+			xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
+				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
+					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
+					uploads.value = uploads.value.filter(x => x.id !== id);
+
+					if (ev.target?.response) {
+						const res = JSON.parse(ev.target.response);
+						if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: i18n.ts.cannotUploadBecauseInappropriate,
+							});
+						} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: i18n.ts.cannotUploadBecauseNoFreeSpace,
+							});
+						} else {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
+							});
+						}
+					} else {
+						alert({
+							type: 'error',
+							title: 'Failed to upload',
+							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
+						});
+					}
+
+					reject();
+					return;
+				}
+
+				const driveFile = JSON.parse(ev.target.response);
+
+				resolve(driveFile);
+
+				uploads.value = uploads.value.filter(x => x.id !== id);
+			}) as (ev: ProgressEvent<EventTarget>) => any;
+
+			xhr.upload.onprogress = ev => {
+				if (ev.lengthComputable) {
+					ctx.progressMax = ev.total;
+					ctx.progressValue = ev.loaded;
+				}
+			};
+
+			xhr.send(formData);
+		};
+		reader.readAsArrayBuffer(file);
+	});
+}
diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts
new file mode 100644
index 0000000000..793c78ad20
--- /dev/null
+++ b/packages/frontend/src/scripts/upload/compress-config.ts
@@ -0,0 +1,23 @@
+import isAnimated from 'is-file-animated';
+import type { BrowserImageResizerConfig } from 'browser-image-resizer';
+
+const compressTypeMap = {
+	'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/png': { quality: 1, mimeType: 'image/png' },
+	'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/svg+xml': { quality: 1, mimeType: 'image/png' },
+} as const;
+
+export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> {
+	const imgConfig = compressTypeMap[file.type];
+	if (!imgConfig || await isAnimated(file)) {
+		return;
+	}
+
+	return {
+		maxWidth: 2048,
+		maxHeight: 2048,
+		debug: true,
+		...imgConfig,
+	};
+}
diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts
new file mode 100644
index 0000000000..86735de9f0
--- /dev/null
+++ b/packages/frontend/src/scripts/url.ts
@@ -0,0 +1,13 @@
+export function query(obj: Record<string, any>): string {
+	const params = Object.entries(obj)
+		.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
+		.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
+
+	return Object.entries(params)
+		.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
+		.join('&');
+}
+
+export function appendQuery(url: string, query: string): string {
+	return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
+}
diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts
new file mode 100644
index 0000000000..881e5e9ad5
--- /dev/null
+++ b/packages/frontend/src/scripts/use-chart-tooltip.ts
@@ -0,0 +1,54 @@
+import { onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import MkChartTooltip from '@/components/MkChartTooltip.vue';
+
+export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
+	const tooltipShowing = ref(false);
+	const tooltipX = ref(0);
+	const tooltipY = ref(0);
+	const tooltipTitle = ref(null);
+	const tooltipSeries = ref(null);
+	let disposeTooltipComponent;
+
+	os.popup(MkChartTooltip, {
+		showing: tooltipShowing,
+		x: tooltipX,
+		y: tooltipY,
+		title: tooltipTitle,
+		series: tooltipSeries,
+	}, {}).then(({ dispose }) => {
+		disposeTooltipComponent = dispose;
+	});
+
+	onUnmounted(() => {
+		if (disposeTooltipComponent) disposeTooltipComponent();
+	});
+
+	function handler(context) {
+		if (context.tooltip.opacity === 0) {
+			tooltipShowing.value = false;
+			return;
+		}
+
+		tooltipTitle.value = context.tooltip.title[0];
+		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
+			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
+			borderColor: context.tooltip.labelColors[i].borderColor,
+			text: b.lines[0],
+		}));
+
+		const rect = context.chart.canvas.getBoundingClientRect();
+
+		tooltipShowing.value = true;
+		tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
+		if (opts.position === 'top') {
+			tooltipY.value = rect.top + window.pageYOffset;
+		} else if (opts.position === 'middle') {
+			tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
+		}
+	}
+
+	return {
+		handler,
+	};
+}
diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts
new file mode 100644
index 0000000000..201ba417ef
--- /dev/null
+++ b/packages/frontend/src/scripts/use-interval.ts
@@ -0,0 +1,24 @@
+import { onMounted, onUnmounted } from 'vue';
+
+export function useInterval(fn: () => void, interval: number, options: {
+	immediate: boolean;
+	afterMounted: boolean;
+}): void {
+	if (Number.isNaN(interval)) return;
+
+	let intervalId: number | null = null;
+
+	if (options.afterMounted) {
+		onMounted(() => {
+			if (options.immediate) fn();
+			intervalId = window.setInterval(fn, interval);
+		});
+	} else {
+		if (options.immediate) fn();
+		intervalId = window.setInterval(fn, interval);
+	}
+
+	onUnmounted(() => {
+		if (intervalId) window.clearInterval(intervalId);
+	});
+}
diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts
new file mode 100644
index 0000000000..a93b84d1fe
--- /dev/null
+++ b/packages/frontend/src/scripts/use-leave-guard.ts
@@ -0,0 +1,47 @@
+import { inject, onUnmounted, Ref } from 'vue';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+export function useLeaveGuard(enabled: Ref<boolean>) {
+	/* TODO
+	const setLeaveGuard = inject('setLeaveGuard');
+
+	if (setLeaveGuard) {
+		setLeaveGuard(async () => {
+			if (!enabled.value) return false;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return canceled;
+		});
+	} else {
+		onBeforeRouteLeave(async (to, from) => {
+			if (!enabled.value) return true;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return !canceled;
+		});
+	}
+	*/
+
+	/*
+	function onBeforeLeave(ev: BeforeUnloadEvent) {
+		if (enabled.value) {
+			ev.preventDefault();
+			ev.returnValue = '';
+		}
+	}
+
+	window.addEventListener('beforeunload', onBeforeLeave);
+	onUnmounted(() => {
+		window.removeEventListener('beforeunload', onBeforeLeave);
+	});
+	*/
+}
diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts
new file mode 100644
index 0000000000..e6bdb345c4
--- /dev/null
+++ b/packages/frontend/src/scripts/use-note-capture.ts
@@ -0,0 +1,110 @@
+import { onUnmounted, Ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+
+export function useNoteCapture(props: {
+	rootEl: Ref<HTMLElement>;
+	note: Ref<misskey.entities.Note>;
+	isDeletedRef: Ref<boolean>;
+}) {
+	const note = props.note;
+	const connection = $i ? stream : null;
+
+	function onStreamNoteUpdated(noteData): void {
+		const { type, id, body } = noteData;
+
+		if (id !== note.value.id) return;
+
+		switch (type) {
+			case 'reacted': {
+				const reaction = body.reaction;
+
+				if (body.emoji) {
+					const emojis = note.value.emojis || [];
+					if (!emojis.includes(body.emoji)) {
+						note.value.emojis = [...emojis, body.emoji];
+					}
+				}
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = currentCount + 1;
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = reaction;
+				}
+				break;
+			}
+
+			case 'unreacted': {
+				const reaction = body.reaction;
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = null;
+				}
+				break;
+			}
+
+			case 'pollVoted': {
+				const choice = body.choice;
+
+				const choices = [...note.value.poll.choices];
+				choices[choice] = {
+					...choices[choice],
+					votes: choices[choice].votes + 1,
+					...($i && (body.userId === $i.id) ? {
+						isVoted: true,
+					} : {}),
+				};
+
+				note.value.poll.choices = choices;
+				break;
+			}
+
+			case 'deleted': {
+				props.isDeletedRef.value = true;
+				break;
+			}
+		}
+	}
+
+	function capture(withHandler = false): void {
+		if (connection) {
+			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+			connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id });
+			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function decapture(withHandler = false): void {
+		if (connection) {
+			connection.send('un', {
+				id: note.value.id,
+			});
+			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function onStreamConnected() {
+		capture(false);
+	}
+	
+	capture(true);
+	if (connection) {
+		connection.on('_connected_', onStreamConnected);
+	}
+	
+	onUnmounted(() => {
+		decapture(true);
+		if (connection) {
+			connection.off('_connected_', onStreamConnected);
+		}
+	});
+}
diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts
new file mode 100644
index 0000000000..1f6e0fb6ce
--- /dev/null
+++ b/packages/frontend/src/scripts/use-tooltip.ts
@@ -0,0 +1,86 @@
+import { Ref, ref, watch, onUnmounted } from 'vue';
+
+export function useTooltip(
+	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
+	onShow: (showing: Ref<boolean>) => void,
+	delay = 300,
+): void {
+	let isHovering = false;
+
+	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
+	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
+	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
+	let shouldIgnoreMouseover = false;
+
+	let timeoutId: number;
+
+	let changeShowingState: (() => void) | null;
+
+	const open = () => {
+		close();
+		if (!isHovering) return;
+		if (elRef.value == null) return;
+		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
+
+		const showing = ref(true);
+		onShow(showing);
+		changeShowingState = () => {
+			showing.value = false;
+		};
+	};
+
+	const close = () => {
+		if (changeShowingState != null) {
+			changeShowingState();
+			changeShowingState = null;
+		}
+	};
+
+	const onMouseover = () => {
+		if (isHovering) return;
+		if (shouldIgnoreMouseover) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onMouseleave = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		close();
+	};
+
+	const onTouchstart = () => {
+		shouldIgnoreMouseover = true;
+		if (isHovering) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onTouchend = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		close();
+	};
+
+	const stop = watch(elRef, () => {
+		if (elRef.value) {
+			stop();
+			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+			el.addEventListener('mouseover', onMouseover, { passive: true });
+			el.addEventListener('mouseleave', onMouseleave, { passive: true });
+			el.addEventListener('touchstart', onTouchstart, { passive: true });
+			el.addEventListener('touchend', onTouchend, { passive: true });
+			el.addEventListener('click', close, { passive: true });
+		}
+	}, {
+		immediate: true,
+		flush: 'post',
+	});
+
+	onUnmounted(() => {
+		close();
+	});
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
new file mode 100644
index 0000000000..1bedab5fad
--- /dev/null
+++ b/packages/frontend/src/store.ts
@@ -0,0 +1,383 @@
+import { markRaw, ref } from 'vue';
+import { Storage } from './pizzax';
+import { Theme } from './scripts/theme';
+
+interface PostFormAction {
+	title: string,
+	handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
+}
+
+interface UserAction {
+	title: string,
+	handler: (user: UserDetailed) => void;
+}
+
+interface NoteAction {
+	title: string,
+	handler: (note: Note) => void;
+}
+
+interface NoteViewInterruptor {
+	handler: (note: Note) => unknown;
+}
+
+interface NotePostInterruptor {
+	handler: (note: FIXME) => unknown;
+}
+
+export const postFormActions: PostFormAction[] = [];
+export const userActions: UserAction[] = [];
+export const noteActions: NoteAction[] = [];
+export const noteViewInterruptors: NoteViewInterruptor[] = [];
+export const notePostInterruptors: NotePostInterruptor[] = [];
+
+// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
+//       あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
+export const defaultStore = markRaw(new Storage('base', {
+	tutorial: {
+		where: 'account',
+		default: 0,
+	},
+	keepCw: {
+		where: 'account',
+		default: true,
+	},
+	showFullAcct: {
+		where: 'account',
+		default: false,
+	},
+	rememberNoteVisibility: {
+		where: 'account',
+		default: false,
+	},
+	defaultNoteVisibility: {
+		where: 'account',
+		default: 'public',
+	},
+	defaultNoteLocalOnly: {
+		where: 'account',
+		default: false,
+	},
+	uploadFolder: {
+		where: 'account',
+		default: null as string | null,
+	},
+	pastedFileName: {
+		where: 'account',
+		default: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
+	},
+	keepOriginalUploading: {
+		where: 'account',
+		default: false,
+	},
+	memo: {
+		where: 'account',
+		default: null,
+	},
+	reactions: {
+		where: 'account',
+		default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
+	},
+	mutedWords: {
+		where: 'account',
+		default: [],
+	},
+	mutedAds: {
+		where: 'account',
+		default: [] as string[],
+	},
+
+	menu: {
+		where: 'deviceAccount',
+		default: [
+			'notifications',
+			'favorites',
+			'drive',
+			'followRequests',
+			'-',
+			'explore',
+			'announcements',
+			'search',
+			'-',
+			'ui',
+		],
+	},
+	visibility: {
+		where: 'deviceAccount',
+		default: 'public' as 'public' | 'home' | 'followers' | 'specified',
+	},
+	localOnly: {
+		where: 'deviceAccount',
+		default: false,
+	},
+	statusbars: {
+		where: 'deviceAccount',
+		default: [] as {
+			name: string;
+			id: string;
+			type: string;
+			size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
+			black: boolean;
+			props: Record<string, any>;
+		}[],
+	},
+	widgets: {
+		where: 'deviceAccount',
+		default: [] as {
+			name: string;
+			id: string;
+			place: string | null;
+			data: Record<string, any>;
+		}[],
+	},
+	tl: {
+		where: 'deviceAccount',
+		default: {
+			src: 'home' as 'home' | 'local' | 'social' | 'global',
+			arg: null,
+		},
+	},
+
+	overridedDeviceKind: {
+		where: 'device',
+		default: null as null | 'smartphone' | 'tablet' | 'desktop',
+	},
+	serverDisconnectedBehavior: {
+		where: 'device',
+		default: 'quiet' as 'quiet' | 'reload' | 'dialog',
+	},
+	nsfw: {
+		where: 'device',
+		default: 'respect' as 'respect' | 'force' | 'ignore',
+	},
+	animation: {
+		where: 'device',
+		default: true,
+	},
+	animatedMfm: {
+		where: 'device',
+		default: false,
+	},
+	loadRawImages: {
+		where: 'device',
+		default: false,
+	},
+	imageNewTab: {
+		where: 'device',
+		default: false,
+	},
+	disableShowingAnimatedImages: {
+		where: 'device',
+		default: false,
+	},
+	disablePagesScript: {
+		where: 'device',
+		default: false,
+	},
+	emojiStyle: {
+		where: 'device',
+		default: 'twemoji', // twemoji / fluentEmoji / native
+	},
+	disableDrawer: {
+		where: 'device',
+		default: false,
+	},
+	useBlurEffectForModal: {
+		where: 'device',
+		default: true,
+	},
+	useBlurEffect: {
+		where: 'device',
+		default: true,
+	},
+	showFixedPostForm: {
+		where: 'device',
+		default: false,
+	},
+	enableInfiniteScroll: {
+		where: 'device',
+		default: true,
+	},
+	useReactionPickerForContextMenu: {
+		where: 'device',
+		default: false,
+	},
+	showGapBetweenNotesInTimeline: {
+		where: 'device',
+		default: false,
+	},
+	darkMode: {
+		where: 'device',
+		default: false,
+	},
+	instanceTicker: {
+		where: 'device',
+		default: 'remote' as 'none' | 'remote' | 'always',
+	},
+	reactionPickerSize: {
+		where: 'device',
+		default: 1,
+	},
+	reactionPickerWidth: {
+		where: 'device',
+		default: 1,
+	},
+	reactionPickerHeight: {
+		where: 'device',
+		default: 2,
+	},
+	reactionPickerUseDrawerForMobile: {
+		where: 'device',
+		default: true,
+	},
+	recentlyUsedEmojis: {
+		where: 'device',
+		default: [] as string[],
+	},
+	recentlyUsedUsers: {
+		where: 'device',
+		default: [] as string[],
+	},
+	defaultSideView: {
+		where: 'device',
+		default: false,
+	},
+	menuDisplay: {
+		where: 'device',
+		default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top',
+	},
+	reportError: {
+		where: 'device',
+		default: false,
+	},
+	squareAvatars: {
+		where: 'device',
+		default: false,
+	},
+	postFormWithHashtags: {
+		where: 'device',
+		default: false,
+	},
+	postFormHashtags: {
+		where: 'device',
+		default: '',
+	},
+	themeInitial: {
+		where: 'device',
+		default: true,
+	},
+	numberOfPageCache: {
+		where: 'device',
+		default: 5,
+	},
+	aiChanMode: {
+		where: 'device',
+		default: false,
+	},
+}));
+
+// TODO: 他のタブと永続化されたstateを同期
+
+const PREFIX = 'miux:';
+
+type Plugin = {
+	id: string;
+	name: string;
+	active: boolean;
+	configData: Record<string, any>;
+	token: string;
+	ast: any[];
+};
+
+interface Watcher {
+	key: string;
+	callback: (value: unknown) => void;
+}
+
+/**
+ * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
+ */
+import lightTheme from '@/themes/l-light.json5';
+import darkTheme from '@/themes/d-green-lime.json5';
+import { Note, UserDetailed } from 'misskey-js/built/entities';
+
+export class ColdDeviceStorage {
+	public static default = {
+		lightTheme,
+		darkTheme,
+		syncDeviceDarkMode: true,
+		plugins: [] as Plugin[],
+		mediaVolume: 0.5,
+		sound_masterVolume: 0.3,
+		sound_note: { type: 'syuilo/down', volume: 1 },
+		sound_noteMy: { type: 'syuilo/up', volume: 1 },
+		sound_notification: { type: 'syuilo/pope2', volume: 1 },
+		sound_chat: { type: 'syuilo/pope1', volume: 1 },
+		sound_chatBg: { type: 'syuilo/waon', volume: 1 },
+		sound_antenna: { type: 'syuilo/triple', volume: 1 },
+		sound_channel: { type: 'syuilo/square-pico', volume: 1 },
+	};
+
+	public static watchers: Watcher[] = [];
+
+	public static get<T extends keyof typeof ColdDeviceStorage.default>(key: T): typeof ColdDeviceStorage.default[T] {
+		// TODO: indexedDBにする
+		//       ただしその際はnullチェックではなくキー存在チェックにしないとダメ
+		//       (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
+		const value = localStorage.getItem(PREFIX + key);
+		if (value == null) {
+			return ColdDeviceStorage.default[key];
+		} else {
+			return JSON.parse(value);
+		}
+	}
+
+	public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void {
+		// 呼び出し側のバグ等で undefined が来ることがある
+		// undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		if (value === undefined) {
+			console.error(`attempt to store undefined value for key '${key}'`);
+			return;
+		}
+
+		localStorage.setItem(PREFIX + key, JSON.stringify(value));
+
+		for (const watcher of this.watchers) {
+			if (watcher.key === key) watcher.callback(value);
+		}
+	}
+
+	public static watch(key, callback) {
+		this.watchers.push({ key, callback });
+	}
+
+	// TODO: VueのcustomRef使うと良い感じになるかも
+	public static ref<T extends keyof typeof ColdDeviceStorage.default>(key: T) {
+		const v = ColdDeviceStorage.get(key);
+		const r = ref(v);
+		// TODO: このままではwatcherがリークするので開放する方法を考える
+		this.watch(key, v => {
+			r.value = v;
+		});
+		return r;
+	}
+
+	/**
+	 * 特定のキーの、簡易的なgetter/setterを作ります
+	 * 主にvue場で設定コントロールのmodelとして使う用
+	 */
+	public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) {
+		// TODO: VueのcustomRef使うと良い感じになるかも
+		const valueRef = ColdDeviceStorage.ref(key);
+		return {
+			get: () => {
+				return valueRef.value;
+			},
+			set: (value: unknown) => {
+				const val = value;
+				ColdDeviceStorage.set(key, val);
+			},
+		};
+	}
+}
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
new file mode 100644
index 0000000000..dea3459b86
--- /dev/null
+++ b/packages/frontend/src/stream.ts
@@ -0,0 +1,8 @@
+import * as Misskey from 'misskey-js';
+import { markRaw } from 'vue';
+import { $i } from '@/account';
+import { url } from '@/config';
+
+export const stream = markRaw(new Misskey.Stream(url, $i ? {
+	token: $i.token,
+} : null));
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
new file mode 100644
index 0000000000..8b7a846863
--- /dev/null
+++ b/packages/frontend/src/style.scss
@@ -0,0 +1,584 @@
+@charset "utf-8";
+
+:root {
+	--radius: 12px;
+	--marginFull: 16px;
+	--marginHalf: 10px;
+
+	--margin: var(--marginFull);
+
+	@media (max-width: 500px) {
+		--margin: var(--marginHalf);
+	}
+
+	//--ad: rgb(255 169 0 / 10%);
+}
+
+::selection {
+	color: #fff;
+	background-color: var(--accent);
+} 
+
+html {
+	touch-action: manipulation;
+	background-color: var(--bg);
+	background-attachment: fixed;
+	background-size: cover;
+	background-position: center;
+	color: var(--fg);
+	accent-color: var(--accent);
+	overflow: auto;
+	overflow-wrap: break-word;
+	font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+	font-size: 14px;
+	line-height: 1.35;
+	text-size-adjust: 100%;
+	tab-size: 2;
+
+	&, * {
+		scrollbar-color: var(--scrollbarHandle) inherit;
+		scrollbar-width: thin;
+
+		&::-webkit-scrollbar {
+			width: 6px;
+			height: 6px;
+		}
+
+		&::-webkit-scrollbar-track {
+			background: inherit;
+		}
+
+		&::-webkit-scrollbar-thumb {
+			background: var(--scrollbarHandle);
+
+			&:hover {
+				background: var(--scrollbarHandleHover);
+			}
+
+			&:active {
+				background: var(--accent);
+			}
+		}
+	}
+
+	&.f-1 {
+		font-size: 15px;
+	}
+
+	&.f-2 {
+		font-size: 16px;
+	}
+
+	&.f-3 {
+		font-size: 17px;
+	}
+
+	&.useSystemFont {
+		font-family: 'Hiragino Maru Gothic Pro', sans-serif;
+	}
+}
+
+html._themeChanging_ {
+	&, * {
+		transition: background 1s ease, border 1s ease !important;
+	}
+}
+
+html, body {
+	margin: 0;
+	padding: 0;
+	scroll-behavior: smooth;
+}
+
+a {
+	text-decoration: none;
+	cursor: pointer;
+	color: inherit;
+	tap-highlight-color: transparent;
+	-webkit-tap-highlight-color: transparent;
+
+	&:hover {
+		text-decoration: underline;
+	}
+}
+
+textarea, input {
+	tap-highlight-color: transparent;
+	-webkit-tap-highlight-color: transparent;
+}
+
+optgroup, option {
+	background: var(--panel);
+	color: var(--fg);
+}
+
+hr {
+	margin: var(--margin) 0 var(--margin) 0;
+	border: none;
+	height: 1px;
+	background: var(--divider);
+}
+
+.ti {
+	vertical-align: -10%;
+	line-height: 0.9em;
+
+	&:before {
+		font-size: 130%;
+	}
+}
+
+.ti-fw {
+	display: inline-block;
+	text-align: center;
+}
+
+._indicatorCircle {
+	display: inline-block;
+	width: 1em;
+	height: 1em;
+	border-radius: 100%;
+	background: currentColor;
+}
+
+._noSelect {
+	user-select: none;
+	-webkit-user-select: none;
+	-webkit-touch-callout: none;
+}
+
+._ghost {
+	&, * {
+		@extend ._noSelect;
+		pointer-events: none;
+	}
+}
+
+._modalBg {
+	position: fixed;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	background: var(--modalBg);
+	-webkit-backdrop-filter: var(--modalBgFilter);
+	backdrop-filter: var(--modalBgFilter);
+}
+
+._shadow {
+	box-shadow: 0px 4px 32px var(--shadow) !important;
+}
+
+._button {
+	appearance: none;
+	display: inline-block;
+	padding: 0;
+	margin: 0; // for Safari
+	background: none;
+	border: none;
+	cursor: pointer;
+	color: inherit;
+	touch-action: manipulation;
+	tap-highlight-color: transparent;
+	-webkit-tap-highlight-color: transparent;
+	font-size: 1em;
+	font-family: inherit;
+	line-height: inherit;
+	max-width: 100%;
+
+	&, * {
+		@extend ._noSelect;
+	}
+
+	* {
+		pointer-events: none;
+	}
+
+	&:focus-visible {
+		outline: none;
+	}
+
+	&:disabled {
+		opacity: 0.5;
+		cursor: default;
+	}
+}
+
+._buttonPrimary {
+	@extend ._button;
+	color: var(--fgOnAccent);
+	background: var(--accent);
+
+	&:not(:disabled):hover {
+		background: var(--X8);
+	}
+
+	&:not(:disabled):active {
+		background: var(--X9);
+	}
+}
+
+._buttonGradate {
+	@extend ._buttonPrimary;
+	color: var(--fgOnAccent);
+	background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+
+	&:not(:disabled):hover {
+		background: linear-gradient(90deg, var(--X8), var(--X8));
+	}
+
+	&:not(:disabled):active {
+		background: linear-gradient(90deg, var(--X8), var(--X8));
+	}
+}
+
+._help {
+	color: var(--accent);
+	cursor: help
+}
+
+._textButton {
+	@extend ._button;
+	color: var(--accent);
+
+	&:not(:disabled):hover {
+		text-decoration: underline;
+	}
+}
+
+._inputs {
+	display: flex;
+	margin: 32px 0;
+
+	&:first-child {
+		margin-top: 8px;
+	}
+
+	&:last-child {
+		margin-bottom: 8px;
+	}
+
+	> * {
+		flex: 1;
+		margin: 0 !important;
+
+		&:not(:first-child) {
+			margin-left: 8px !important;
+		}
+
+		&:not(:last-child) {
+			margin-right: 8px !important;
+		}
+	}
+}
+
+._panel {
+	background: var(--panel);
+	border-radius: var(--radius);
+	overflow: clip;
+}
+
+._block {
+	@extend ._panel;
+
+	& + ._block {
+		margin-top: var(--margin);
+	}
+}
+
+._gap {
+	margin: var(--margin) 0;
+}
+
+// TODO: 廃止
+._card {
+	@extend ._panel;
+
+	// TODO: _cardTitle に
+	> ._title {
+		margin: 0;
+		padding: 22px 32px;
+		font-size: 1em;
+		border-bottom: solid 1px var(--panelHeaderDivider);
+		font-weight: bold;
+		background: var(--panelHeaderBg);
+		color: var(--panelHeaderFg);
+
+		@media (max-width: 500px) {
+			padding: 16px;
+			font-size: 1em;
+		}
+	}
+
+	// TODO: _cardContent に
+	> ._content {
+		padding: 32px;
+
+		@media (max-width: 500px) {
+			padding: 16px;
+		}
+
+		&._noPad {
+			padding: 0 !important;
+		}
+
+		& + ._content {
+			border-top: solid 0.5px var(--divider);
+		}
+	}
+
+	// TODO: _cardFooter に
+	> ._footer {
+		border-top: solid 0.5px var(--divider);
+		padding: 24px 32px;
+
+		@media (max-width: 500px) {
+			padding: 16px;
+		}
+	}
+}
+
+._borderButton {
+	@extend ._button;
+	display: block;
+	width: 100%;
+	padding: 10px;
+	box-sizing: border-box;
+	text-align: center;
+	border: solid 0.5px var(--divider);
+	border-radius: var(--radius);
+
+	&:active {
+		border-color: var(--accent);
+	}
+}
+
+._popup {
+	background: var(--popup);
+	border-radius: var(--radius);
+	contain: content;
+}
+
+// TODO: 廃止
+._monolithic_ {
+	._section:not(:empty) {
+		box-sizing: border-box;
+		padding: var(--root-margin, 32px);
+	
+		@media (max-width: 500px) {
+			--root-margin: 10px;
+		}
+	
+		& + ._section:not(:empty) {
+			border-top: solid 0.5px var(--divider);
+		}
+	}
+}
+
+._narrow_ ._card {
+	> ._title {
+		padding: 16px;
+		font-size: 1em;
+	}
+
+	> ._content {
+		padding: 16px;
+	}
+
+	> ._footer {
+		padding: 16px;
+	}
+}
+
+._acrylic {
+	background: var(--acrylicPanel);
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+}
+
+._formBlock {
+	margin: 1.5em 0;
+}
+
+._formRoot {
+	> ._formBlock:first-child {
+		margin-top: 0;
+	}
+
+	> ._formBlock:last-child {
+		margin-bottom: 0;
+	}
+}
+
+._formLinksGrid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+	grid-gap: 12px;
+}
+
+._formLinks {
+	> *:not(:last-child) {
+		margin-bottom: 8px;
+	}
+}
+
+._beta {
+	margin-left: 0.7em;
+	font-size: 65%;
+	padding: 2px 3px;
+	color: var(--accent);
+	border: solid 1px var(--accent);
+	border-radius: 4px;
+	vertical-align: top;
+}
+
+._table {
+	> ._row {
+		display: flex;
+
+		&:not(:last-child) {
+			margin-bottom: 16px;
+
+			@media (max-width: 500px) {
+				margin-bottom: 8px;
+			}
+		}
+
+		> ._cell {
+			flex: 1;
+
+			> ._label {
+				font-size: 80%;
+				opacity: 0.7;
+
+				> ._icon {
+					margin-right: 4px;
+					display: none;
+				}
+			}
+		}
+	}
+}
+
+._fullinfo {
+	padding: 64px 32px;
+	text-align: center;
+
+	> img {
+		vertical-align: bottom;
+		height: 128px;
+		margin-bottom: 16px;
+		border-radius: 16px;
+	}
+}
+
+._keyValue {
+	display: flex;
+
+	> * {
+		flex: 1;
+	}
+}
+
+._link {
+	color: var(--link);
+}
+
+._caption {
+	font-size: 0.8em;
+	opacity: 0.7;
+}
+
+._monospace {
+	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
+}
+
+._code {
+	@extend ._monospace;
+	background: #2d2d2d;
+	color: #ccc;
+	font-size: 14px;
+	line-height: 1.5;
+	padding: 5px;
+}
+
+.prism-editor__textarea:focus {
+	outline: none;
+}
+
+._zoom {
+	transition-duration: 0.5s, 0.5s;
+	transition-property: opacity, transform;
+	transition-timing-function: cubic-bezier(0,.5,.5,1);
+}
+
+.zoom-enter-active, .zoom-leave-active {
+	transition: opacity 0.5s, transform 0.5s !important;
+}
+.zoom-enter-from, .zoom-leave-to {
+	opacity: 0;
+	transform: scale(0.9);
+}
+
+@keyframes blink {
+	0% { opacity: 1; transform: scale(1); }
+	30% { opacity: 1; transform: scale(1); }
+	90% { opacity: 0; transform: scale(0.5); }
+}
+
+@keyframes tada {
+	from {
+		transform: scale3d(1, 1, 1);
+	}
+
+	10%,
+	20% {
+		transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg);
+	}
+
+	30%,
+	50%,
+	70%,
+	90% {
+		transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg);
+	}
+
+	40%,
+	60%,
+	80% {
+		transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
+	}
+
+	to {
+		transform: scale3d(1, 1, 1);
+	}
+}
+
+._anime_bounce {
+	will-change: transform;
+  animation: bounce ease 0.7s;
+  animation-iteration-count: 1;
+  transform-origin: 50% 50%;
+}
+._anime_bounce_ready {
+	will-change: transform;
+	transform:  scaleX(0.90) scaleY(0.90) ;
+}
+._anime_bounce_standBy {
+	transition: transform 0.1s ease;
+}
+
+@keyframes bounce{
+  0% {
+    transform:  scaleX(0.90) scaleY(0.90) ;
+  }
+  19% {
+    transform:  scaleX(1.10) scaleY(1.10) ;
+  }
+  48% {
+    transform:  scaleX(0.95) scaleY(0.95) ;
+  }
+  100% {
+    transform:  scaleX(1.00) scaleY(1.00) ;
+  }
+}
diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts
new file mode 100644
index 0000000000..fdc92ed793
--- /dev/null
+++ b/packages/frontend/src/theme-store.ts
@@ -0,0 +1,34 @@
+import { api } from '@/os';
+import { $i } from '@/account';
+import { Theme } from './scripts/theme';
+
+const lsCacheKey = $i ? `themes:${$i.id}` : '';
+
+export function getThemes(): Theme[] {
+	return JSON.parse(localStorage.getItem(lsCacheKey) || '[]');
+}
+
+export async function fetchThemes(): Promise<void> {
+	if ($i == null) return;
+
+	try {
+		const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' });
+		localStorage.setItem(lsCacheKey, JSON.stringify(themes));
+	} catch (err) {
+		if (err.code === 'NO_SUCH_KEY') return;
+		throw err;
+	}
+}
+
+export async function addTheme(theme: Theme): Promise<void> {
+	await fetchThemes();
+	const themes = getThemes().concat(theme);
+	await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
+	localStorage.setItem(lsCacheKey, JSON.stringify(themes));
+}
+
+export async function removeTheme(theme: Theme): Promise<void> {
+	const themes = getThemes().filter(t => t.id !== theme.id);
+	await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
+	localStorage.setItem(lsCacheKey, JSON.stringify(themes));
+}
diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5
new file mode 100644
index 0000000000..88ec8a5459
--- /dev/null
+++ b/packages/frontend/src/themes/_dark.json5
@@ -0,0 +1,99 @@
+// ダークテーマのベーステーマ
+// このテーマが直接使われることは無い
+{
+	id: 'dark',
+
+	name: 'Dark',
+	author: 'syuilo',
+	desc: 'Default dark theme',
+	kind: 'dark',
+
+	props: {
+		accent: '#86b300',
+		accentDarken: ':darken<10<@accent',
+		accentLighten: ':lighten<10<@accent',
+		accentedBg: ':alpha<0.15<@accent',
+		focus: ':alpha<0.3<@accent',
+		bg: '#000',
+		acrylicBg: ':alpha<0.5<@bg',
+		fg: '#dadada',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		fgHighlighted: ':lighten<3<@fg',
+		fgOnAccent: '#fff',
+		divider: 'rgba(255, 255, 255, 0.1)',
+		indicator: '@accent',
+		panel: ':lighten<3<@bg',
+		panelHighlight: ':lighten<3<@panel',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		panelBorder: '" solid 1px var(--divider)',
+		acrylicPanel: ':alpha<0.5<@panel',
+		windowHeader: ':alpha<0.85<@panel',
+		popup: ':lighten<3<@panel',
+		shadow: 'rgba(0, 0, 0, 0.3)',
+		header: ':alpha<0.7<@panel',
+		navBg: '@panel',
+		navFg: '@fg',
+		navHoverFg: ':lighten<17<@fg',
+		navActive: '@accent',
+		navIndicator: '@indicator',
+		link: '#44a4c1',
+		hashtag: '#ff9156',
+		mention: '@accent',
+		mentionMe: '@mention',
+		renote: '#229e82',
+		modalBg: 'rgba(0, 0, 0, 0.5)',
+		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
+		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
+		dateLabelFg: '@fg',
+		infoBg: '#253142',
+		infoFg: '#fff',
+		infoWarnBg: '#42321c',
+		infoWarnFg: '#ffbd3e',
+		switchBg: 'rgba(255, 255, 255, 0.15)',
+		cwBg: '#687390',
+		cwFg: '#393f4f',
+		cwHoverBg: '#707b97',
+		buttonBg: 'rgba(255, 255, 255, 0.05)',
+		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<20<@accent',
+		swutchOffBg: 'rgba(255, 255, 255, 0.1)',
+		swutchOffFg: '@fg',
+		swutchOnBg: '@accentedBg',
+		swutchOnFg: '@accent',
+		inputBorder: 'rgba(255, 255, 255, 0.1)',
+		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
+		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
+		driveFolderBg: ':alpha<0.3<@accent',
+		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
+		badge: '#31b1ce',
+		messageBg: '@bg',
+		success: '#86b300',
+		error: '#ec4137',
+		warn: '#ecb637',
+		codeString: '#ffb675',
+		codeNumber: '#cfff9e',
+		codeBoolean: '#c59eff',
+		deckDivider: '#000',
+		htmlThemeColor: '@bg',
+		X2: ':darken<2<@panel',
+		X3: 'rgba(255, 255, 255, 0.05)',
+		X4: 'rgba(255, 255, 255, 0.1)',
+		X5: 'rgba(255, 255, 255, 0.05)',
+		X6: 'rgba(255, 255, 255, 0.15)',
+		X7: 'rgba(255, 255, 255, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.3)',
+		X12: 'rgba(255, 255, 255, 0.1)',
+		X13: 'rgba(255, 255, 255, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+	},
+}
diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5
new file mode 100644
index 0000000000..bad1291c83
--- /dev/null
+++ b/packages/frontend/src/themes/_light.json5
@@ -0,0 +1,99 @@
+// ライトテーマのベーステーマ
+// このテーマが直接使われることは無い
+{
+	id: 'light',
+
+	name: 'Light',
+	author: 'syuilo',
+	desc: 'Default light theme',
+	kind: 'light',
+
+	props: {
+		accent: '#86b300',
+		accentDarken: ':darken<10<@accent',
+		accentLighten: ':lighten<10<@accent',
+		accentedBg: ':alpha<0.15<@accent',
+		focus: ':alpha<0.3<@accent',
+		bg: '#fff',
+		acrylicBg: ':alpha<0.5<@bg',
+		fg: '#5f5f5f',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		fgHighlighted: ':darken<3<@fg',
+		fgOnAccent: '#fff',
+		divider: 'rgba(0, 0, 0, 0.1)',
+		indicator: '@accent',
+		panel: ':lighten<3<@bg',
+		panelHighlight: ':darken<3<@panel',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		panelBorder: '" solid 1px var(--divider)',
+		acrylicPanel: ':alpha<0.5<@panel',
+		windowHeader: ':alpha<0.85<@panel',
+		popup: ':lighten<3<@panel',
+		shadow: 'rgba(0, 0, 0, 0.1)',
+		header: ':alpha<0.7<@panel',
+		navBg: '@panel',
+		navFg: '@fg',
+		navHoverFg: ':darken<17<@fg',
+		navActive: '@accent',
+		navIndicator: '@indicator',
+		link: '#44a4c1',
+		hashtag: '#ff9156',
+		mention: '@accent',
+		mentionMe: '@mention',
+		renote: '#229e82',
+		modalBg: 'rgba(0, 0, 0, 0.3)',
+		scrollbarHandle: 'rgba(0, 0, 0, 0.2)',
+		scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)',
+		dateLabelFg: '@fg',
+		infoBg: '#e5f5ff',
+		infoFg: '#72818a',
+		infoWarnBg: '#fff0db',
+		infoWarnFg: '#8f6e31',
+		switchBg: 'rgba(0, 0, 0, 0.15)',
+		cwBg: '#b1b9c1',
+		cwFg: '#fff',
+		cwHoverBg: '#bbc4ce',
+		buttonBg: 'rgba(0, 0, 0, 0.05)',
+		buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<20<@accent',
+		swutchOffBg: 'rgba(0, 0, 0, 0.1)',
+		swutchOffFg: '@panel',
+		swutchOnBg: '@accent',
+		swutchOnFg: '@fgOnAccent',
+		inputBorder: 'rgba(0, 0, 0, 0.1)',
+		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
+		listItemHoverBg: 'rgba(0, 0, 0, 0.03)',
+		driveFolderBg: ':alpha<0.3<@accent',
+		wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
+		badge: '#31b1ce',
+		messageBg: '@bg',
+		success: '#86b300',
+		error: '#ec4137',
+		warn: '#ecb637',
+		codeString: '#b98710',
+		codeNumber: '#0fbbbb',
+		codeBoolean: '#62b70c',
+		deckDivider: ':darken<3<@bg',
+		htmlThemeColor: '@bg',
+		X2: ':darken<2<@panel',
+		X3: 'rgba(0, 0, 0, 0.05)',
+		X4: 'rgba(0, 0, 0, 0.1)',
+		X5: 'rgba(0, 0, 0, 0.05)',
+		X6: 'rgba(0, 0, 0, 0.25)',
+		X7: 'rgba(0, 0, 0, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.1)',
+		X12: 'rgba(0, 0, 0, 0.1)',
+		X13: 'rgba(0, 0, 0, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+	},
+}
diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend/src/themes/d-astro.json5
new file mode 100644
index 0000000000..c6a927ec3a
--- /dev/null
+++ b/packages/frontend/src/themes/d-astro.json5
@@ -0,0 +1,78 @@
+{
+	id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea',
+	base: 'dark',
+	name: 'Mi Astro Dark',
+	author: 'syuilo',
+	props: {
+		bg: '#232125',
+		fg: '#efdab9',
+		cwBg: '#687390',
+		cwFg: '#393f4f',
+		link: '#78b0a0',
+		warn: '#ecb637',
+		badge: '#31b1ce',
+		error: '#ec4137',
+		focus: ':alpha<0.3<@accent',
+		navBg: '@panel',
+		navFg: '@fg',
+		panel: '#2a272b',
+		accent: '#81c08b',
+		header: ':alpha<0.7<@bg',
+		infoBg: '#253142',
+		infoFg: '#fff',
+		renote: '#659CC8',
+		shadow: 'rgba(0, 0, 0, 0.3)',
+		divider: 'rgba(255, 255, 255, 0.1)',
+		hashtag: '#ff9156',
+		mention: '#ffd152',
+		modalBg: 'rgba(0, 0, 0, 0.5)',
+		success: '#86b300',
+		buttonBg: 'rgba(255, 255, 255, 0.05)',
+		acrylicBg: ':alpha<0.5<@bg',
+		cwHoverBg: '#707b97',
+		indicator: '@accent',
+		mentionMe: '#fb5d38',
+		messageBg: '@bg',
+		navActive: '@accent',
+		infoWarnBg: '#42321c',
+		infoWarnFg: '#ffbd3e',
+		navHoverFg: ':lighten<17<@fg',
+		dateLabelFg: '@fg',
+		inputBorder: 'rgba(255, 255, 255, 0.1)',
+		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
+		panelBorder: '" solid 1px var(--divider)',
+		accentDarken: ':darken<10<@accent',
+		acrylicPanel: ':alpha<0.5<@panel',
+		navIndicator: '@accent',
+		accentLighten: ':lighten<10<@accent',
+		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<-20<@accent',
+		driveFolderBg: ':alpha<0.3<@accent',
+		fgHighlighted: ':lighten<3<@fg',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		htmlThemeColor: '@bg',
+		panelHighlight: ':lighten<3<@panel',
+		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
+		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
+		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
+		X2: ':darken<2<@panel',
+		X3: 'rgba(255, 255, 255, 0.05)',
+		X4: 'rgba(255, 255, 255, 0.1)',
+		X5: 'rgba(255, 255, 255, 0.05)',
+		X6: 'rgba(255, 255, 255, 0.15)',
+		X7: 'rgba(255, 255, 255, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.3)',
+		X12: 'rgba(255, 255, 255, 0.1)',
+		X13: 'rgba(255, 255, 255, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+	},
+}
diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5
new file mode 100644
index 0000000000..c03b95e2d7
--- /dev/null
+++ b/packages/frontend/src/themes/d-botanical.json5
@@ -0,0 +1,26 @@
+{
+	id: '504debaf-4912-6a4c-5059-1db08a76b737',
+
+	name: 'Mi Botanical Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: 'rgb(148, 179, 0)',
+		bg: 'rgb(37, 38, 36)',
+		fg: 'rgb(216, 212, 199)',
+		fgHighlighted: '#fff',
+		divider: 'rgba(255, 255, 255, 0.14)',
+		panel: 'rgb(47, 47, 44)',
+		panelHeaderBg: '@panel',
+		panelHeaderDivider: '@divider',
+		header: ':alpha<0.7<@panel',
+		navBg: '#363636',
+		renote: '@accent',
+		mention: 'rgb(212, 153, 76)',
+		mentionMe: 'rgb(212, 210, 76)',
+		hashtag: '#5bcbb0',
+		link: '@accent',
+	},
+}
diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend/src/themes/d-cherry.json5
new file mode 100644
index 0000000000..a7e1ad1c80
--- /dev/null
+++ b/packages/frontend/src/themes/d-cherry.json5
@@ -0,0 +1,20 @@
+{
+	id: '679b3b87-a4e9-4789-8696-b56c15cc33b0',
+
+	name: 'Mi Cherry Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: 'rgb(255, 89, 117)',
+		bg: 'rgb(28, 28, 37)',
+		fg: 'rgb(236, 239, 244)',
+		panel: 'rgb(35, 35, 47)',
+		renote: '@accent',
+		link: '@accent',
+		mention: '@accent',
+		hashtag: '@accent',
+		divider: 'rgb(63, 63, 80)',
+	},
+}
diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5
new file mode 100644
index 0000000000..d24ce4df69
--- /dev/null
+++ b/packages/frontend/src/themes/d-dark.json5
@@ -0,0 +1,26 @@
+{
+	id: '8050783a-7f63-445a-b270-36d0f6ba1677',
+
+	name: 'Mi Dark',
+	author: 'syuilo',
+	desc: 'Default light theme',
+
+	base: 'dark',
+
+	props: {
+		bg: '#232323',
+		fg: 'rgb(199, 209, 216)',
+		fgHighlighted: '#fff',
+		divider: 'rgba(255, 255, 255, 0.14)',
+		panel: '#2d2d2d',
+		panelHeaderBg: '@panel',
+		panelHeaderDivider: '@divider',
+		header: ':alpha<0.7<@panel',
+		navBg: '#363636',
+		renote: '@accent',
+		mention: '#da6d35',
+		mentionMe: '#d44c4c',
+		hashtag: '#4cb8d4',
+		link: '@accent',
+	},
+}
diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5
new file mode 100644
index 0000000000..b6fa1ab0c1
--- /dev/null
+++ b/packages/frontend/src/themes/d-future.json5
@@ -0,0 +1,27 @@
+{
+	id: '32a637ef-b47a-4775-bb7b-bacbb823f865',
+
+	name: 'Mi Future Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: '#63e2b7',
+		bg: '#101014',
+		fg: '#D5D5D6',
+		fgHighlighted: '#fff',
+		fgOnAccent: '#000',
+		divider: 'rgba(255, 255, 255, 0.1)',
+		panel: '#18181c',
+		panelHeaderBg: '@panel',
+		panelHeaderDivider: '@divider',
+		renote: '@accent',
+		mention: '#f2c97d',
+		mentionMe: '@accent',
+		hashtag: '#70c0e8',
+		link: '#e88080',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':saturate<30<:hue<30<@accent',
+	},
+}
diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5
new file mode 100644
index 0000000000..a6983b9ac2
--- /dev/null
+++ b/packages/frontend/src/themes/d-green-lime.json5
@@ -0,0 +1,24 @@
+{
+	id: '02816013-8107-440f-877e-865083ffe194',
+
+	name: 'Mi Green+Lime Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: '#b4e900',
+		bg: '#0C1210',
+		fg: '#dee7e4',
+		fgHighlighted: '#fff',
+		fgOnAccent: '#192320',
+		divider: '#e7fffb24',
+		panel: '#192320',
+		panelHeaderBg: '@panel',
+		panelHeaderDivider: '@divider',
+		popup: '#293330',
+		renote: '@accent',
+		mentionMe: '#ffaa00',
+		link: '#24d7ce',
+	},
+}
diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5
new file mode 100644
index 0000000000..62adc39e29
--- /dev/null
+++ b/packages/frontend/src/themes/d-green-orange.json5
@@ -0,0 +1,24 @@
+{
+	id: 'dc489603-27b5-424a-9b25-1ff6aec9824a',
+
+	name: 'Mi Green+Orange Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: '#e97f00',
+		bg: '#0C1210',
+		fg: '#dee7e4',
+		fgHighlighted: '#fff',
+		fgOnAccent: '#192320',
+		divider: '#e7fffb24',
+		panel: '#192320',
+		panelHeaderBg: '@panel',
+		panelHeaderDivider: '@divider',
+		popup: '#293330',
+		renote: '@accent',
+		mentionMe: '#b4e900',
+		link: '#24d7ce',
+	},
+}
diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend/src/themes/d-ice.json5
new file mode 100644
index 0000000000..179b060dcf
--- /dev/null
+++ b/packages/frontend/src/themes/d-ice.json5
@@ -0,0 +1,13 @@
+{
+	id: '66e7e5a9-cd43-42cd-837d-12f47841fa34',
+
+	name: 'Mi Ice Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: '#47BFE8',
+		bg: '#212526',
+	},
+}
diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend/src/themes/d-persimmon.json5
new file mode 100644
index 0000000000..e36265ff10
--- /dev/null
+++ b/packages/frontend/src/themes/d-persimmon.json5
@@ -0,0 +1,25 @@
+{
+	id: 'c503d768-7c70-4db2-a4e6-08264304bc8d',
+
+	name: 'Mi Persimmon Dark',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	props: {
+		accent: 'rgb(206, 102, 65)',
+		bg: 'rgb(31, 33, 31)',
+		fg: '#cdd8c7',
+		fgHighlighted: '#fff',
+		divider: 'rgba(255, 255, 255, 0.14)',
+		panel: 'rgb(41, 43, 41)',
+		infoFg: '@fg',
+		infoBg: '#333c3b',
+		navBg: '#141714',
+		renote: '@accent',
+		mention: '@accent',
+		mentionMe: '#de6161',
+		hashtag: '#68bad0',
+		link: '#a1c758',
+	},
+}
diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend/src/themes/d-u0.json5
new file mode 100644
index 0000000000..b270f809ac
--- /dev/null
+++ b/packages/frontend/src/themes/d-u0.json5
@@ -0,0 +1,88 @@
+{
+	id: '7a5bc13b-df8f-4d44-8e94-4452f0c634bb',
+	base: 'dark',
+	name: 'Mi U0 Dark',
+	props: {
+		X2: ':darken<2<@panel',
+		X3: 'rgba(255, 255, 255, 0.05)',
+		X4: 'rgba(255, 255, 255, 0.1)',
+		X5: 'rgba(255, 255, 255, 0.05)',
+		X6: 'rgba(255, 255, 255, 0.15)',
+		X7: 'rgba(255, 255, 255, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		bg: '#172426',
+		fg: '#dadada',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.3)',
+		X12: 'rgba(255, 255, 255, 0.1)',
+		X13: 'rgba(255, 255, 255, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+		cwBg: '#687390',
+		cwFg: '#393f4f',
+		link: '@accent',
+		warn: '#ecb637',
+		badge: '#31b1ce',
+		error: '#ec4137',
+		focus: ':alpha<0.3<@accent',
+		navBg: '@panel',
+		navFg: '@fg',
+		panel: ':lighten<3<@bg',
+		popup: ':lighten<3<@panel',
+		accent: '#00a497',
+		header: ':alpha<0.7<@panel',
+		infoBg: '#253142',
+		infoFg: '#fff',
+		renote: '@accent',
+		shadow: 'rgba(0, 0, 0, 0.3)',
+		divider: 'rgba(255, 255, 255, 0.1)',
+		hashtag: '#e6b422',
+		mention: '@accent',
+		modalBg: 'rgba(0, 0, 0, 0.5)',
+		success: '#86b300',
+		buttonBg: 'rgba(255, 255, 255, 0.05)',
+		switchBg: 'rgba(255, 255, 255, 0.15)',
+		acrylicBg: ':alpha<0.5<@bg',
+		cwHoverBg: '#707b97',
+		indicator: '@accent',
+		mentionMe: '@mention',
+		messageBg: '@bg',
+		navActive: '@accent',
+		accentedBg: ':alpha<0.15<@accent',
+		codeNumber: '#cfff9e',
+		codeString: '#ffb675',
+		fgOnAccent: '#fff',
+		infoWarnBg: '#42321c',
+		infoWarnFg: '#ffbd3e',
+		navHoverFg: ':lighten<17<@fg',
+		codeBoolean: '#c59eff',
+		dateLabelFg: '@fg',
+		inputBorder: 'rgba(255, 255, 255, 0.1)',
+		panelBorder: '" solid 1px var(--divider)',
+		accentDarken: ':darken<10<@accent',
+		acrylicPanel: ':alpha<0.5<@panel',
+		navIndicator: '@indicator',
+		accentLighten: ':lighten<10<@accent',
+		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
+		driveFolderBg: ':alpha<0.3<@accent',
+		fgHighlighted: ':lighten<3<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<20<@accent',
+		htmlThemeColor: '@bg',
+		panelHighlight: ':lighten<3<@panel',
+		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
+		scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
+		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
+		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
+		deckDivider: '#142022',
+	},
+}
diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend/src/themes/l-apricot.json5
new file mode 100644
index 0000000000..1ed5525575
--- /dev/null
+++ b/packages/frontend/src/themes/l-apricot.json5
@@ -0,0 +1,22 @@
+{
+	id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b',
+
+	name: 'Mi Apricot Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		accent: 'rgb(234, 154, 82)',
+		bg: '#e6e5e2',
+		fg: 'rgb(149, 143, 139)',
+		panel: '#EEECE8',
+		renote: '@accent',
+		link: '@accent',
+		mention: '@accent',
+		hashtag: '@accent',
+		inputBorder: 'rgba(0, 0, 0, 0.1)',
+		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
+		infoBg: 'rgb(226, 235, 241)',
+	},
+}
diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend/src/themes/l-cherry.json5
new file mode 100644
index 0000000000..5ad240241e
--- /dev/null
+++ b/packages/frontend/src/themes/l-cherry.json5
@@ -0,0 +1,21 @@
+{
+	id: 'ac168876-f737-4074-a3fc-a370c732ef48',
+
+	name: 'Mi Cherry Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		accent: 'rgb(219, 96, 114)',
+		bg: 'rgb(254, 248, 249)',
+		fg: 'rgb(152, 13, 26)',
+		panel: 'rgb(255, 255, 255)',
+		renote: '@accent',
+		link: 'rgb(156, 187, 5)',
+		mention: '@accent',
+		hashtag: '@accent',
+		divider: 'rgba(134, 51, 51, 0.1)',
+		inputBorderHover: 'rgb(238, 221, 222)',
+	},
+}
diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend/src/themes/l-coffee.json5
new file mode 100644
index 0000000000..fbcd4fa9ef
--- /dev/null
+++ b/packages/frontend/src/themes/l-coffee.json5
@@ -0,0 +1,21 @@
+{
+	id: '6ed80faa-74f0-42c2-98e4-a64d9e138eab',
+
+	name: 'Mi Coffee Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		accent: '#9f8989',
+		bg: '#f5f3f3',
+		fg: '#7f6666',
+		panel: '#fff',
+		divider: 'rgba(87, 68, 68, 0.1)',
+		renote: 'rgb(160, 172, 125)',
+		link: 'rgb(137, 151, 159)',
+		mention: '@accent',
+		mentionMe: 'rgb(170, 149, 98)',
+		hashtag: '@accent',
+	},
+}
diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend/src/themes/l-light.json5
new file mode 100644
index 0000000000..248355c945
--- /dev/null
+++ b/packages/frontend/src/themes/l-light.json5
@@ -0,0 +1,20 @@
+{
+	id: '4eea646f-7afa-4645-83e9-83af0333cd37',
+
+	name: 'Mi Light',
+	author: 'syuilo',
+	desc: 'Default light theme',
+
+	base: 'light',
+
+	props: {
+		bg: '#f9f9f9',
+		fg: '#676767',
+		divider: '#e8e8e8',
+		header: ':alpha<0.7<@panel',
+		navBg: '#fff',
+		panel: '#fff',
+		panelHeaderDivider: '@divider',
+		mentionMe: 'rgb(0, 179, 70)',
+	},
+}
diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend/src/themes/l-rainy.json5
new file mode 100644
index 0000000000..283dd74c6c
--- /dev/null
+++ b/packages/frontend/src/themes/l-rainy.json5
@@ -0,0 +1,21 @@
+{
+	id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96',
+
+	name: 'Mi Rainy Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		accent: '#5db0da',
+		bg: 'rgb(246 248 249)',
+		fg: '#636b71',
+		panel: '#fff',
+		divider: 'rgb(230 233 234)',
+		panelHeaderDivider: '@divider',
+		renote: '@accent',
+		link: '@accent',
+		mention: '@accent',
+		hashtag: '@accent',
+	},
+}
diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5
new file mode 100644
index 0000000000..5846927d65
--- /dev/null
+++ b/packages/frontend/src/themes/l-sushi.json5
@@ -0,0 +1,18 @@
+{
+	id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c',
+
+	name: 'Mi Sushi Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		accent: '#e36749',
+		bg: '#f0eee9',
+		fg: '#5f5f5f',
+		renote: '@accent',
+		link: '@accent',
+		mention: '@accent',
+		hashtag: '#229e82',
+	},
+}
diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend/src/themes/l-u0.json5
new file mode 100644
index 0000000000..03b114ba39
--- /dev/null
+++ b/packages/frontend/src/themes/l-u0.json5
@@ -0,0 +1,87 @@
+{
+	id: 'e2c940b5-6e9a-4c03-b738-261c720c426d',
+	base: 'light',
+	name: 'Mi U0 Light',
+	props: {
+		X2: ':darken<2<@panel',
+		X3: 'rgba(255, 255, 255, 0.05)',
+		X4: 'rgba(255, 255, 255, 0.1)',
+		X5: 'rgba(255, 255, 255, 0.05)',
+		X6: 'rgba(255, 255, 255, 0.15)',
+		X7: 'rgba(255, 255, 255, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		bg: '#e7e7eb',
+		fg: '#5f5f5f',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.3)',
+		X12: 'rgba(255, 255, 255, 0.1)',
+		X13: 'rgba(255, 255, 255, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+		cwBg: '#687390',
+		cwFg: '#393f4f',
+		link: '@accent',
+		warn: '#ecb637',
+		badge: '#31b1ce',
+		error: '#ec4137',
+		focus: ':alpha<0.3<@accent',
+		navBg: '@panel',
+		navFg: '@fg',
+		panel: ':lighten<3<@bg',
+		popup: ':lighten<3<@panel',
+		accent: '#478384',
+		header: ':alpha<0.7<@panel',
+		infoBg: '#253142',
+		infoFg: '#fff',
+		renote: '@accent',
+		shadow: 'rgba(0, 0, 0, 0.3)',
+		divider: '#4646461a',
+		hashtag: '#1f3134',
+		mention: '@accent',
+		modalBg: 'rgba(0, 0, 0, 0.5)',
+		success: '#86b300',
+		buttonBg: '#0000000d',
+		switchBg: 'rgba(255, 255, 255, 0.15)',
+		acrylicBg: ':alpha<0.5<@bg',
+		cwHoverBg: '#707b97',
+		indicator: '@accent',
+		mentionMe: '@mention',
+		messageBg: '@bg',
+		navActive: '@accent',
+		accentedBg: ':alpha<0.15<@accent',
+		codeNumber: '#cfff9e',
+		codeString: '#ffb675',
+		fgOnAccent: '#fff',
+		infoWarnBg: '#42321c',
+		infoWarnFg: '#ffbd3e',
+		navHoverFg: ':lighten<17<@fg',
+		codeBoolean: '#c59eff',
+		dateLabelFg: '@fg',
+		inputBorder: 'rgba(255, 255, 255, 0.1)',
+		panelBorder: '" solid 1px var(--divider)',
+		accentDarken: ':darken<10<@accent',
+		acrylicPanel: ':alpha<0.5<@panel',
+		navIndicator: '@indicator',
+		accentLighten: ':lighten<10<@accent',
+		buttonHoverBg: '#0000001a',
+		driveFolderBg: ':alpha<0.3<@accent',
+		fgHighlighted: ':lighten<3<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		buttonGradateA: '@accent',
+		buttonGradateB: ':hue<20<@accent',
+		htmlThemeColor: '@bg',
+		panelHighlight: ':lighten<3<@panel',
+		listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
+		scrollbarHandle: '#74747433',
+		inputBorderHover: 'rgba(255, 255, 255, 0.2)',
+		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
+		scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
+	},
+}
diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend/src/themes/l-vivid.json5
new file mode 100644
index 0000000000..b3c08f38ae
--- /dev/null
+++ b/packages/frontend/src/themes/l-vivid.json5
@@ -0,0 +1,82 @@
+{
+	id: '6128c2a9-5c54-43fe-a47d-17942356470b',
+
+	name: 'Mi Vivid Light',
+	author: 'syuilo',
+
+	base: 'light',
+
+	props: {
+		bg: '#fafafa',
+		fg: '#444',
+		cwBg: '#b1b9c1',
+		cwFg: '#fff',
+		link: '#ff9400',
+		warn: '#ecb637',
+		badge: '#31b1ce',
+		error: '#ec4137',
+		focus: ':alpha<0.3<@accent',
+		navBg: '@panel',
+		navFg: '@fg',
+		panel: '#fff',
+		accent: '#008cff',
+		header: ':alpha<0.7<@panel',
+		infoBg: '#e5f5ff',
+		infoFg: '#72818a',
+		renote: '@accent',
+		shadow: 'rgba(0, 0, 0, 0.1)',
+		divider: 'rgba(0, 0, 0, 0.08)',
+		hashtag: '#92d400',
+		mention: '@accent',
+		modalBg: 'rgba(0, 0, 0, 0.3)',
+		success: '#86b300',
+		buttonBg: 'rgba(0, 0, 0, 0.05)',
+		acrylicBg: ':alpha<0.5<@bg',
+		cwHoverBg: '#bbc4ce',
+		indicator: '@accent',
+		mentionMe: '@mention',
+		messageBg: '@bg',
+		navActive: '@accent',
+		infoWarnBg: '#fff0db',
+		infoWarnFg: '#8f6e31',
+		navHoverFg: ':darken<17<@fg',
+		dateLabelFg: '@fg',
+		inputBorder: 'rgba(0, 0, 0, 0.1)',
+		inputBorderHover: 'rgba(0, 0, 0, 0.2)',
+		panelBorder: '" solid 1px var(--divider)',
+		accentDarken: ':darken<10<@accent',
+		acrylicPanel: ':alpha<0.5<@panel',
+		navIndicator: '@accent',
+		accentLighten: ':lighten<10<@accent',
+		buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
+		driveFolderBg: ':alpha<0.3<@accent',
+		fgHighlighted: ':darken<3<@fg',
+		fgTransparent: ':alpha<0.5<@fg',
+		panelHeaderBg: ':lighten<3<@panel',
+		panelHeaderFg: '@fg',
+		htmlThemeColor: '@bg',
+		panelHighlight: ':darken<3<@panel',
+		listItemHoverBg: 'rgba(0, 0, 0, 0.03)',
+		scrollbarHandle: 'rgba(0, 0, 0, 0.2)',
+		wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
+		fgTransparentWeak: ':alpha<0.75<@fg',
+		panelHeaderDivider: '@divider',
+		scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)',
+		X2: ':darken<2<@panel',
+		X3: 'rgba(0, 0, 0, 0.05)',
+		X4: 'rgba(0, 0, 0, 0.1)',
+		X5: 'rgba(0, 0, 0, 0.05)',
+		X6: 'rgba(0, 0, 0, 0.25)',
+		X7: 'rgba(0, 0, 0, 0.05)',
+		X8: ':lighten<5<@accent',
+		X9: ':darken<5<@accent',
+		X10: ':alpha<0.4<@accent',
+		X11: 'rgba(0, 0, 0, 0.1)',
+		X12: 'rgba(0, 0, 0, 0.1)',
+		X13: 'rgba(0, 0, 0, 0.15)',
+		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
+		X16: ':alpha<0.7<@panel',
+		X17: ':alpha<0.8<@bg',
+	},
+}
diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts
new file mode 100644
index 0000000000..972f6db214
--- /dev/null
+++ b/packages/frontend/src/types/menu.ts
@@ -0,0 +1,21 @@
+import * as Misskey from 'misskey-js';
+import { Ref } from 'vue';
+
+export type MenuAction = (ev: MouseEvent) => void;
+
+export type MenuDivider = null;
+export type MenuNull = undefined;
+export type MenuLabel = { type: 'label', text: string };
+export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
+export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
+export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
+export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean };
+export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction };
+export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] };
+
+export type MenuPending = { type: 'pending' };
+
+type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
+type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>;
+export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
+export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent;
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
new file mode 100644
index 0000000000..7f3fc0e4af
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -0,0 +1,139 @@
+<template>
+<component
+	:is="popup.component"
+	v-for="popup in popups"
+	:key="popup.id"
+	v-bind="popup.props"
+	v-on="popup.events"
+/>
+
+<XUpload v-if="uploads.length > 0"/>
+
+<XStreamIndicator/>
+
+<div v-if="pendingApiRequestsCount > 0" id="wait"></div>
+
+<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
+
+<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { swInject } from './sw-inject';
+import { popup, popups, pendingApiRequestsCount } from '@/os';
+import { uploads } from '@/scripts/upload';
+import * as sound from '@/scripts/sound';
+import { $i } from '@/account';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
+const XUpload = defineAsyncComponent(() => import('./upload.vue'));
+
+const dev = _DEV_;
+
+const onNotification = notification => {
+	if ($i.mutingNotificationTypes.includes(notification.type)) return;
+
+	if (document.visibilityState === 'visible') {
+		stream.send('readNotification', {
+			id: notification.id,
+		});
+
+		popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), {
+			notification,
+		}, {}, 'closed');
+	}
+
+	sound.play('notification');
+};
+
+if ($i) {
+	const connection = stream.useChannel('main', null, 'UI');
+	connection.on('notification', onNotification);
+
+	//#region Listen message from SW
+	if ('serviceWorker' in navigator) {
+		swInject();
+	}
+}
+</script>
+
+<style lang="scss">
+@keyframes dev-ticker-blink {
+	0% { opacity: 1; }
+	50% { opacity: 0; }
+	100% { opacity: 1; }
+}
+
+@keyframes progress-spinner {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+
+#wait {
+	display: block;
+	position: fixed;
+	z-index: 4000000;
+	top: 15px;
+	right: 15px;
+
+	&:before {
+		content: "";
+		display: block;
+		width: 18px;
+		height: 18px;
+		box-sizing: border-box;
+		border: solid 2px transparent;
+		border-top-color: var(--accent);
+		border-left-color: var(--accent);
+		border-radius: 50%;
+		animation: progress-spinner 400ms linear infinite;
+	}
+}
+
+#botWarn {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	margin: auto;
+	width: 100%;
+	height: max-content;
+	text-align: center;
+	z-index: 2147483647;
+	color: #ff0;
+	background: rgba(0, 0, 0, 0.5);
+	padding: 4px 7px;
+	font-size: 14px;
+	pointer-events: none;
+	user-select: none;
+
+	> span {
+		animation: dev-ticker-blink 2s infinite;
+	}
+}
+
+#devTicker {
+	position: fixed;
+	top: 0;
+	left: 0;
+	z-index: 2147483647;
+	color: #ff0;
+	background: rgba(0, 0, 0, 0.5);
+	padding: 4px 5px;
+	font-size: 14px;
+	pointer-events: none;
+	user-select: none;
+
+	> span {
+		animation: dev-ticker-blink 2s infinite;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
new file mode 100644
index 0000000000..50b28de063
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -0,0 +1,314 @@
+<template>
+<div class="kmwsukvl">
+	<div class="body">
+		<div class="top">
+			<div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div>
+			<button v-click-anime class="item _button instance" @click="openInstanceMenu">
+				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+			</button>
+		</div>
+		<div class="middle">
+			<MkA v-click-anime class="item index" active-class="active" to="/" exact>
+				<i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
+			</MkA>
+			<template v-for="item in menu">
+				<div v-if="item === '-'" class="divider"></div>
+				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
+					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+				</component>
+			</template>
+			<div class="divider"></div>
+			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin">
+				<i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
+			</MkA>
+			<button v-click-anime class="item _button" @click="more">
+				<i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
+				<span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+			</button>
+			<MkA v-click-anime class="item" active-class="active" to="/settings">
+				<i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
+			</MkA>
+		</div>
+		<div class="bottom">
+			<button class="item _button post" data-cy-open-post-form @click="os.post">
+				<i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
+			</button>
+			<button v-click-anime class="item _button account" @click="openAccountMenu">
+				<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+			</button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { openAccountMenu as openAccountMenu_ } from '@/account';
+import { defaultStore } from '@/store';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const menu = toRef(defaultStore.state, 'menu');
+const otherMenuItemIndicated = computed(() => {
+	for (const def in navbarItemDef) {
+		if (menu.value.includes(def)) continue;
+		if (navbarItemDef[def].indicated) return true;
+	}
+	return false;
+});
+
+function openAccountMenu(ev: MouseEvent) {
+	openAccountMenu_({
+		withExtraOperation: true,
+	}, ev);
+}
+
+function openInstanceMenu(ev: MouseEvent) {
+	os.popupMenu([{
+		text: instance.name ?? host,
+		type: 'label',
+	}, {
+		type: 'link',
+		text: i18n.ts.instanceInfo,
+		icon: 'ti ti-info-circle',
+		to: '/about',
+	}, {
+		type: 'link',
+		text: i18n.ts.customEmojis,
+		icon: 'ti ti-mood-happy',
+		to: '/about#emojis',
+	}, {
+		type: 'link',
+		text: i18n.ts.federation,
+		icon: 'ti ti-whirl',
+		to: '/about#federation',
+	}, null, {
+		type: 'parent',
+		text: i18n.ts.help,
+		icon: 'ti ti-question-circle',
+		children: [{
+			type: 'link',
+			to: '/mfm-cheat-sheet',
+			text: i18n.ts._mfm.cheatSheet,
+			icon: 'ti ti-code',
+		}, {
+			type: 'link',
+			to: '/scratchpad',
+			text: i18n.ts.scratchpad,
+			icon: 'ti ti-terminal-2',
+		}, {
+			type: 'link',
+			to: '/api-console',
+			text: 'API Console',
+			icon: 'ti ti-terminal-2',
+		}, null, {
+			text: i18n.ts.document,
+			icon: 'ti ti-question-circle',
+			action: () => {
+				window.open('https://misskey-hub.net/help.html', '_blank');
+			},
+		}],
+	}, {
+		type: 'link',
+		text: i18n.ts.aboutMisskey,
+		to: '/about-misskey',
+	}], ev.currentTarget ?? ev.target, {
+		align: 'left',
+	});
+}
+
+function more() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
+	}, 'closed');
+}
+</script>
+
+<style lang="scss" scoped>
+.kmwsukvl {
+	> .body {
+		display: flex;
+		flex-direction: column;
+
+		> .top {
+			position: sticky;
+			top: 0;
+			z-index: 1;
+			padding: 20px 0;
+			background: var(--X14);
+			-webkit-backdrop-filter: var(--blur, blur(8px));
+			backdrop-filter: var(--blur, blur(8px));
+
+			> .banner {
+				position: absolute;
+				top: 0;
+				left: 0;
+				width: 100%;
+				height: 100%;
+				background-size: cover;
+				background-position: center center;
+				-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+				mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+			}
+
+			> .instance {
+				position: relative;
+				display: block;
+				text-align: center;
+				width: 100%;
+
+				> .icon {
+					display: inline-block;
+					width: 38px;
+					aspect-ratio: 1;
+				}
+			}
+		}
+
+		> .bottom {
+			position: sticky;
+			bottom: 0;
+			padding: 20px 0;
+			background: var(--X14);
+			-webkit-backdrop-filter: var(--blur, blur(8px));
+			backdrop-filter: var(--blur, blur(8px));
+
+			> .post {
+				position: relative;
+				display: block;
+				width: 100%;
+				height: 40px;
+				color: var(--fgOnAccent);
+				font-weight: bold;
+				text-align: left;
+
+				&:before {
+					content: "";
+					display: block;
+					width: calc(100% - 38px);
+					height: 100%;
+					margin: auto;
+					position: absolute;
+					top: 0;
+					left: 0;
+					right: 0;
+					bottom: 0;
+					border-radius: 999px;
+					background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+				}
+
+				&:hover, &.active {
+					&:before {
+						background: var(--accentLighten);
+					}
+				}
+
+				> .icon {
+					position: relative;
+					margin-left: 30px;
+					margin-right: 8px;
+					width: 32px;
+				}
+
+				> .text {
+					position: relative;
+				}
+			}
+
+			> .account {
+				position: relative;
+				display: flex;
+				align-items: center;
+				padding-left: 30px;
+				text-overflow: ellipsis;
+				overflow: hidden;
+				white-space: nowrap;
+				width: 100%;
+				text-align: left;
+				box-sizing: border-box;
+				margin-top: 16px;
+
+				> .avatar {
+					position: relative;
+					width: 32px;
+					aspect-ratio: 1;
+					margin-right: 8px;
+				}
+			}
+		}
+
+		> .middle {
+			flex: 1;
+
+			> .divider {
+				margin: 16px 16px;
+				border-top: solid 0.5px var(--divider);
+			}
+
+			> .item {
+				position: relative;
+				display: block;
+				padding-left: 24px;
+				line-height: 2.85rem;
+				text-overflow: ellipsis;
+				overflow: hidden;
+				white-space: nowrap;
+				width: 100%;
+				text-align: left;
+				box-sizing: border-box;
+				color: var(--navFg);
+
+				> .icon {
+					position: relative;
+					width: 32px;
+					margin-right: 8px;
+				}
+
+				> .indicator {
+					position: absolute;
+					top: 0;
+					left: 20px;
+					color: var(--navIndicator);
+					font-size: 8px;
+					animation: blink 1s infinite;
+				}
+
+				> .text {
+					position: relative;
+					font-size: 0.9em;
+				}
+
+				&:hover {
+					text-decoration: none;
+					color: var(--navHoverFg);
+				}
+
+				&.active {
+					color: var(--navActive);
+				}
+
+				&:hover, &.active {
+					&:before {
+						content: "";
+						display: block;
+						width: calc(100% - 24px);
+						height: 100%;
+						margin: auto;
+						position: absolute;
+						top: 0;
+						left: 0;
+						right: 0;
+						bottom: 0;
+						border-radius: 999px;
+						background: var(--accentedBg);
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
new file mode 100644
index 0000000000..b82da15f13
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -0,0 +1,521 @@
+<template>
+<div class="mvcprjjd" :class="{ iconOnly }">
+	<div class="body">
+		<div class="top">
+			<div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div>
+			<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
+				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+			</button>
+		</div>
+		<div class="middle">
+			<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact>
+				<i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
+			</MkA>
+			<template v-for="item in menu">
+				<div v-if="item === '-'" class="divider"></div>
+				<component
+					:is="navbarItemDef[item].to ? 'MkA' : 'button'"
+					v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
+					v-click-anime
+					v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
+					class="item _button"
+					:class="[item, { active: navbarItemDef[item].active }]"
+					active-class="active"
+					:to="navbarItemDef[item].to"
+					v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
+				>
+					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
+					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+				</component>
+			</template>
+			<div class="divider"></div>
+			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin">
+				<i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
+			</MkA>
+			<button v-click-anime class="item _button" @click="more">
+				<i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
+				<span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+			</button>
+			<MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings">
+				<i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
+			</MkA>
+		</div>
+		<div class="bottom">
+			<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
+				<i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
+			</button>
+			<button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
+				<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+			</button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, watch } from 'vue';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { host } from '@/config';
+
+const iconOnly = ref(false);
+
+const menu = computed(() => defaultStore.state.menu);
+const otherMenuItemIndicated = computed(() => {
+	for (const def in navbarItemDef) {
+		if (menu.value.includes(def)) continue;
+		if (navbarItemDef[def].indicated) return true;
+	}
+	return false;
+});
+
+const calcViewState = () => {
+	iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon');
+};
+
+calcViewState();
+
+window.addEventListener('resize', calcViewState);
+
+watch(defaultStore.reactiveState.menuDisplay, () => {
+	calcViewState();
+});
+
+function openAccountMenu(ev: MouseEvent) {
+	openAccountMenu_({
+		withExtraOperation: true,
+	}, ev);
+}
+
+function openInstanceMenu(ev: MouseEvent) {
+	os.popupMenu([{
+		text: instance.name ?? host,
+		type: 'label',
+	}, {
+		type: 'link',
+		text: i18n.ts.instanceInfo,
+		icon: 'ti ti-info-circle',
+		to: '/about',
+	}, {
+		type: 'link',
+		text: i18n.ts.customEmojis,
+		icon: 'ti ti-mood-happy',
+		to: '/about#emojis',
+	}, {
+		type: 'link',
+		text: i18n.ts.federation,
+		icon: 'ti ti-whirl',
+		to: '/about#federation',
+	}, null, {
+		type: 'parent',
+		text: i18n.ts.help,
+		icon: 'ti ti-question-circle',
+		children: [{
+			type: 'link',
+			to: '/mfm-cheat-sheet',
+			text: i18n.ts._mfm.cheatSheet,
+			icon: 'ti ti-code',
+		}, {
+			type: 'link',
+			to: '/scratchpad',
+			text: i18n.ts.scratchpad,
+			icon: 'ti ti-terminal-2',
+		}, {
+			type: 'link',
+			to: '/api-console',
+			text: 'API Console',
+			icon: 'ti ti-terminal-2',
+		}, null, {
+			text: i18n.ts.document,
+			icon: 'ti ti-question-circle',
+			action: () => {
+				window.open('https://misskey-hub.net/help.html', '_blank');
+			},
+		}],
+	}, {
+		type: 'link',
+		text: i18n.ts.aboutMisskey,
+		to: '/about-misskey',
+	}], ev.currentTarget ?? ev.target, {
+		align: 'left',
+	});
+}
+
+function more(ev: MouseEvent) {
+	os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
+		src: ev.currentTarget ?? ev.target,
+	}, {
+	}, 'closed');
+}
+</script>
+
+<style lang="scss" scoped>
+.mvcprjjd {
+	$nav-width: 250px;
+	$nav-icon-only-width: 80px;
+
+	flex: 0 0 $nav-width;
+	width: $nav-width;
+	box-sizing: border-box;
+
+	> .body {
+		position: fixed;
+		top: 0;
+		left: 0;
+		z-index: 1001;
+		width: $nav-icon-only-width;
+		height: 100dvh;
+		box-sizing: border-box;
+		overflow: auto;
+		overflow-x: clip;
+		background: var(--navBg);
+		contain: strict;
+		display: flex;
+		flex-direction: column;
+	}
+
+	&:not(.iconOnly) {
+		> .body {
+			width: $nav-width;
+
+			> .top {
+				position: sticky;
+				top: 0;
+				z-index: 1;
+				padding: 20px 0;
+				background: var(--X14);
+				-webkit-backdrop-filter: var(--blur, blur(8px));
+				backdrop-filter: var(--blur, blur(8px));
+
+				> .banner {
+					position: absolute;
+					top: 0;
+					left: 0;
+					width: 100%;
+					height: 100%;
+					background-size: cover;
+					background-position: center center;
+					-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+					mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+				}
+
+				> .instance {
+					position: relative;
+					display: block;
+					text-align: center;
+					width: 100%;
+
+					> .icon {
+						display: inline-block;
+						width: 38px;
+						aspect-ratio: 1;
+					}
+				}
+			}
+
+			> .bottom {
+				position: sticky;
+				bottom: 0;
+				padding: 20px 0;
+				background: var(--X14);
+				-webkit-backdrop-filter: var(--blur, blur(8px));
+				backdrop-filter: var(--blur, blur(8px));
+
+				> .post {
+					position: relative;
+					display: block;
+					width: 100%;
+					height: 40px;
+					color: var(--fgOnAccent);
+					font-weight: bold;
+					text-align: left;
+
+					&:before {
+						content: "";
+						display: block;
+						width: calc(100% - 38px);
+						height: 100%;
+						margin: auto;
+						position: absolute;
+						top: 0;
+						left: 0;
+						right: 0;
+						bottom: 0;
+						border-radius: 999px;
+						background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+					}
+
+					&:hover, &.active {
+						&:before {
+							background: var(--accentLighten);
+						}
+					}
+
+					> .icon {
+						position: relative;
+						margin-left: 30px;
+						margin-right: 8px;
+						width: 32px;
+					}
+
+					> .text {
+						position: relative;
+					}
+				}
+
+				> .account {
+					position: relative;
+					display: flex;
+					align-items: center;
+					padding-left: 30px;
+					text-overflow: ellipsis;
+					overflow: hidden;
+					white-space: nowrap;
+					width: 100%;
+					text-align: left;
+					box-sizing: border-box;
+					margin-top: 16px;
+
+					> .avatar {
+						position: relative;
+						width: 32px;
+						aspect-ratio: 1;
+						margin-right: 8px;
+					}
+				}
+			}
+
+			> .middle {
+				flex: 1;
+
+				> .divider {
+					margin: 16px 16px;
+					border-top: solid 0.5px var(--divider);
+				}
+
+				> .item {
+					position: relative;
+					display: block;
+					padding-left: 30px;
+					line-height: 2.85rem;
+					text-overflow: ellipsis;
+					overflow: hidden;
+					white-space: nowrap;
+					width: 100%;
+					text-align: left;
+					box-sizing: border-box;
+					color: var(--navFg);
+
+					> .icon {
+						position: relative;
+						width: 32px;
+						margin-right: 8px;
+					}
+
+					> .indicator {
+						position: absolute;
+						top: 0;
+						left: 20px;
+						color: var(--navIndicator);
+						font-size: 8px;
+						animation: blink 1s infinite;
+					}
+
+					> .text {
+						position: relative;
+						font-size: 0.9em;
+					}
+
+					&:hover {
+						text-decoration: none;
+						color: var(--navHoverFg);
+					}
+
+					&.active {
+						color: var(--navActive);
+					}
+
+					&:hover, &.active {
+						color: var(--accent);
+
+						&:before {
+							content: "";
+							display: block;
+							width: calc(100% - 34px);
+							height: 100%;
+							margin: auto;
+							position: absolute;
+							top: 0;
+							left: 0;
+							right: 0;
+							bottom: 0;
+							border-radius: 999px;
+							background: var(--accentedBg);
+						}
+					}
+				}
+			}
+		}
+	}
+
+	&.iconOnly {
+		flex: 0 0 $nav-icon-only-width;
+		width: $nav-icon-only-width;
+
+		> .body {
+			width: $nav-icon-only-width;
+
+			> .top {
+				position: sticky;
+				top: 0;
+				z-index: 1;
+				padding: 20px 0;
+				background: var(--X14);
+				-webkit-backdrop-filter: var(--blur, blur(8px));
+				backdrop-filter: var(--blur, blur(8px));
+
+				> .instance {
+					display: block;
+					text-align: center;
+					width: 100%;
+
+					> .icon {
+						display: inline-block;
+						width: 30px;
+						aspect-ratio: 1;
+					}
+				}
+			}
+
+			> .bottom {
+				position: sticky;
+				bottom: 0;
+				padding: 20px 0;
+				background: var(--X14);
+				-webkit-backdrop-filter: var(--blur, blur(8px));
+				backdrop-filter: var(--blur, blur(8px));
+
+				> .post {
+					display: block;
+					position: relative;
+					width: 100%;
+					height: 52px;
+					margin-bottom: 16px;
+					text-align: center;
+
+					&:before {
+						content: "";
+						display: block;
+						position: absolute;
+						top: 0;
+						left: 0;
+						right: 0;
+						bottom: 0;
+						margin: auto;
+						width: 52px;
+						aspect-ratio: 1/1;
+						border-radius: 100%;
+						background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+					}
+
+					&:hover, &.active {
+						&:before {
+							background: var(--accentLighten);
+						}
+					}
+
+					> .icon {
+						position: relative;
+						color: var(--fgOnAccent);
+					}
+
+					> .text {
+						display: none;
+					}
+				}
+
+				> .account {
+					display: block;
+					text-align: center;
+					width: 100%;
+
+					> .avatar {
+						display: inline-block;
+						width: 38px;
+						aspect-ratio: 1;
+					}
+
+					> .text {
+						display: none;
+					}
+				}
+			}
+
+			> .middle {
+				flex: 1;
+
+				> .divider {
+					margin: 8px auto;
+					width: calc(100% - 32px);
+					border-top: solid 0.5px var(--divider);
+				}
+
+				> .item {
+					display: block;
+					position: relative;
+					padding: 18px 0;
+					width: 100%;
+					text-align: center;
+
+					> .icon {
+						display: block;
+						margin: 0 auto;
+						opacity: 0.7;
+					}
+
+					> .text {
+						display: none;
+					}
+
+					> .indicator {
+						position: absolute;
+						top: 6px;
+						left: 24px;
+						color: var(--navIndicator);
+						font-size: 8px;
+						animation: blink 1s infinite;
+					}
+
+					&:hover, &.active {
+						text-decoration: none;
+						color: var(--accent);
+
+						&:before {
+							content: "";
+							display: block;
+							height: 100%;
+							aspect-ratio: 1;
+							margin: auto;
+							position: absolute;
+							top: 0;
+							left: 0;
+							right: 0;
+							bottom: 0;
+							border-radius: 999px;
+							background: var(--accentedBg);
+						}
+
+						> .icon, > .text {
+							opacity: 1;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
new file mode 100644
index 0000000000..24fc4f6f6d
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -0,0 +1,108 @@
+<template>
+<span v-if="!fetching" class="nmidsaqw">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
+					<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
+					<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
+						{{ instance.host }}
+					</MkA>
+					<span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MarqueeText from '@/components/MkMarquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { notePage } from '@/filters/note';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const props = defineProps<{
+	display?: 'marquee' | 'oneByOne';
+	colored?: boolean;
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const instances = ref<misskey.entities.Instance[]>([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 30,
+	}).then(res => {
+		instances.value = res;
+		fetching.value = false;
+		key++;
+	});
+};
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+
+function getInstanceIcon(instance): string {
+	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.nmidsaqw {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-block;
+		vertical-align: bottom;
+		margin-right: 5em;
+
+		> .icon {
+			display: inline-block;
+			height: var(--height);
+			aspect-ratio: 1;
+			vertical-align: bottom;
+			margin-right: 1em;
+		}
+
+		> .host {
+			vertical-align: bottom;
+		}
+
+		&.colored {
+			padding-right: 1em;
+			color: #fff;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
new file mode 100644
index 0000000000..e7f88e4984
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -0,0 +1,93 @@
+<template>
+<span v-if="!fetching" class="xbhtxfms">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="item in items" class="item">
+					<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import MarqueeText from '@/components/MkMarquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { shuffle } from '@/scripts/shuffle';
+
+const props = defineProps<{
+	url?: string;
+	shuffle?: boolean;
+	display?: 'marquee' | 'oneByOne';
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const items = ref([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
+		res.json().then(feed => {
+			if (props.shuffle) {
+				shuffle(feed.items);
+			}
+			items.value = feed.items;
+			fetching.value = false;
+			key++;
+		});
+	});
+};
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.xbhtxfms {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-flex;
+		align-items: center;
+		vertical-align: bottom;
+		margin: 0;
+
+		> .divider {
+			display: inline-block;
+			width: 0.5px;
+			height: var(--height);
+			margin: 0 3em;
+			background: currentColor;
+			opacity: 0.3;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
new file mode 100644
index 0000000000..f4d989c387
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -0,0 +1,113 @@
+<template>
+<span v-if="!fetching" class="osdsvwzy">
+	<template v-if="display === 'marquee'">
+		<transition name="change" mode="default">
+			<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
+				<span v-for="note in notes" :key="note.id" class="item">
+					<img class="avatar" :src="note.user.avatarUrl" decoding="async"/>
+					<MkA class="text" :to="notePage(note)">
+						<Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/>
+					</MkA>
+					<span class="divider"></span>
+				</span>
+			</MarqueeText>
+		</transition>
+	</template>
+	<template v-else-if="display === 'oneByOne'">
+		<!-- TODO -->
+	</template>
+</span>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MarqueeText from '@/components/MkMarquee.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import { notePage } from '@/filters/note';
+
+const props = defineProps<{
+	userListId?: string;
+	display?: 'marquee' | 'oneByOne';
+	marqueeDuration?: number;
+	marqueeReverse?: boolean;
+	oneByOneInterval?: number;
+	refreshIntervalSec?: number;
+}>();
+
+const notes = ref<misskey.entities.Note[]>([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	if (props.userListId == null) return;
+	os.api('notes/user-list-timeline', {
+		listId: props.userListId,
+	}).then(res => {
+		notes.value = res;
+		fetching.value = false;
+		key++;
+	});
+};
+
+watch(() => props.userListId, tick);
+
+useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.osdsvwzy {
+	display: inline-block;
+	position: relative;
+
+	::v-deep(.item) {
+		display: inline-flex;
+		align-items: center;
+		vertical-align: bottom;
+		margin: 0;
+
+		> .avatar {
+			display: inline-block;
+			height: var(--height);
+			aspect-ratio: 1;
+			vertical-align: bottom;
+			margin-right: 8px;
+		}
+
+		> .text {
+			> .text {
+				display: inline-block;
+				vertical-align: bottom;
+			}
+		}
+
+		> .divider {
+			display: inline-block;
+			width: 0.5px;
+			height: 16px;
+			margin: 0 3em;
+			background: currentColor;
+			opacity: 0;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue
new file mode 100644
index 0000000000..114ca5be8c
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/statusbars.vue
@@ -0,0 +1,92 @@
+<template>
+<div class="dlrsnxqu">
+	<div
+		v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, {
+			verySmall: x.size === 'verySmall',
+			small: x.size === 'small',
+			medium: x.size === 'medium',
+			large: x.size === 'large',
+			veryLarge: x.size === 'veryLarge',
+		}]"
+	>
+		<span class="name">{{ x.name }}</span>
+		<XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/>
+		<XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/>
+		<XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue'));
+const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue'));
+const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
+</script>
+
+<style lang="scss" scoped>
+.dlrsnxqu {
+	font-size: 15px;
+	background: var(--panel);
+
+	> .item {
+		--height: 24px;
+		--nameMargin: 10px;
+		font-size: 0.85em;
+
+		&.verySmall {
+			--nameMargin: 7px;
+			--height: 16px;
+			font-size: 0.75em;
+		}
+
+		&.small {
+			--nameMargin: 8px;
+			--height: 20px;
+			font-size: 0.8em;
+		}
+
+		&.large {
+			--nameMargin: 12px;
+			--height: 26px;
+			font-size: 0.875em;
+		}
+
+		&.veryLarge {
+			--nameMargin: 14px;
+			--height: 30px;
+			font-size: 0.9em;
+		}
+
+		display: flex;
+		vertical-align: bottom;
+		width: 100%;
+		line-height: var(--height);
+		height: var(--height);
+		overflow: clip;
+		contain: strict;
+
+		> .name {
+			padding: 0 var(--nameMargin);
+			font-weight: bold;
+			color: var(--accent);
+
+			&:empty {
+				display: none;
+			}
+		}
+
+		> .body {
+			min-width: 0;
+			flex: 1;
+		}
+
+		&.black {
+			background: #000;
+			color: #fff;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue
new file mode 100644
index 0000000000..a855de8ab9
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/stream-indicator.vue
@@ -0,0 +1,61 @@
+<template>
+<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected">
+	<div>{{ i18n.ts.disconnectedFromServer }}</div>
+	<div class="command">
+		<button class="_textButton" @click="reload">{{ i18n.ts.reload }}</button>
+		<button class="_textButton">{{ i18n.ts.doNothing }}</button>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted } from 'vue';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+let hasDisconnected = $ref(false);
+
+function onDisconnected() {
+	hasDisconnected = true;
+}
+
+function resetDisconnected() {
+	hasDisconnected = false;
+}
+
+function reload() {
+	location.reload();
+}
+
+stream.on('_disconnected_', onDisconnected);
+
+onUnmounted(() => {
+	stream.off('_disconnected_', onDisconnected);
+});
+</script>
+
+<style lang="scss" scoped>
+.nsbbhtug {
+	position: fixed;
+	z-index: 16385;
+	bottom: 8px;
+	right: 8px;
+	margin: 0;
+	padding: 6px 12px;
+	font-size: 0.9em;
+	color: #fff;
+	background: #000;
+	opacity: 0.8;
+	border-radius: 4px;
+	max-width: 320px;
+
+	> .command {
+		display: flex;
+		justify-content: space-around;
+
+		> button {
+			padding: 0.7em;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts
new file mode 100644
index 0000000000..8676d2d48d
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/sw-inject.ts
@@ -0,0 +1,35 @@
+import { inject } from 'vue';
+import { post } from '@/os';
+import { $i, login } from '@/account';
+import { defaultStore } from '@/store';
+import { getAccountFromId } from '@/scripts/get-account-from-id';
+import { mainRouter } from '@/router';
+
+export function swInject() {
+	navigator.serviceWorker.addEventListener('message', ev => {
+		if (_DEV_) {
+			console.log('sw msg', ev.data);
+		}
+
+		if (ev.data.type !== 'order') return;
+
+		if (ev.data.loginId !== $i?.id) {
+			return getAccountFromId(ev.data.loginId).then(account => {
+				if (!account) return;
+				return login(account.token, ev.data.url);
+			});
+		}
+
+		switch (ev.data.order) {
+			case 'post':
+				return post(ev.data.options);
+			case 'push':
+				if (mainRouter.currentRoute.value.path === ev.data.url) {
+					return window.scroll({ top: 0, behavior: 'smooth' });
+				}
+				return mainRouter.push(ev.data.url);
+			default:
+				return;
+		}
+	});
+}
diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue
new file mode 100644
index 0000000000..70882bd251
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/upload.vue
@@ -0,0 +1,129 @@
+<template>
+<div class="mk-uploader _acrylic" :style="{ zIndex }">
+	<ol v-if="uploads.length > 0">
+		<li v-for="ctx in uploads" :key="ctx.id">
+			<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
+			<div class="top">
+				<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
+				<p class="status">
+					<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
+					<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
+					<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
+				</p>
+			</div>
+			<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
+		</li>
+	</ol>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as os from '@/os';
+import { uploads } from '@/scripts/upload';
+import { i18n } from '@/i18n';
+
+const zIndex = os.claimZIndex('high');
+</script>
+
+<style lang="scss" scoped>
+.mk-uploader {
+	position: fixed;
+	right: 16px;
+	width: 260px;
+	top: 32px;
+	padding: 16px 20px;
+	pointer-events: none;
+	box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+	border-radius: 8px;
+}
+.mk-uploader:empty {
+  display: none;
+}
+.mk-uploader > ol {
+  display: block;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+.mk-uploader > ol > li {
+  display: grid;
+  margin: 8px 0 0 0;
+  padding: 0;
+  height: 36px;
+  width: 100%;
+  border-top: solid 8px transparent;
+  grid-template-columns: 36px calc(100% - 44px);
+  grid-template-rows: 1fr 8px;
+  column-gap: 8px;
+  box-sizing: content-box;
+}
+.mk-uploader > ol > li:first-child {
+  margin: 0;
+  box-shadow: none;
+  border-top: none;
+}
+.mk-uploader > ol > li > .img {
+  display: block;
+  background-size: cover;
+  background-position: center center;
+  grid-column: 1/2;
+  grid-row: 1/3;
+}
+.mk-uploader > ol > li > .top {
+  display: flex;
+  grid-column: 2/3;
+  grid-row: 1/2;
+}
+.mk-uploader > ol > li > .top > .name {
+  display: block;
+  padding: 0 8px 0 0;
+  margin: 0;
+  font-size: 0.8em;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  flex-shrink: 1;
+}
+.mk-uploader > ol > li > .top > .name > i {
+  margin-right: 4px;
+}
+.mk-uploader > ol > li > .top > .status {
+  display: block;
+  margin: 0 0 0 auto;
+  padding: 0;
+  font-size: 0.8em;
+  flex-shrink: 0;
+}
+.mk-uploader > ol > li > .top > .status > .initing {
+}
+.mk-uploader > ol > li > .top > .status > .kb {
+}
+.mk-uploader > ol > li > .top > .status > .percentage {
+  display: inline-block;
+  width: 48px;
+  text-align: right;
+}
+.mk-uploader > ol > li > .top > .status > .percentage:after {
+  content: '%';
+}
+.mk-uploader > ol > li > progress {
+  display: block;
+  background: transparent;
+  border: none;
+  border-radius: 4px;
+  overflow: hidden;
+  grid-column: 2/3;
+  grid-row: 2/3;
+  z-index: 2;
+	width: 100%;
+	height: 8px;
+}
+.mk-uploader > ol > li > progress::-webkit-progress-value {
+  background: var(--accent);
+}
+.mk-uploader > ol > li > progress::-webkit-progress-bar {
+  //background: var(--accentAlpha01);
+	background: transparent;
+}
+</style>
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
new file mode 100644
index 0000000000..46d79e6355
--- /dev/null
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -0,0 +1,217 @@
+<template>
+<div class="azykntjl">
+	<div class="body">
+		<div class="left">
+			<MkA v-click-anime v-tooltip="$ts.timeline" class="item index" active-class="active" to="/" exact>
+				<i class="ti ti-home ti-fw"></i>
+			</MkA>
+			<template v-for="item in menu">
+				<div v-if="item === '-'" class="divider"></div>
+				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+					<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
+					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
+				</component>
+			</template>
+			<div class="divider"></div>
+			<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="$ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
+				<i class="ti ti-dashboard ti-fw"></i>
+			</MkA>
+			<button v-click-anime class="item _button" @click="more">
+				<i class="ti ti-dots ti-fw"></i>
+				<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
+			</button>
+		</div>
+		<div class="right">
+			<MkA v-click-anime v-tooltip="$ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
+				<i class="ti ti-settings ti-fw"></i>
+			</MkA>
+			<button v-click-anime class="item _button account" @click="openAccountMenu">
+				<MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
+			</button>
+			<div class="post" @click="post">
+				<MkButton class="button" gradate full rounded>
+					<i class="ti ti-pencil ti-fw"></i>
+				</MkButton>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { openAccountMenu } from '@/account';
+import MkButton from '@/components/MkButton.vue';
+
+export default defineComponent({
+	components: {
+		MkButton,
+	},
+
+	data() {
+		return {
+			host: host,
+			accounts: [],
+			connection: null,
+			navbarItemDef: navbarItemDef,
+			settingsWindowed: false,
+		};
+	},
+
+	computed: {
+		menu(): string[] {
+			return this.$store.state.menu;
+		},
+
+		otherNavItemIndicated(): boolean {
+			for (const def in this.navbarItemDef) {
+				if (this.menu.includes(def)) continue;
+				if (this.navbarItemDef[def].indicated) return true;
+			}
+			return false;
+		},
+	},
+
+	watch: {
+		'$store.reactiveState.menuDisplay.value'() {
+			this.calcViewState();
+		},
+	},
+
+	created() {
+		window.addEventListener('resize', this.calcViewState);
+		this.calcViewState();
+	},
+
+	methods: {
+		calcViewState() {
+			this.settingsWindowed = (window.innerWidth > 1400);
+		},
+
+		post() {
+			os.post();
+		},
+
+		search() {
+			search();
+		},
+
+		more(ev) {
+			os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
+				src: ev.currentTarget ?? ev.target,
+				anchor: { x: 'center', y: 'bottom' },
+			}, {
+			}, 'closed');
+		},
+
+		openAccountMenu: (ev) => {
+			openAccountMenu({
+				withExtraOperation: true,
+			}, ev);
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.azykntjl {
+	$height: 60px;
+	$avatar-size: 32px;
+	$avatar-margin: 8px;
+
+	position: sticky;
+	top: 0;
+	z-index: 1000;
+	width: 100%;
+	height: $height;
+	background-color: var(--bg);
+
+	> .body {
+		max-width: 1380px;
+		margin: 0 auto;
+		display: flex;
+
+		> .right,
+		> .left {
+
+			> .item {
+				position: relative;
+				font-size: 0.9em;
+				display: inline-block;
+				padding: 0 12px;
+				line-height: $height;
+
+				> i,
+				> .avatar {
+					margin-right: 0;
+				}
+
+				> i {
+					left: 10px;
+				}
+
+				> .avatar {
+					width: $avatar-size;
+					height: $avatar-size;
+					vertical-align: middle;
+				}
+
+				> .indicator {
+					position: absolute;
+					top: 0;
+					left: 0;
+					color: var(--navIndicator);
+					font-size: 8px;
+					animation: blink 1s infinite;
+				}
+
+				&:hover {
+					text-decoration: none;
+					color: var(--navHoverFg);
+				}
+
+				&.active {
+					color: var(--navActive);
+				}
+			}
+
+			> .divider {
+				display: inline-block;
+				height: 16px;
+				margin: 0 10px;
+				border-right: solid 0.5px var(--divider);
+			}
+
+			> .post {
+				display: inline-block;
+			
+				> .button {
+					width: 40px;
+					height: 40px;
+					padding: 0;
+					min-width: 0;
+				}
+			}
+
+			> .account {
+				display: inline-flex;
+				align-items: center;
+				vertical-align: top;
+				margin-right: 8px;
+
+				> .acct {
+					margin-left: 8px;
+				}
+			}
+		}
+
+		> .right {
+			margin-left: auto;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
new file mode 100644
index 0000000000..dac09ea703
--- /dev/null
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -0,0 +1,268 @@
+<template>
+<div class="npcljfve" :class="{ iconOnly }">
+	<button v-click-anime class="item _button account" @click="openAccountMenu">
+		<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+	</button>
+	<div class="post" data-cy-open-post-form @click="post">
+		<MkButton class="button" gradate full rounded>
+			<i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ $ts.note }}</span>
+		</MkButton>
+	</div>
+	<div class="divider"></div>
+	<MkA v-click-anime class="item index" active-class="active" to="/" exact>
+		<i class="ti ti-home ti-fw"></i><span class="text">{{ $ts.timeline }}</span>
+	</MkA>
+	<template v-for="item in menu">
+		<div v-if="item === '-'" class="divider"></div>
+		<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+			<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span>
+			<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
+		</component>
+	</template>
+	<div class="divider"></div>
+	<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
+		<i class="ti ti-dashboard ti-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
+	</MkA>
+	<button v-click-anime class="item _button" @click="more">
+		<i class="ti ti-dots ti-fw"></i><span class="text">{{ $ts.more }}</span>
+		<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
+	</button>
+	<MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
+		<i class="ti ti-settings ti-fw"></i><span class="text">{{ $ts.settings }}</span>
+	</MkA>
+	<div class="divider"></div>
+	<div class="about">
+		<MkA v-click-anime class="link" to="/about">
+			<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/>
+		</MkA>
+	</div>
+	<!--<MisskeyLogo class="misskey"/>-->
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { openAccountMenu } from '@/account';
+import MkButton from '@/components/MkButton.vue';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+//import MisskeyLogo from '@assets/client/misskey.svg';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		//MisskeyLogo,
+	},
+
+	data() {
+		return {
+			host: host,
+			accounts: [],
+			connection: null,
+			navbarItemDef: navbarItemDef,
+			iconOnly: false,
+			settingsWindowed: false,
+		};
+	},
+
+	computed: {
+		menu(): string[] {
+			return this.$store.state.menu;
+		},
+
+		otherNavItemIndicated(): boolean {
+			for (const def in this.navbarItemDef) {
+				if (this.menu.includes(def)) continue;
+				if (this.navbarItemDef[def].indicated) return true;
+			}
+			return false;
+		},
+	},
+
+	watch: {
+		'$store.reactiveState.menuDisplay.value'() {
+			this.calcViewState();
+		},
+
+		iconOnly() {
+			this.$nextTick(() => {
+				this.$emit('change-view-mode');
+			});
+		},
+	},
+
+	created() {
+		window.addEventListener('resize', this.calcViewState);
+		this.calcViewState();
+	},
+
+	mounted() {
+		const sticky = new StickySidebar(this.$el.parentElement, 16);
+		window.addEventListener('scroll', () => {
+			sticky.calc(window.scrollY);
+		}, { passive: true });
+	},
+
+	methods: {
+		calcViewState() {
+			this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon');
+			this.settingsWindowed = (window.innerWidth > 1400);
+		},
+
+		post() {
+			os.post();
+		},
+
+		search() {
+			search();
+		},
+
+		more(ev) {
+			os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
+				src: ev.currentTarget ?? ev.target,
+			}, {}, 'closed');
+		},
+
+		openAccountMenu: (ev) => {
+			openAccountMenu({
+				withExtraOperation: true,
+			}, ev);
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.npcljfve {
+	$ui-font-size: 1em; // TODO: どこかに集約したい
+	$nav-icon-only-width: 78px; // TODO: どこかに集約したい
+	$avatar-size: 32px;
+	$avatar-margin: 8px;
+
+	padding: 0 16px;
+	box-sizing: border-box;
+	width: 260px;
+
+	&.iconOnly {
+		flex: 0 0 $nav-icon-only-width;
+		width: $nav-icon-only-width !important;
+
+		> .divider {
+			margin: 8px auto;
+			width: calc(100% - 32px);
+		}
+
+		> .post {
+			> .button {
+				width: 46px;
+				height: 46px;
+				padding: 0;
+			}
+		}
+
+		> .item {
+			padding-left: 0;
+			width: 100%;
+			text-align: center;
+			font-size: $ui-font-size * 1.1;
+			line-height: 3.7rem;
+
+			> i,
+			> .avatar {
+				margin-right: 0;
+			}
+
+			> i {
+				left: 10px;
+			}
+
+			> .text {
+				display: none;
+			}
+		}
+	}
+
+	> .divider {
+		margin: 10px 0;
+		border-top: solid 0.5px var(--divider);
+	}
+
+	> .post {
+		position: sticky;
+		top: 0;
+		z-index: 1;
+		padding: 16px 0;
+		background: var(--bg);
+
+		> .button {
+			min-width: 0;
+		}
+	}
+
+	> .about {
+		fill: currentColor;
+		padding: 8px 0 16px 0;
+		text-align: center;
+
+		> .link {
+			display: block;
+			width: 32px;
+			margin: 0 auto;
+
+			img {
+				display: block;
+				width: 100%;
+			}
+		}
+	}
+
+	> .item {
+		position: relative;
+		display: block;
+		font-size: $ui-font-size;
+		line-height: 2.6rem;
+		text-overflow: ellipsis;
+		overflow: hidden;
+		white-space: nowrap;
+		width: 100%;
+		text-align: left;
+		box-sizing: border-box;
+
+		> i {
+			width: 32px;
+		}
+
+		> i,
+		> .avatar {
+			margin-right: $avatar-margin;
+		}
+
+		> .avatar {
+			width: $avatar-size;
+			height: $avatar-size;
+			vertical-align: middle;
+		}
+
+		> .indicator {
+			position: absolute;
+			top: 0;
+			left: 0;
+			color: var(--navIndicator);
+			font-size: 8px;
+			animation: blink 1s infinite;
+		}
+
+		&:hover {
+			text-decoration: none;
+			color: var(--navHoverFg);
+		}
+
+		&.active {
+			color: var(--navActive);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
new file mode 100644
index 0000000000..0e726c11ed
--- /dev/null
+++ b/packages/frontend/src/ui/classic.vue
@@ -0,0 +1,320 @@
+<template>
+<div class="gbhvwtnk" :class="{ wallpaper }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`">
+	<XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/>
+
+	<div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }">
+		<div v-if="!showMenuOnTop" class="sidebar">
+			<XSidebar/>
+		</div>
+		<div v-else ref="widgetsLeft" class="widgets left">
+			<XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/>
+		</div>
+
+		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
+			<div class="content">
+				<RouterView/>
+			</div>
+		</main>
+
+		<div v-if="isDesktop" ref="widgetsRight" class="widgets right">
+			<XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/>
+		</div>
+	</div>
+
+	<transition :name="$store.state.animation ? 'tray-back' : ''">
+		<div
+			v-if="widgetsShowing"
+			class="tray-back _modalBg"
+			@click="widgetsShowing = false"
+			@touchstart.passive="widgetsShowing = false"
+		></div>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'tray' : ''">
+		<XWidgets v-if="widgetsShowing" class="tray"/>
+	</transition>
+
+	<iframe v-if="$store.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue';
+import XSidebar from './classic.sidebar.vue';
+import XCommon from './_common_/common.vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
+const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue'));
+
+const DESKTOP_THRESHOLD = 1100;
+
+let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let widgetsShowing = $ref(false);
+let fullView = $ref(false);
+let globalHeaderHeight = $ref(0);
+const wallpaper = localStorage.getItem('wallpaper') != null;
+const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top');
+let live2d = $ref<HTMLIFrameElement>();
+let widgetsLeft = $ref();
+let widgetsRight = $ref();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+provide('shouldHeaderThin', showMenuOnTop);
+provide('shouldSpacerMin', true);
+
+function attachSticky(el) {
+	const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
+	window.addEventListener('scroll', () => {
+		sticky.calc(window.scrollY);
+	}, { passive: true });
+}
+
+function top() {
+	window.scroll({ top: 0, behavior: 'smooth' });
+}
+
+function onContextmenu(ev: MouseEvent) {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
+	if (window.getSelection().toString() !== '') return;
+	const path = mainRouter.getCurrentPath();
+	os.contextMenu([{
+		type: 'label',
+		text: path,
+	}, {
+		icon: fullView ? 'fas fa-compress' : 'fas fa-expand',
+		text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView,
+		action: () => {
+			fullView = !fullView;
+		},
+	}, {
+		icon: 'ti ti-window-maximize',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(path);
+		},
+	}], ev);
+}
+
+function onAiClick(ev) {
+	//if (this.live2d) this.live2d.click(ev);
+}
+
+if (window.innerWidth < 1024) {
+	localStorage.setItem('ui', 'default');
+	location.reload();
+}
+
+document.documentElement.style.overflowY = 'scroll';
+
+if (defaultStore.state.widgets.length === 0) {
+	defaultStore.set('widgets', [{
+		name: 'calendar',
+		id: 'a', place: null, data: {},
+	}, {
+		name: 'notifications',
+		id: 'b', place: null, data: {},
+	}, {
+		name: 'trends',
+		id: 'c', place: null, data: {},
+	}]);
+}
+
+onMounted(() => {
+	window.addEventListener('resize', () => {
+		isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
+	}, { passive: true });
+
+	if (defaultStore.state.aiChanMode) {
+		const iframeRect = live2d.getBoundingClientRect();
+		window.addEventListener('mousemove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.clientX - iframeRect.left,
+					y: ev.clientY - iframeRect.top,
+				},
+			}, '*');
+		}, { passive: true });
+		window.addEventListener('touchmove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.touches[0].clientX - iframeRect.left,
+					y: ev.touches[0].clientY - iframeRect.top,
+				},
+			}, '*');
+		}, { passive: true });
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+	opacity: 0;
+	transform: translateX(240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+	opacity: 0;
+}
+
+.gbhvwtnk {
+	$ui-font-size: 1em;
+	$widgets-hide-threshold: 1200px;
+
+	min-height: 100dvh;
+	box-sizing: border-box;
+
+	&.wallpaper {
+		background: var(--wallpaperOverlay);
+		//backdrop-filter: var(--blur, blur(4px));
+	}
+
+	> .columns {
+		display: flex;
+		justify-content: center;
+		max-width: 100%;
+		//margin: 32px 0;
+
+		&.fullView {
+			margin: 0;
+		
+			> .sidebar {
+				display: none;
+			}
+
+			> .widgets {
+				display: none;
+			}
+
+			> .main {
+				margin: 0;
+				border-radius: 0;
+				box-shadow: none;
+				width: 100%;
+			}
+		}
+
+		> .main {
+			min-width: 0;
+			width: 750px;
+			margin: 0 16px 0 0;
+			background: var(--panel);
+			border-left: solid 1px var(--divider);
+			border-right: solid 1px var(--divider);
+			border-radius: 0;
+			overflow: clip;
+			--margin: 12px;
+		}
+
+		> .widgets {
+			//--panelBorder: none;
+			width: 300px;
+			margin-top: 16px;
+
+			@media (max-width: $widgets-hide-threshold) {
+				display: none;
+			}
+
+			&.left {
+				margin-right: 16px;
+			}
+		}
+
+		> .sidebar {
+			margin-top: 16px;
+		}
+
+		&.withGlobalHeader {
+			> .main {
+				margin-top: 0;
+				border: solid 1px var(--divider);
+				border-radius: var(--radius);
+				--stickyTop: var(--globalHeaderHeight);
+			}
+
+			> .widgets {
+				--stickyTop: var(--globalHeaderHeight);
+				margin-top: 0;
+			}
+		}
+
+		@media (max-width: 850px) {
+			margin: 0;
+
+			> .sidebar {
+				border-right: solid 0.5px var(--divider);
+			}
+
+			> .main {
+				margin: 0;
+				border-radius: 0;
+				box-shadow: none;
+				width: 100%;
+			}
+		}
+	}
+
+	> .tray-back {
+		z-index: 1001;
+	}
+
+	> .tray {
+		position: fixed;
+		top: 0;
+		right: 0;
+		z-index: 1001;
+		height: 100dvh;
+		padding: var(--margin);
+		box-sizing: border-box;
+		overflow: auto;
+		background: var(--bg);
+	}
+
+	> .ivnzpscs {
+		position: fixed;
+		bottom: 0;
+		right: 0;
+		width: 300px;
+		height: 600px;
+		border: none;
+		pointer-events: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/classic.widgets.vue b/packages/frontend/src/ui/classic.widgets.vue
new file mode 100644
index 0000000000..163ec982ce
--- /dev/null
+++ b/packages/frontend/src/ui/classic.widgets.vue
@@ -0,0 +1,84 @@
+<template>
+<div class="ddiqwdnk">
+	<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+	<MkAd class="a" :prefer="['square']"/>
+
+	<button v-if="editMode" class="_textButton edit" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ $ts.editWidgetsExit }}</button>
+	<button v-else class="_textButton edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ $ts.editWidgets }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XWidgets from '@/components/MkWidgets.vue';
+
+export default defineComponent({
+	components: {
+		XWidgets,
+	},
+
+	props: {
+		place: {
+			type: String,
+		},
+	},
+
+	emits: ['mounted'],
+
+	data() {
+		return {
+			editMode: false,
+		};
+	},
+
+	mounted() {
+		this.$emit('mounted', this.$el);
+	},
+
+	methods: {
+		addWidget(widget) {
+			this.$store.set('widgets', [{
+				...widget,
+				place: this.place,
+			}, ...this.$store.state.widgets]);
+		},
+
+		removeWidget(widget) {
+			this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id !== widget.id));
+		},
+
+		updateWidget({ id, data }) {
+			this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
+				...w,
+				data,
+			} : w));
+		},
+
+		updateWidgets(widgets) {
+			this.$store.set('widgets', [
+				...this.$store.state.widgets.filter(w => w.place !== this.place),
+				...widgets,
+			]);
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ddiqwdnk {
+	position: sticky;
+	height: min-content;
+	box-sizing: border-box;
+	padding-bottom: 8px;
+
+	> .widgets,
+	> .a {
+		width: 300px;
+	}
+
+	> .edit {
+		display: block;
+		margin: 16px auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
new file mode 100644
index 0000000000..f3415cfd09
--- /dev/null
+++ b/packages/frontend/src/ui/deck.vue
@@ -0,0 +1,435 @@
+<template>
+<div
+	class="mk-deck" :class="[{ isMobile }]"
+>
+	<XSidebar v-if="!isMobile"/>
+
+	<div class="main">
+		<XStatusBars class="statusbars"/>
+		<div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu">
+			<template v-for="ids in layout">
+				<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+				<section
+					v-if="ids.length > 1"
+					class="folder column"
+					:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
+				>
+					<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
+				</section>
+				<DeckColumnCore
+					v-else
+					:ref="ids[0]"
+					:key="ids[0]"
+					class="column"
+					:column="columns.find(c => c.id === ids[0])"
+					:is-stacked="false"
+					:style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
+					@parent-focus="moveFocus(ids[0], $event)"
+				/>
+			</template>
+			<div v-if="layout.length === 0" class="intro _panel">
+				<div>{{ i18n.ts._deck.introduction }}</div>
+				<MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton>
+				<div>{{ i18n.ts._deck.introduction2 }}</div>
+			</div>
+			<div class="sideMenu">
+				<div class="top">
+					<button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" class="_button button" @click="changeProfile"><i class="ti ti-caret-down"></i></button>
+					<button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="ti ti-trash"></i></button>
+				</div>
+				<div class="middle">
+					<button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="ti ti-plus"></i></button>
+				</div>
+				<div class="bottom">
+					<button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="ti ti-settings"></i></button>
+				</div>
+			</div>
+		</div>
+	</div>
+
+	<div v-if="isMobile" class="buttons">
+		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.push('/')"><i class="ti ti-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button>
+		<button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button>
+	</div>
+
+	<transition :name="$store.state.animation ? 'menu-back' : ''">
+		<div
+			v-if="drawerMenuShowing"
+			class="menu-back _modalBg"
+			@click="drawerMenuShowing = false"
+			@touchstart.passive="drawerMenuShowing = false"
+		></div>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'menu' : ''">
+		<XDrawerMenu v-if="drawerMenuShowing" class="menu"/>
+	</transition>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XCommon from './_common_/common.vue';
+import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store';
+import DeckColumnCore from '@/ui/deck/column-core.vue';
+import XSidebar from '@/ui/_common_/navbar.vue';
+import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
+import MkButton from '@/components/MkButton.vue';
+import { getScrollContainer } from '@/scripts/scroll';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
+import { unisonReload } from '@/scripts/unison-reload';
+const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
+
+mainRouter.navHook = (path, flag): boolean => {
+	if (flag === 'forcePage') return false;
+	const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main');
+	if (deckStore.state.navWindow || noMainColumn) {
+		os.pageWindow(path);
+		return true;
+	}
+	return false;
+};
+
+const isMobile = ref(window.innerWidth <= 500);
+window.addEventListener('resize', () => {
+	isMobile.value = window.innerWidth <= 500;
+});
+
+const drawerMenuShowing = ref(false);
+
+const route = 'TODO';
+watch(route, () => {
+	drawerMenuShowing.value = false;
+});
+
+const columns = deckStore.reactiveState.columns;
+const layout = deckStore.reactiveState.layout;
+const menuIndicated = computed(() => {
+	if ($i == null) return false;
+	for (const def in navbarItemDef) {
+		if (navbarItemDef[def].indicated) return true;
+	}
+	return false;
+});
+
+function showSettings() {
+	os.pageWindow('/settings/deck');
+}
+
+let columnsEl = $ref<HTMLElement>();
+
+const addColumn = async (ev) => {
+	const columns = [
+		'main',
+		'widgets',
+		'notifications',
+		'tl',
+		'antenna',
+		'list',
+		'mentions',
+		'direct',
+	];
+
+	const { canceled, result: column } = await os.select({
+		title: i18n.ts._deck.addColumn,
+		items: columns.map(column => ({
+			value: column, text: i18n.t('_deck._columns.' + column),
+		})),
+	});
+	if (canceled) return;
+
+	addColumnToStore({
+		type: column,
+		id: uuid(),
+		name: i18n.t('_deck._columns.' + column),
+		width: 330,
+	});
+};
+
+const onContextmenu = (ev) => {
+	os.contextMenu([{
+		text: i18n.ts._deck.addColumn,
+		action: addColumn,
+	}], ev);
+};
+
+document.documentElement.style.overflowY = 'hidden';
+document.documentElement.style.scrollBehavior = 'auto';
+window.addEventListener('wheel', (ev) => {
+	if (ev.target === columnsEl && ev.deltaX === 0) {
+		columnsEl.scrollLeft += ev.deltaY;
+	} else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) {
+		columnsEl.scrollLeft += ev.deltaY;
+	}
+});
+loadDeck();
+
+function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
+	// TODO??
+}
+
+function changeProfile(ev: MouseEvent) {
+	const items = ref([{
+		text: deckStore.state.profile,
+		active: true.valueOf,
+	}]);
+	getProfiles().then(profiles => {
+		items.value = [{
+			text: deckStore.state.profile,
+			active: true.valueOf,
+		}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
+			text: k,
+			action: () => {
+				deckStore.set('profile', k);
+				unisonReload();
+			},
+		}))), null, {
+			text: i18n.ts._deck.newProfile,
+			icon: 'ti ti-plus',
+			action: async () => {
+				const { canceled, result: name } = await os.inputText({
+					title: i18n.ts._deck.profile,
+					allowEmpty: false,
+				});
+				if (canceled) return;
+
+				deckStore.set('profile', name);
+				unisonReload();
+			},
+		}];
+	});
+	os.popupMenu(items, ev.currentTarget ?? ev.target);
+}
+
+async function deleteProfile() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }),
+	});
+	if (canceled) return;
+
+	deleteProfile_(deckStore.state.profile);
+	deckStore.set('profile', 'default');
+	unisonReload();
+}
+</script>
+
+<style lang="scss" scoped>
+.menu-enter-active,
+.menu-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.menu-enter-from,
+.menu-leave-active {
+	opacity: 0;
+	transform: translateX(-240px);
+}
+
+.menu-back-enter-active,
+.menu-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.menu-back-enter-from,
+.menu-back-leave-active {
+	opacity: 0;
+}
+
+.mk-deck {
+	$nav-hide-threshold: 650px; // TODO: どこかに集約したい
+
+	// TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい
+	--margin: var(--marginHalf);
+
+	--deckDividerThickness: 5px;
+
+	display: flex;
+	height: 100dvh;
+	box-sizing: border-box;
+	flex: 1;
+
+	&.isMobile {
+		padding-bottom: 100px;
+	}
+
+	> .main {
+		flex: 1;
+		min-width: 0;
+		display: flex;
+		flex-direction: column;
+
+		> .columns {
+			flex: 1;
+			display: flex;
+			overflow-x: auto;
+			overflow-y: clip;
+
+			&.center {
+				> .column:first-of-type {
+					margin-left: auto;
+				}
+
+				> .column:last-of-type {
+					margin-right: auto;
+				}
+			}
+
+			> .column {
+				flex-shrink: 0;
+				border-right: solid var(--deckDividerThickness) var(--deckDivider);
+
+				&:first-of-type {
+					border-left: solid var(--deckDividerThickness) var(--deckDivider);
+				}
+
+				&.folder {
+					display: flex;
+					flex-direction: column;
+
+					> *:not(:last-of-type) {
+						border-bottom: solid var(--deckDividerThickness) var(--deckDivider);
+					}
+				}
+			}
+
+			> .intro {
+				padding: 32px;
+				height: min-content;
+				text-align: center;
+				margin: auto;
+
+				> .add {
+					margin: 1em auto;
+				}
+			}
+
+			> .sideMenu {
+				flex-shrink: 0;
+				margin-right: 0;
+				margin-left: auto;
+				display: flex;
+				flex-direction: column;
+				justify-content: center;
+				width: 32px;
+
+				> .top, > .middle, > .bottom {
+					> .button {
+						display: block;
+						width: 100%;
+						aspect-ratio: 1;
+					}
+				}
+
+				> .top {
+					margin-bottom: auto;
+				}
+
+				> .middle {
+					margin-top: auto;
+					margin-bottom: auto;
+				}
+
+				> .bottom {
+					margin-top: auto;
+				}
+			}
+		}
+	}
+
+	> .buttons {
+		position: fixed;
+		z-index: 1000;
+		bottom: 0;
+		left: 0;
+		padding: 16px;
+		display: flex;
+		width: 100%;
+		box-sizing: border-box;
+
+		> .button {
+			position: relative;
+			flex: 1;
+			padding: 0;
+			margin: auto;
+			height: 64px;
+			border-radius: 8px;
+			background: var(--panel);
+			color: var(--fg);
+
+			&:not(:last-child) {
+				margin-right: 12px;
+			}
+
+			@media (max-width: 400px) {
+				height: 60px;
+
+				&:not(:last-child) {
+					margin-right: 8px;
+				}
+			}
+
+			&:hover {
+				background: var(--X2);
+			}
+
+			> .indicator {
+				position: absolute;
+				top: 0;
+				left: 0;
+				color: var(--indicator);
+				font-size: 16px;
+				animation: blink 1s infinite;
+			}
+
+			&:first-child {
+				margin-left: 0;
+			}
+
+			&:last-child {
+				margin-right: 0;
+			}
+
+			> * {
+				font-size: 20px;
+			}
+
+			&:disabled {
+				cursor: default;
+
+				> * {
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+
+	> .menu-back {
+		z-index: 1001;
+	}
+
+	> .menu {
+		position: fixed;
+		top: 0;
+		left: 0;
+		z-index: 1001;
+		height: 100dvh;
+		width: 240px;
+		box-sizing: border-box;
+		contain: strict;
+		overflow: auto;
+		overscroll-behavior: contain;
+		background: var(--navBg);
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
new file mode 100644
index 0000000000..ba14530662
--- /dev/null
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -0,0 +1,70 @@
+<template>
+<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XColumn from './column.vue';
+import { updateColumn, Column } from './deck-store';
+import XTimeline from '@/components/MkTimeline.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'loaded'): void;
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let timeline = $ref<InstanceType<typeof XTimeline>>();
+
+onMounted(() => {
+	if (props.column.antennaId == null) {
+		setAntenna();
+	}
+});
+
+async function setAntenna() {
+	const antennas = await os.api('antennas/list');
+	const { canceled, result: antenna } = await os.select({
+		title: i18n.ts.selectAntenna,
+		items: antennas.map(x => ({
+			value: x, text: x.name,
+		})),
+		default: props.column.antennaId,
+	});
+	if (canceled) return;
+	updateColumn(props.column.id, {
+		antennaId: antenna.id,
+	});
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.selectAntenna,
+	action: setAntenna,
+}];
+
+/*
+function focus() {
+	timeline.focus();
+}
+
+defineExpose({
+	focus,
+});
+*/
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue
new file mode 100644
index 0000000000..30c0dc5e1c
--- /dev/null
+++ b/packages/frontend/src/ui/deck/column-core.vue
@@ -0,0 +1,34 @@
+<template>
+<!-- TODO: リファクタの余地がありそう -->
+<div v-if="!column">たぶん見えちゃいけないやつ</div>
+<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XMainColumn from './main-column.vue';
+import XTlColumn from './tl-column.vue';
+import XAntennaColumn from './antenna-column.vue';
+import XListColumn from './list-column.vue';
+import XNotificationsColumn from './notifications-column.vue';
+import XWidgetsColumn from './widgets-column.vue';
+import XMentionsColumn from './mentions-column.vue';
+import XDirectColumn from './direct-column.vue';
+import { Column } from './deck-store';
+
+defineProps<{
+	column?: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+</script>
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
new file mode 100644
index 0000000000..2a99b621e6
--- /dev/null
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -0,0 +1,398 @@
+<template>
+<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+<section
+	v-hotkey="keymap" class="dnpfarvg _narrow_"
+	:class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }"
+	@dragover.prevent.stop="onDragover"
+	@dragleave="onDragleave"
+	@drop.prevent.stop="onDrop"
+>
+	<header
+		:class="{ indicated }"
+		draggable="true"
+		@click="goTop"
+		@dragstart="onDragstart"
+		@dragend="onDragend"
+		@contextmenu.prevent.stop="onContextmenu"
+	>
+		<button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive">
+			<template v-if="active"><i class="ti ti-chevron-up"></i></template>
+			<template v-else><i class="ti ti-chevron-down"></i></template>
+		</button>
+		<div class="action">
+			<slot name="action"></slot>
+		</div>
+		<span class="header"><slot name="header"></slot></span>
+		<button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button>
+	</header>
+	<div v-show="active" ref="body">
+		<slot></slot>
+	</div>
+</section>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue';
+import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column, deckStore } from './deck-store';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { MenuItem } from '@/types/menu';
+
+provide('shouldHeaderThin', true);
+provide('shouldOmitHeaderTitle', true);
+provide('shouldSpacerMin', true);
+
+const props = withDefaults(defineProps<{
+	column: Column;
+	isStacked?: boolean;
+	naked?: boolean;
+	indicated?: boolean;
+	menu?: MenuItem[];
+}>(), {
+	isStacked: false,
+	naked: false,
+	indicated: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+	(ev: 'change-active-state', v: boolean): void;
+}>();
+
+let body = $ref<HTMLDivElement>();
+
+let dragging = $ref(false);
+watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
+
+let draghover = $ref(false);
+let dropready = $ref(false);
+
+const isMainColumn = $computed(() => props.column.type === 'main');
+const active = $computed(() => props.column.active !== false);
+watch($$(active), v => emit('change-active-state', v));
+
+const keymap = $computed(() => ({
+	'shift+up': () => emit('parent-focus', 'up'),
+	'shift+down': () => emit('parent-focus', 'down'),
+	'shift+left': () => emit('parent-focus', 'left'),
+	'shift+right': () => emit('parent-focus', 'right'),
+}));
+
+onMounted(() => {
+	os.deckGlobalEvents.on('column.dragStart', onOtherDragStart);
+	os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd);
+});
+
+onBeforeUnmount(() => {
+	os.deckGlobalEvents.off('column.dragStart', onOtherDragStart);
+	os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd);
+});
+
+function onOtherDragStart() {
+	dropready = true;
+}
+
+function onOtherDragEnd() {
+	dropready = false;
+}
+
+function toggleActive() {
+	if (!props.isStacked) return;
+	updateColumn(props.column.id, {
+		active: !props.column.active,
+	});
+}
+
+function getMenu() {
+	let items = [{
+		icon: 'ti ti-settings',
+		text: i18n.ts._deck.configureColumn,
+		action: async () => {
+			const { canceled, result } = await os.form(props.column.name, {
+				name: {
+					type: 'string',
+					label: i18n.ts.name,
+					default: props.column.name,
+				},
+				width: {
+					type: 'number',
+					label: i18n.ts.width,
+					default: props.column.width,
+				},
+				flexible: {
+					type: 'boolean',
+					label: i18n.ts.flexible,
+					default: props.column.flexible,
+				},
+			});
+			if (canceled) return;
+			updateColumn(props.column.id, result);
+		},
+	}, {
+		type: 'parent',
+		text: i18n.ts.move + '...',
+		icon: 'ti ti-arrows-move',
+		children: [{
+			icon: 'ti ti-arrow-left',
+			text: i18n.ts._deck.swapLeft,
+			action: () => {
+				swapLeftColumn(props.column.id);
+			},
+		}, {
+			icon: 'ti ti-arrow-right',
+			text: i18n.ts._deck.swapRight,
+			action: () => {
+				swapRightColumn(props.column.id);
+			},
+		}, props.isStacked ? {
+			icon: 'ti ti-arrow-up',
+			text: i18n.ts._deck.swapUp,
+			action: () => {
+				swapUpColumn(props.column.id);
+			},
+		} : undefined, props.isStacked ? {
+			icon: 'ti ti-arrow-down',
+			text: i18n.ts._deck.swapDown,
+			action: () => {
+				swapDownColumn(props.column.id);
+			},
+		} : undefined],
+	}, {
+		icon: 'ti ti-stack-2',
+		text: i18n.ts._deck.stackLeft,
+		action: () => {
+			stackLeftColumn(props.column.id);
+		},
+	}, props.isStacked ? {
+		icon: 'ti ti-window-maximize',
+		text: i18n.ts._deck.popRight,
+		action: () => {
+			popRightColumn(props.column.id);
+		},
+	} : undefined, null, {
+		icon: 'ti ti-trash',
+		text: i18n.ts.remove,
+		danger: true,
+		action: () => {
+			removeColumn(props.column.id);
+		},
+	}];
+
+	if (props.menu) {
+		items.unshift(null);
+		items = props.menu.concat(items);
+	}
+
+	return items;
+}
+
+function showSettingsMenu(ev: MouseEvent) {
+	os.popupMenu(getMenu(), ev.currentTarget ?? ev.target);
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(getMenu(), ev);
+}
+
+function goTop() {
+	body.scrollTo({
+		top: 0,
+		behavior: 'smooth',
+	});
+}
+
+function onDragstart(ev) {
+	ev.dataTransfer.effectAllowed = 'move';
+	ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id);
+
+	// Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
+	// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
+	window.setTimeout(() => {
+		dragging = true;
+	}, 10);
+}
+
+function onDragend(ev) {
+	dragging = false;
+}
+
+function onDragover(ev) {
+	// 自分自身がドラッグされている場合
+	if (dragging) {
+		// 自分自身にはドロップさせない
+		ev.dataTransfer.dropEffect = 'none';
+	} else {
+		const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_;
+
+		ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
+
+		if (isDeckColumn) draghover = true;
+	}
+}
+
+function onDragleave() {
+	draghover = false;
+}
+
+function onDrop(ev) {
+	draghover = false;
+	os.deckGlobalEvents.emit('column.dragEnd');
+
+	const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_);
+	if (id != null && id !== '') {
+		swapColumn(props.column.id, id);
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.dnpfarvg {
+	--root-margin: 10px;
+	--deckColumnHeaderHeight: 40px;
+
+	height: 100%;
+	overflow: hidden;
+	contain: strict;
+
+	&.draghover {
+		&:after {
+			content: "";
+			display: block;
+			position: absolute;
+			z-index: 1000;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--focus);
+		}
+	}
+
+	&.dragging {
+		&:after {
+			content: "";
+			display: block;
+			position: absolute;
+			z-index: 1000;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--focus);
+			opacity: 0.5;
+		}
+	}
+
+	&.dropready {
+		* {
+			pointer-events: none;
+		}
+	}
+
+	&:not(.active) {
+		flex-basis: var(--deckColumnHeaderHeight);
+		min-height: var(--deckColumnHeaderHeight);
+
+		> header.indicated {
+			box-shadow: 4px 0px var(--accent) inset;
+		}
+	}
+
+	&.naked {
+		background: var(--acrylicBg) !important;
+		-webkit-backdrop-filter: var(--blur, blur(10px));
+		backdrop-filter: var(--blur, blur(10px));
+
+		> header {
+			background: transparent;
+			box-shadow: none;
+
+			> button {
+				color: var(--fg);
+			}
+		}
+	}
+
+	&.paged {
+		background: var(--bg) !important;
+	}
+
+	> header {
+		position: relative;
+		display: flex;
+		z-index: 2;
+		line-height: var(--deckColumnHeaderHeight);
+		height: var(--deckColumnHeaderHeight);
+		padding: 0 16px;
+		font-size: 0.9em;
+		color: var(--panelHeaderFg);
+		background: var(--panelHeaderBg);
+		box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
+		cursor: pointer;
+
+		&, * {
+			user-select: none;
+		}
+
+		&.indicated {
+			box-shadow: 0 3px 0 0 var(--accent);
+		}
+
+		> .header {
+			display: inline-block;
+			align-items: center;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+
+		> span:only-of-type {
+			width: 100%;
+		}
+
+		> .toggleActive,
+		> .action > ::v-deep(*),
+		> .menu {
+			z-index: 1;
+			width: var(--deckColumnHeaderHeight);
+			line-height: var(--deckColumnHeaderHeight);
+			color: var(--faceTextButton);
+
+			&:hover {
+				color: var(--faceTextButtonHover);
+			}
+
+			&:active {
+				color: var(--faceTextButtonActive);
+			}
+		}
+
+		> .toggleActive, > .action {
+			margin-left: -16px;
+		}
+
+		> .action {
+			z-index: 1;
+		}
+
+		> .action:empty {
+			display: none;
+		}
+
+		> .menu {
+			margin-left: auto;
+			margin-right: -16px;
+		}
+	}
+
+	> div {
+		height: calc(100% - var(--deckColumnHeaderHeight));
+		overflow-y: auto;
+		overflow-x: hidden; // Safari does not supports clip
+		overflow-x: clip;
+		-webkit-overflow-scrolling: touch;
+		box-sizing: border-box;
+		background-color: var(--bg);
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
new file mode 100644
index 0000000000..56db7398e5
--- /dev/null
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -0,0 +1,296 @@
+import { throttle } from 'throttle-debounce';
+import { markRaw } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import { Storage } from '../../pizzax';
+import { i18n } from '@/i18n';
+import { api } from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+type ColumnWidget = {
+	name: string;
+	id: string;
+	data: Record<string, any>;
+};
+
+export type Column = {
+	id: string;
+	type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct';
+	name: string | null;
+	width: number;
+	widgets?: ColumnWidget[];
+	active?: boolean;
+	flexible?: boolean;
+	antennaId?: string;
+	listId?: string;
+	includingTypes?: typeof notificationTypes[number][];
+	tl?: 'home' | 'local' | 'social' | 'global';
+};
+
+export const deckStore = markRaw(new Storage('deck', {
+	profile: {
+		where: 'deviceAccount',
+		default: 'default',
+	},
+	columns: {
+		where: 'deviceAccount',
+		default: [] as Column[],
+	},
+	layout: {
+		where: 'deviceAccount',
+		default: [] as Column['id'][][],
+	},
+	columnAlign: {
+		where: 'deviceAccount',
+		default: 'left' as 'left' | 'right' | 'center',
+	},
+	alwaysShowMainColumn: {
+		where: 'deviceAccount',
+		default: true,
+	},
+	navWindow: {
+		where: 'deviceAccount',
+		default: true,
+	},
+}));
+
+export const loadDeck = async () => {
+	let deck;
+
+	try {
+		deck = await api('i/registry/get', {
+			scope: ['client', 'deck', 'profiles'],
+			key: deckStore.state.profile,
+		});
+	} catch (err) {
+		if (err.code === 'NO_SUCH_KEY') {
+			// 後方互換性のため
+			if (deckStore.state.profile === 'default') {
+				saveDeck();
+				return;
+			}
+
+			deckStore.set('columns', []);
+			deckStore.set('layout', []);
+			return;
+		}
+		throw err;
+	}
+
+	deckStore.set('columns', deck.columns);
+	deckStore.set('layout', deck.layout);
+};
+
+// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
+export const saveDeck = throttle(1000, () => {
+	api('i/registry/set', {
+		scope: ['client', 'deck', 'profiles'],
+		key: deckStore.state.profile,
+		value: {
+			columns: deckStore.reactiveState.columns.value,
+			layout: deckStore.reactiveState.layout.value,
+		},
+	});
+});
+
+export async function getProfiles(): Promise<string[]> {
+	return await api('i/registry/keys', {
+		scope: ['client', 'deck', 'profiles'],
+	});
+}
+
+export async function deleteProfile(key: string): Promise<void> {
+	return await api('i/registry/remove', {
+		scope: ['client', 'deck', 'profiles'],
+		key: key,
+	});
+}
+
+export function addColumn(column: Column) {
+	if (column.name === undefined) column.name = null;
+	deckStore.push('columns', column);
+	deckStore.push('layout', [column.id]);
+	saveDeck();
+}
+
+export function removeColumn(id: Column['id']) {
+	deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id));
+	deckStore.set('layout', deckStore.state.layout
+		.map(ids => ids.filter(_id => _id !== id))
+		.filter(ids => ids.length > 0));
+	saveDeck();
+}
+
+export function swapColumn(a: Column['id'], b: Column['id']) {
+	const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1);
+	const aY = deckStore.state.layout[aX].findIndex(id => id === a);
+	const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1);
+	const bY = deckStore.state.layout[bX].findIndex(id => id === b);
+	const layout = deepClone(deckStore.state.layout);
+	layout[aX][aY] = b;
+	layout[bX][bY] = a;
+	deckStore.set('layout', layout);
+	saveDeck();
+}
+
+export function swapLeftColumn(id: Column['id']) {
+	const layout = deepClone(deckStore.state.layout);
+	deckStore.state.layout.some((ids, i) => {
+		if (ids.includes(id)) {
+			const left = deckStore.state.layout[i - 1];
+			if (left) {
+				layout[i - 1] = deckStore.state.layout[i];
+				layout[i] = left;
+				deckStore.set('layout', layout);
+			}
+			return true;
+		}
+	});
+	saveDeck();
+}
+
+export function swapRightColumn(id: Column['id']) {
+	const layout = deepClone(deckStore.state.layout);
+	deckStore.state.layout.some((ids, i) => {
+		if (ids.includes(id)) {
+			const right = deckStore.state.layout[i + 1];
+			if (right) {
+				layout[i + 1] = deckStore.state.layout[i];
+				layout[i] = right;
+				deckStore.set('layout', layout);
+			}
+			return true;
+		}
+	});
+	saveDeck();
+}
+
+export function swapUpColumn(id: Column['id']) {
+	const layout = deepClone(deckStore.state.layout);
+	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
+	const ids = deepClone(deckStore.state.layout[idsIndex]);
+	ids.some((x, i) => {
+		if (x === id) {
+			const up = ids[i - 1];
+			if (up) {
+				ids[i - 1] = id;
+				ids[i] = up;
+
+				layout[idsIndex] = ids;
+				deckStore.set('layout', layout);
+			}
+			return true;
+		}
+	});
+	saveDeck();
+}
+
+export function swapDownColumn(id: Column['id']) {
+	const layout = deepClone(deckStore.state.layout);
+	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
+	const ids = deepClone(deckStore.state.layout[idsIndex]);
+	ids.some((x, i) => {
+		if (x === id) {
+			const down = ids[i + 1];
+			if (down) {
+				ids[i + 1] = id;
+				ids[i] = down;
+
+				layout[idsIndex] = ids;
+				deckStore.set('layout', layout);
+			}
+			return true;
+		}
+	});
+	saveDeck();
+}
+
+export function stackLeftColumn(id: Column['id']) {
+	let layout = deepClone(deckStore.state.layout);
+	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
+	layout = layout.map(ids => ids.filter(_id => _id !== id));
+	layout[i - 1].push(id);
+	layout = layout.filter(ids => ids.length > 0);
+	deckStore.set('layout', layout);
+	saveDeck();
+}
+
+export function popRightColumn(id: Column['id']) {
+	let layout = deepClone(deckStore.state.layout);
+	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
+	const affected = layout[i];
+	layout = layout.map(ids => ids.filter(_id => _id !== id));
+	layout.splice(i + 1, 0, [id]);
+	layout = layout.filter(ids => ids.length > 0);
+	deckStore.set('layout', layout);
+
+	const columns = deepClone(deckStore.state.columns);
+	for (const column of columns) {
+		if (affected.includes(column.id)) {
+			column.active = true;
+		}
+	}
+	deckStore.set('columns', columns);
+
+	saveDeck();
+}
+
+export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
+	const columns = deepClone(deckStore.state.columns);
+	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+	const column = deepClone(deckStore.state.columns[columnIndex]);
+	if (column == null) return;
+	if (column.widgets == null) column.widgets = [];
+	column.widgets.unshift(widget);
+	columns[columnIndex] = column;
+	deckStore.set('columns', columns);
+	saveDeck();
+}
+
+export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
+	const columns = deepClone(deckStore.state.columns);
+	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+	const column = deepClone(deckStore.state.columns[columnIndex]);
+	if (column == null) return;
+	column.widgets = column.widgets.filter(w => w.id !== widget.id);
+	columns[columnIndex] = column;
+	deckStore.set('columns', columns);
+	saveDeck();
+}
+
+export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
+	const columns = deepClone(deckStore.state.columns);
+	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+	const column = deepClone(deckStore.state.columns[columnIndex]);
+	if (column == null) return;
+	column.widgets = widgets;
+	columns[columnIndex] = column;
+	deckStore.set('columns', columns);
+	saveDeck();
+}
+
+export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
+	const columns = deepClone(deckStore.state.columns);
+	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+	const column = deepClone(deckStore.state.columns[columnIndex]);
+	if (column == null) return;
+	column.widgets = column.widgets.map(w => w.id === widgetId ? {
+		...w,
+		data: widgetData,
+	} : w);
+	columns[columnIndex] = column;
+	deckStore.set('columns', columns);
+	saveDeck();
+}
+
+export function updateColumn(id: Column['id'], column: Partial<Column>) {
+	const columns = deepClone(deckStore.state.columns);
+	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+	const currentColumn = deepClone(deckStore.state.columns[columnIndex]);
+	if (currentColumn == null) return;
+	for (const [k, v] of Object.entries(column)) {
+		currentColumn[k] = v;
+	}
+	columns[columnIndex] = currentColumn;
+	deckStore.set('columns', columns);
+	saveDeck();
+}
diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue
new file mode 100644
index 0000000000..75b018cacd
--- /dev/null
+++ b/packages/frontend/src/ui/deck/direct-column.vue
@@ -0,0 +1,31 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+	<XNotes :pagination="pagination"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XColumn from './column.vue';
+import XNotes from '@/components/MkNotes.vue';
+import { Column } from './deck-store';
+
+defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+const pagination = {
+	endpoint: 'notes/mentions' as const,
+	limit: 10,
+	params: {
+		visibility: 'specified',
+	},
+};
+</script>
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
new file mode 100644
index 0000000000..d9f3f7b4e7
--- /dev/null
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -0,0 +1,58 @@
+<template>
+<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XColumn from './column.vue';
+import { updateColumn, Column } from './deck-store';
+import XTimeline from '@/components/MkTimeline.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'loaded'): void;
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let timeline = $ref<InstanceType<typeof XTimeline>>();
+
+if (props.column.listId == null) {
+	setList();
+}
+
+async function setList() {
+	const lists = await os.api('users/lists/list');
+	const { canceled, result: list } = await os.select({
+		title: i18n.ts.selectList,
+		items: lists.map(x => ({
+			value: x, text: x.name,
+		})),
+		default: props.column.listId,
+	});
+	if (canceled) return;
+	updateColumn(props.column.id, {
+		listId: list.id,
+	});
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.selectList,
+	action: setList,
+}];
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
new file mode 100644
index 0000000000..0c66172397
--- /dev/null
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -0,0 +1,68 @@
+<template>
+<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<template v-if="pageMetadata?.value">
+			<i :class="pageMetadata?.value.icon"></i>
+			{{ pageMetadata?.value.title }}
+		</template>
+	</template>
+
+	<RouterView @contextmenu.stop="onContextmenu"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { ComputedRef, provide } from 'vue';
+import XColumn from './column.vue';
+import { deckStore, Column } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+
+/*
+function back() {
+	history.back();
+}
+*/
+function onContextmenu(ev: MouseEvent) {
+	if (!ev.target) return;
+
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target as HTMLElement)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return;
+	if (window.getSelection()?.toString() !== '') return;
+	const path = mainRouter.currentRoute.value.path;
+	os.contextMenu([{
+		type: 'label',
+		text: path,
+	}, {
+		icon: 'ti ti-window-maximize',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(path);
+		},
+	}], ev);
+}
+</script>
diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue
new file mode 100644
index 0000000000..16962956a0
--- /dev/null
+++ b/packages/frontend/src/ui/deck/mentions-column.vue
@@ -0,0 +1,28 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+	<XNotes :pagination="pagination"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XColumn from './column.vue';
+import XNotes from '@/components/MkNotes.vue';
+import { Column } from './deck-store';
+
+defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+const pagination = {
+	endpoint: 'notes/mentions' as const,
+	limit: 10,
+};
+</script>
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
new file mode 100644
index 0000000000..9d133035fe
--- /dev/null
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -0,0 +1,44 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+	<XNotifications :include-types="column.includingTypes"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import XColumn from './column.vue';
+import { updateColumn, Column } from './deck-store';
+import XNotifications from '@/components/MkNotifications.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+function func() {
+	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
+		includingTypes: props.column.includingTypes,
+	}, {
+		done: async (res) => {
+			const { includingTypes } = res;
+			updateColumn(props.column.id, {
+				includingTypes: includingTypes,
+			});
+		},
+	}, 'closed');
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.notificationSetting,
+	action: func,
+}];
+</script>
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
new file mode 100644
index 0000000000..49b29145ff
--- /dev/null
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -0,0 +1,119 @@
+<template>
+<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<i v-if="column.tl === 'home'" class="ti ti-home"></i>
+		<i v-else-if="column.tl === 'local'" class="ti ti-messages"></i>
+		<i v-else-if="column.tl === 'social'" class="ti ti-share"></i>
+		<i v-else-if="column.tl === 'global'" class="ti ti-world"></i>
+		<span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<div v-if="disabled" class="iwaalbte">
+		<p>
+			<i class="ti ti-minus-circle"></i>
+			{{ $t('disabled-timeline.title') }}
+		</p>
+		<p class="desc">{{ $t('disabled-timeline.description') }}</p>
+	</div>
+	<XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XColumn from './column.vue';
+import { removeColumn, updateColumn, Column } from './deck-store';
+import XTimeline from '@/components/MkTimeline.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'loaded'): void;
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let disabled = $ref(false);
+let indicated = $ref(false);
+let columnActive = $ref(true);
+
+onMounted(() => {
+	if (props.column.tl == null) {
+		setType();
+	} else if ($i) {
+		disabled = !$i.isModerator && !$i.isAdmin && (
+			instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) ||
+			instance.disableGlobalTimeline && ['global'].includes(props.column.tl));
+	}
+});
+
+async function setType() {
+	const { canceled, result: src } = await os.select({
+		title: i18n.ts.timeline,
+		items: [{
+			value: 'home' as const, text: i18n.ts._timelines.home,
+		}, {
+			value: 'local' as const, text: i18n.ts._timelines.local,
+		}, {
+			value: 'social' as const, text: i18n.ts._timelines.social,
+		}, {
+			value: 'global' as const, text: i18n.ts._timelines.global,
+		}],
+	});
+	if (canceled) {
+		if (props.column.tl == null) {
+			removeColumn(props.column.id);
+		}
+		return;
+	}
+	updateColumn(props.column.id, {
+		tl: src,
+	});
+}
+
+function queueUpdated(q) {
+	if (columnActive) {
+		indicated = q !== 0;
+	}
+}
+
+function onNote() {
+	if (!columnActive) {
+		indicated = true;
+	}
+}
+
+function onChangeActiveState(state) {
+	columnActive = state;
+
+	if (columnActive) {
+		indicated = false;
+	}
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.timeline,
+	action: setType,
+}];
+</script>
+
+<style lang="scss" scoped>
+.iwaalbte {
+	text-align: center;
+
+	> p {
+		margin: 16px;
+
+		&.desc {
+			font-size: 14px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue
new file mode 100644
index 0000000000..fc61d18ff6
--- /dev/null
+++ b/packages/frontend/src/ui/deck/widgets-column.vue
@@ -0,0 +1,69 @@
+<template>
+<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+	<div class="wtdtxvec">
+		<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div>
+		<XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
+	</div>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XColumn from './column.vue';
+import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
+import XWidgets from '@/components/MkWidgets.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let edit = $ref(false);
+
+function addWidget(widget) {
+	addColumnWidget(props.column.id, widget);
+}
+
+function removeWidget(widget) {
+	removeColumnWidget(props.column.id, widget);
+}
+
+function updateWidget({ id, data }) {
+	updateColumnWidget(props.column.id, id, data);
+}
+
+function updateWidgets(widgets) {
+	setColumnWidgets(props.column.id, widgets);
+}
+
+function func() {
+	edit = !edit;
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.editWidgets,
+	action: func,
+}];
+</script>
+
+<style lang="scss" scoped>
+.wtdtxvec {
+	--margin: 8px;
+	--panelBorder: none;
+
+	padding: 0 var(--margin);
+
+	> .intro {
+		padding: 16px;
+		text-align: center;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
new file mode 100644
index 0000000000..b91bf476e8
--- /dev/null
+++ b/packages/frontend/src/ui/universal.vue
@@ -0,0 +1,390 @@
+<template>
+<div class="dkgtipfy" :class="{ wallpaper }">
+	<XSidebar v-if="!isMobile" class="sidebar"/>
+
+	<MkStickyContainer class="contents">
+		<template #header><XStatusBars :class="$style.statusbars"/></template>
+		<main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
+			<div :class="$style.content">
+				<RouterView/>
+			</div>
+			<div :class="$style.spacer"></div>
+		</main>
+	</MkStickyContainer>
+
+	<div v-if="isDesktop" ref="widgetsEl" class="widgets">
+		<XWidgets @mounted="attachSticky"/>
+	</div>
+
+	<button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
+
+	<div v-if="isMobile" class="buttons">
+		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="ti ti-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button>
+		<button class="button widget _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
+		<button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button>
+	</div>
+
+	<transition :name="$store.state.animation ? 'menuDrawer-back' : ''">
+		<div
+			v-if="drawerMenuShowing"
+			class="menuDrawer-back _modalBg"
+			@click="drawerMenuShowing = false"
+			@touchstart.passive="drawerMenuShowing = false"
+		></div>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'menuDrawer' : ''">
+		<XDrawerMenu v-if="drawerMenuShowing" class="menuDrawer"/>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''">
+		<div
+			v-if="widgetsShowing"
+			class="widgetsDrawer-back _modalBg"
+			@click="widgetsShowing = false"
+			@touchstart.passive="widgetsShowing = false"
+		></div>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'widgetsDrawer' : ''">
+		<XWidgets v-if="widgetsShowing" class="widgetsDrawer"/>
+	</transition>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue';
+import XCommon from './_common_/common.vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { navbarItemDef } from '@/navbar';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { Router } from '@/nirax';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { deviceKind } from '@/scripts/device-kind';
+const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
+const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
+const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
+
+const DESKTOP_THRESHOLD = 1100;
+const MOBILE_THRESHOLD = 500;
+
+// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない
+const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
+const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
+window.addEventListener('resize', () => {
+	isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD;
+});
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+const widgetsEl = $ref<HTMLElement>();
+const widgetsShowing = $ref(false);
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+
+const menuIndicated = computed(() => {
+	for (const def in navbarItemDef) {
+		if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
+		if (navbarItemDef[def].indicated) return true;
+	}
+	return false;
+});
+
+const drawerMenuShowing = ref(false);
+
+mainRouter.on('change', () => {
+	drawerMenuShowing.value = false;
+});
+
+document.documentElement.style.overflowY = 'scroll';
+
+if (defaultStore.state.widgets.length === 0) {
+	defaultStore.set('widgets', [{
+		name: 'calendar',
+		id: 'a', place: 'right', data: {},
+	}, {
+		name: 'notifications',
+		id: 'b', place: 'right', data: {},
+	}, {
+		name: 'trends',
+		id: 'c', place: 'right', data: {},
+	}]);
+}
+
+onMounted(() => {
+	if (!isDesktop.value) {
+		window.addEventListener('resize', () => {
+			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop.value = true;
+		}, { passive: true });
+	}
+});
+
+const onContextmenu = (ev) => {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
+		}
+	};
+	if (isLink(ev.target)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
+	if (window.getSelection()?.toString() !== '') return;
+	const path = mainRouter.getCurrentPath();
+	os.contextMenu([{
+		type: 'label',
+		text: path,
+	}, {
+		icon: 'ti ti-window-maximize',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(path);
+		},
+	}], ev);
+};
+
+const attachSticky = (el) => {
+	const sticky = new StickySidebar(widgetsEl);
+	window.addEventListener('scroll', () => {
+		sticky.calc(window.scrollY);
+	}, { passive: true });
+};
+
+function top() {
+	window.scroll({ top: 0, behavior: 'smooth' });
+}
+
+const wallpaper = localStorage.getItem('wallpaper') != null;
+</script>
+
+<style lang="scss" scoped>
+.widgetsDrawer-enter-active,
+.widgetsDrawer-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.widgetsDrawer-enter-from,
+.widgetsDrawer-leave-active {
+	opacity: 0;
+	transform: translateX(240px);
+}
+
+.widgetsDrawer-back-enter-active,
+.widgetsDrawer-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.widgetsDrawer-back-enter-from,
+.widgetsDrawer-back-leave-active {
+	opacity: 0;
+}
+
+.menuDrawer-enter-active,
+.menuDrawer-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.menuDrawer-enter-from,
+.menuDrawer-leave-active {
+	opacity: 0;
+	transform: translateX(-240px);
+}
+
+.menuDrawer-back-enter-active,
+.menuDrawer-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.menuDrawer-back-enter-from,
+.menuDrawer-back-leave-active {
+	opacity: 0;
+}
+
+.dkgtipfy {
+	$ui-font-size: 1em; // TODO: どこかに集約したい
+	$widgets-hide-threshold: 1090px;
+
+	min-height: 100dvh;
+	box-sizing: border-box;
+	display: flex;
+
+	&.wallpaper {
+		background: var(--wallpaperOverlay);
+		//backdrop-filter: var(--blur, blur(4px));
+	}
+
+	> .sidebar {
+		border-right: solid 0.5px var(--divider);
+	}
+
+	> .contents {
+		width: 100%;
+		min-width: 0;
+		background: var(--bg);
+	}
+
+	> .widgets {
+		padding: 0 var(--margin);
+		border-left: solid 0.5px var(--divider);
+		background: var(--bg);
+
+		@media (max-width: $widgets-hide-threshold) {
+			display: none;
+		}
+	}
+
+	> .widgetButton {
+		display: block;
+		position: fixed;
+		z-index: 1000;
+		bottom: 32px;
+		right: 32px;
+		width: 64px;
+		height: 64px;
+		border-radius: 100%;
+		box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
+		font-size: 22px;
+		background: var(--panel);
+	}
+
+	> .widgetsDrawer-back {
+		z-index: 1001;
+	}
+
+	> .widgetsDrawer {
+		position: fixed;
+		top: 0;
+		right: 0;
+		z-index: 1001;
+		height: 100dvh;
+		padding: var(--margin);
+		box-sizing: border-box;
+		overflow: auto;
+		overscroll-behavior: contain;
+		background: var(--bg);
+	}
+
+	> .buttons {
+		position: fixed;
+		z-index: 1000;
+		bottom: 0;
+		left: 0;
+		padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
+		display: flex;
+		width: 100%;
+		box-sizing: border-box;
+		-webkit-backdrop-filter: var(--blur, blur(32px));
+		backdrop-filter: var(--blur, blur(32px));
+		background-color: var(--header);
+		border-top: solid 0.5px var(--divider);
+
+		> .button {
+			position: relative;
+			flex: 1;
+			padding: 0;
+			margin: auto;
+			height: 64px;
+			border-radius: 8px;
+			background: var(--panel);
+			color: var(--fg);
+
+			&:not(:last-child) {
+				margin-right: 12px;
+			}
+
+			@media (max-width: 400px) {
+				height: 60px;
+
+				&:not(:last-child) {
+					margin-right: 8px;
+				}
+			}
+
+			&:hover {
+				background: var(--X2);
+			}
+
+			> .indicator {
+				position: absolute;
+				top: 0;
+				left: 0;
+				color: var(--indicator);
+				font-size: 16px;
+				animation: blink 1s infinite;
+			}
+
+			&:first-child {
+				margin-left: 0;
+			}
+
+			&:last-child {
+				margin-right: 0;
+			}
+
+			> * {
+				font-size: 20px;
+			}
+
+			&:disabled {
+				cursor: default;
+
+				> * {
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+
+	> .menuDrawer-back {
+		z-index: 1001;
+	}
+
+	> .menuDrawer {
+		position: fixed;
+		top: 0;
+		left: 0;
+		z-index: 1001;
+		height: 100dvh;
+		width: 240px;
+		box-sizing: border-box;
+		contain: strict;
+		overflow: auto;
+		overscroll-behavior: contain;
+		background: var(--navBg);
+	}
+}
+</style>
+
+<style lang="scss" module>
+.statusbars {
+	position: sticky;
+	top: 0;
+	left: 0;
+}
+
+.spacer {
+	$widgets-hide-threshold: 1090px;
+
+	height: calc(env(safe-area-inset-bottom, 0px) + 96px);
+
+	@media (min-width: ($widgets-hide-threshold + 1px)) {
+		display: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue
new file mode 100644
index 0000000000..33fb492836
--- /dev/null
+++ b/packages/frontend/src/ui/universal.widgets.vue
@@ -0,0 +1,71 @@
+<template>
+<div class="efzpzdvf">
+	<XWidgets :edit="editMode" :widgets="defaultStore.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+
+	<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
+	<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XWidgets from '@/components/MkWidgets.vue';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+const emit = defineEmits<{
+	(ev: 'mounted', el: Element): void;
+}>();
+
+let editMode = $ref(false);
+let rootEl = $ref<HTMLDivElement>();
+
+onMounted(() => {
+	emit('mounted', rootEl);
+});
+
+function addWidget(widget) {
+	defaultStore.set('widgets', [{
+		...widget,
+		place: null,
+	}, ...defaultStore.state.widgets]);
+}
+
+function removeWidget(widget) {
+	defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id));
+}
+
+function updateWidget({ id, data }) {
+	defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? {
+		...w,
+		data,
+	} : w));
+}
+
+function updateWidgets(widgets) {
+	defaultStore.set('widgets', widgets);
+}
+</script>
+
+<style lang="scss" scoped>
+.efzpzdvf {
+	position: sticky;
+	height: min-content;
+	min-height: 100vh;
+	padding: var(--margin) 0;
+	box-sizing: border-box;
+
+	> * {
+		margin: var(--margin) 0;
+		width: 300px;
+
+		&:first-child {
+			margin-top: 0;
+		}
+	}
+
+	> .add {
+		margin: 0 auto;
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
new file mode 100644
index 0000000000..ec9150d346
--- /dev/null
+++ b/packages/frontend/src/ui/visitor.vue
@@ -0,0 +1,19 @@
+<template>
+<DesignB/>
+<XCommon/>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import DesignA from './visitor/a.vue';
+import DesignB from './visitor/b.vue';
+import XCommon from './_common_/common.vue';
+
+export default defineComponent({
+	components: {
+		XCommon,
+		DesignA,
+		DesignB,
+	},
+});
+</script>
diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue
new file mode 100644
index 0000000000..f8db7a9d09
--- /dev/null
+++ b/packages/frontend/src/ui/visitor/a.vue
@@ -0,0 +1,259 @@
+<template>
+<div class="mk-app">
+	<div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+		<div>
+			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+			<div v-if="meta" class="about">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
+			</div>
+			<div class="action">
+				<button class="_button primary" @click="signup()">{{ $ts.signup }}</button>
+				<button class="_button" @click="signin()">{{ $ts.login }}</button>
+			</div>
+		</div>
+	</div>
+	<div v-else class="banner-mini" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+		<div>
+			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+		</div>
+	</div>
+
+	<div class="main">
+		<div ref="contents" class="contents" :class="{ wallpaper }">
+			<header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header">
+				<XHeader :info="pageInfo"/>
+			</header>
+			<main ref="main">
+				<RouterView/>
+			</main>
+			<div class="powered-by">
+				<b><MkA to="/">{{ host }}</MkA></b>
+				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XHeader from './header.vue';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import { ColdDeviceStorage } from '@/store';
+import { mainRouter } from '@/router';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+	components: {
+		XHeader,
+		MkPagination,
+		MkButton,
+	},
+
+	data() {
+		return {
+			host,
+			instanceName,
+			pageInfo: null,
+			meta: null,
+			narrow: window.innerWidth < 1280,
+			announcements: {
+				endpoint: 'announcements',
+				limit: 10,
+			},
+			mainRouter,
+			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+		};
+	},
+
+	computed: {
+		keymap(): any {
+			return {
+				'd': () => {
+					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+					this.$store.set('darkMode', !this.$store.state.darkMode);
+				},
+				's': search,
+				'h|/': this.help,
+			};
+		},
+	},
+
+	created() {
+		document.documentElement.style.overflowY = 'scroll';
+
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+	},
+
+	mounted() {
+		if (!this.isDesktop) {
+			window.addEventListener('resize', () => {
+				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+			}, { passive: true });
+		}
+	},
+
+	methods: {
+		// @ThatOneCalculator: Are these methods even used?
+		// I can't find references to them anywhere else in the code...
+
+		// setParallax(el) {
+		// 	new simpleParallax(el);
+		// },
+
+		changePage(page) {
+			if (page == null) return;
+			// eslint-disable-next-line no-undef
+			if (page[symbols.PAGE_INFO]) {
+				// eslint-disable-next-line no-undef
+				this.pageInfo = page[symbols.PAGE_INFO];
+			}
+		},
+
+		top() {
+			window.scroll({ top: 0, behavior: 'smooth' });
+		},
+
+		help() {
+			window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+	min-height: 100vh;
+
+	> .banner {
+		position: relative;
+		width: 100%;
+		text-align: center;
+		background-position: center;
+		background-size: cover;
+
+		> div {
+			height: 100%;
+			background: rgba(0, 0, 0, 0.3);
+
+			* {
+				color: #fff;
+			}
+					
+			> h1 {
+				margin: 0;
+				padding: 96px 32px 0 32px;
+				text-shadow: 0 0 8px black;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 150px;
+				}
+			}
+
+			> .about {
+				padding: 32px;
+				max-width: 580px;
+				margin: 0 auto;
+				box-sizing: border-box;
+				text-shadow: 0 0 8px black;
+			}
+
+			> .action {
+				padding-bottom: 64px;
+				
+				> button {
+					display: inline-block;
+					padding: 10px 20px;
+					box-sizing: border-box;
+					text-align: center;
+					border-radius: 999px;
+					background: var(--panel);
+					color: var(--fg);
+
+					&.primary {
+						background: var(--accent);
+						color: #fff;
+					}
+
+					&:first-child {
+						margin-right: 16px;
+					}
+				}
+			}
+		}
+	}
+
+	> .banner-mini {
+		position: relative;
+		width: 100%;
+		text-align: center;
+		background-position: center;
+		background-size: cover;
+
+		> div {
+			position: relative;
+			z-index: 1;
+			height: 100%;
+			background: rgba(0, 0, 0, 0.3);
+
+			* {
+				color: #fff !important;
+			}
+
+			> header {
+				
+			}
+					
+			> h1 {
+				margin: 0;
+				padding: 32px;
+				text-shadow: 0 0 8px black;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 100px;
+				}
+			}
+		}
+	}
+
+	> .main {
+		> .contents {
+			position: relative;
+			z-index: 1;
+
+			> .header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				z-index: 1000;
+			}
+
+			> .powered-by {
+				padding: 28px;
+				font-size: 14px;
+				text-align: center;
+				border-top: 1px solid var(--divider);
+
+				> small {
+					display: block;
+					margin-top: 8px;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue
new file mode 100644
index 0000000000..275008a8f8
--- /dev/null
+++ b/packages/frontend/src/ui/visitor/b.vue
@@ -0,0 +1,248 @@
+<template>
+<div class="mk-app">
+	<a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
+
+	<div v-if="!narrow && !root" class="side">
+		<XKanban class="kanban" full/>
+	</div>
+
+	<div class="main">
+		<XKanban v-if="narrow && !root" class="banner" :powered-by="root"/>
+
+		<div class="contents">
+			<XHeader v-if="!root" class="header" :info="pageInfo"/>
+			<main>
+				<RouterView/>
+			</main>
+			<div v-if="!root" class="powered-by">
+				<b><MkA to="/">{{ host }}</MkA></b>
+				<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+			</div>
+		</div>
+	</div>
+
+	<transition :name="$store.state.animation ? 'tray-back' : ''">
+		<div
+			v-if="showMenu"
+			class="menu-back _modalBg"
+			@click="showMenu = false"
+			@touchstart.passive="showMenu = false"
+		></div>
+	</transition>
+
+	<transition :name="$store.state.animation ? 'tray' : ''">
+		<div v-if="showMenu" class="menu">
+			<MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA>
+			<MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA>
+			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
+			<MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA>
+			<div class="action">
+				<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
+				<button class="_button" @click="signin()">{{ $ts.login }}</button>
+			</div>
+		</div>
+	</transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ComputedRef, onMounted, provide } from 'vue';
+import XHeader from './header.vue';
+import XKanban from './kanban.vue';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/MkPagination.vue';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+const DESKTOP_THRESHOLD = 1100;
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+
+const announcements = {
+	endpoint: 'announcements',
+	limit: 10,
+};
+let showMenu = $ref(false);
+let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
+let narrow = $ref(window.innerWidth < 1280);
+let meta = $ref();
+
+const keymap = $computed(() => {
+	return {
+		'd': () => {
+			if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+			defaultStore.set('darkMode', !defaultStore.state.darkMode);
+		},
+		's': search,
+	};
+});
+
+const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
+
+os.api('meta', { detail: true }).then(res => {
+	meta = res;
+});
+
+function signin() {
+	os.popup(XSigninDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
+
+function signup() {
+	os.popup(XSignupDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
+
+onMounted(() => {
+	if (!isDesktop) {
+		window.addEventListener('resize', () => {
+			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
+		}, { passive: true });
+	}
+});
+
+defineExpose({
+	showMenu: $$(showMenu),
+});
+</script>
+
+<style>
+.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
+</style>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+	opacity: 0;
+	transform: translateX(-240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+	opacity: 0;
+}
+
+.mk-app {
+	display: flex;
+	min-height: 100vh;
+	background-position: center;
+	background-size: cover;
+	background-attachment: fixed;
+
+	> .side {
+		width: 500px;
+		height: 100vh;
+
+		> .kanban {
+			position: fixed;
+			top: 0;
+			left: 0;
+			width: 500px;
+			height: 100vh;
+			overflow: auto;
+		}
+	}
+
+	> .main {
+		flex: 1;
+		min-width: 0;
+
+		> .banner {
+		}
+
+		> .contents {
+			position: relative;
+			z-index: 1;
+
+			> .powered-by {
+				padding: 28px;
+				font-size: 14px;
+				text-align: center;
+				border-top: 1px solid var(--divider);
+
+				> small {
+					display: block;
+					margin-top: 8px;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+
+	> .menu-back {
+		position: fixed;
+		z-index: 1001;
+		top: 0;
+		left: 0;
+		width: 100vw;
+		height: 100vh;
+	}
+
+	> .menu {
+		position: fixed;
+		z-index: 1001;
+		top: 0;
+		left: 0;
+		width: 240px;
+		height: 100vh;
+		background: var(--panel);
+
+		> .link {
+			display: block;
+			padding: 16px;
+
+			> .icon {
+				margin-right: 1em;
+			}
+		}
+
+		> .action {
+			padding: 16px;
+
+			> button {
+				display: block;
+				width: 100%;
+				padding: 10px;
+				box-sizing: border-box;
+				text-align: center;
+				border-radius: 999px;
+
+				&._button {
+					background: var(--panel);
+				}
+
+				&:first-child {
+					margin-bottom: 16px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue
new file mode 100644
index 0000000000..7300b12a75
--- /dev/null
+++ b/packages/frontend/src/ui/visitor/header.vue
@@ -0,0 +1,228 @@
+<template>
+<div class="sqxihjet">
+	<div v-if="narrow === false" class="wide">
+		<div class="content">
+			<MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA>
+			<MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA>
+			<MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
+			<MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA>
+			<div v-if="info" class="page active link">
+				<div class="title">
+					<i v-if="info.icon" class="icon" :class="info.icon"></i>
+					<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+					<span v-if="info.title" class="text">{{ info.title }}</span>
+					<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
+				</div>
+				<button v-if="info.action" class="_button action" @click.stop="info.action.handler"><!-- TODO --></button>
+			</div>
+			<div class="right">
+				<button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ $ts.search }}</span></button>
+				<button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button>
+				<button class="_button login" @click="signin()">{{ $ts.login }}</button>
+			</div>
+		</div>
+	</div>
+	<div v-else-if="narrow === true" class="narrow">
+		<button class="menu _button" @click="$parent.showMenu = true">
+			<i class="ti ti-menu-2 icon"></i>
+		</button>
+		<div v-if="info" class="title">
+			<i v-if="info.icon" class="icon" :class="info.icon"></i>
+			<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+			<span v-if="info.title" class="text">{{ info.title }}</span>
+			<MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
+		</div>
+		<button v-if="info && info.action" class="action _button" @click.stop="info.action.handler">
+			<!-- TODO -->
+		</button>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import * as os from '@/os';
+import { search } from '@/scripts/search';
+
+export default defineComponent({
+	props: {
+		info: {
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			narrow: null,
+			showMenu: false,
+		};
+	},
+
+	mounted() {
+		this.narrow = this.$el.clientWidth < 1300;
+	},
+
+	methods: {
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		search,
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.sqxihjet {
+	$height: 60px;
+	position: sticky;
+	top: 0;
+	left: 0;
+	z-index: 1000;
+	line-height: $height;
+	-webkit-backdrop-filter: var(--blur, blur(32px));
+	backdrop-filter: var(--blur, blur(32px));
+	background-color: var(--X16);
+
+	> .wide {
+		> .content {
+			max-width: 1400px;
+			margin: 0 auto;
+			display: flex;
+			align-items: center;
+
+			> .link {
+				$line: 3px;
+				display: inline-block;
+				padding: 0 16px;
+				line-height: $height - ($line * 2);
+				border-top: solid $line transparent;
+				border-bottom: solid $line transparent;
+
+				> .icon {
+					margin-right: 0.5em;
+				}
+
+				&.page {
+					border-bottom-color: var(--accent);
+				}
+			}
+
+			> .page {
+				> .title {
+					display: inline-block;
+					vertical-align: bottom;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+					position: relative;
+
+					> .icon + .text {
+						margin-left: 8px;
+					}
+
+					> .avatar {
+						$size: 32px;
+						display: inline-block;
+						width: $size;
+						height: $size;
+						vertical-align: middle;
+						margin-right: 8px;
+						pointer-events: none;
+					}
+
+					&._button {
+						&:hover {
+							color: var(--fgHighlighted);
+						}
+					}
+
+					&.selected {
+						box-shadow: 0 -2px 0 0 var(--accent) inset;
+						color: var(--fgHighlighted);
+					}
+				}
+
+				> .action {
+					padding: 0 0 0 16px;
+				}
+			}
+
+			> .right {
+				margin-left: auto;
+
+				> .search {
+					background: var(--bg);
+					border-radius: 999px;
+					width: 230px;
+					line-height: $height - 20px;
+					margin-right: 16px;
+					text-align: left;
+
+					> * {
+						opacity: 0.7;
+					}
+
+					> .icon {
+						padding: 0 16px;
+					}
+				}
+
+				> .signup {
+					border-radius: 999px;
+					padding: 0 24px;
+					line-height: $height - 20px;
+				}
+
+				> .login {
+					padding: 0 16px;
+				}
+			}
+		}
+	}
+
+	> .narrow {
+		display: flex;
+
+		> .menu,
+		> .action {
+			width: $height;
+			height: $height;
+			font-size: 20px;
+		}
+
+		> .title {
+			flex: 1;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			position: relative;
+			text-align: center;
+
+			> .icon + .text {
+				margin-left: 8px;
+			}
+
+			> .avatar {
+				$size: 32px;
+				display: inline-block;
+				width: $size;
+				height: $size;
+				vertical-align: middle;
+				margin-right: 8px;
+				pointer-events: none;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue
new file mode 100644
index 0000000000..51e47f277d
--- /dev/null
+++ b/packages/frontend/src/ui/visitor/kanban.vue
@@ -0,0 +1,257 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }">
+	<div class="back" :class="{ transparent }"></div>
+	<div class="contents">
+		<div class="wrapper">
+			<h1 v-if="meta" :class="{ full }">
+				<MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA>
+			</h1>
+			<template v-if="full">
+				<div v-if="meta" class="about">
+					<div class="desc" v-html="meta.description || $ts.introMisskey"></div>
+				</div>
+				<div class="action">
+					<button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
+					<button class="_button" @click="signin()">{{ $ts.login }}</button>
+				</div>
+				<div class="announcements panel">
+					<header>{{ $ts.announcements }}</header>
+					<MkPagination v-slot="{items}" :pagination="announcements" class="list">
+						<section v-for="announcement in items" :key="announcement.id" class="item">
+							<div class="title">{{ announcement.title }}</div>
+							<div class="content">
+								<Mfm :text="announcement.text"/>
+								<img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/>
+							</div>
+						</section>
+					</MkPagination>
+				</div>
+				<div v-if="poweredBy" class="powered-by">
+					<b><MkA to="/">{{ host }}</MkA></b>
+					<small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+				</div>
+			</template>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import MkPagination from '@/components/MkPagination.vue';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+
+export default defineComponent({
+	components: {
+		MkPagination,
+		MkButton,
+	},
+
+	props: {
+		full: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		transparent: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		poweredBy: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+	},
+
+	data() {
+		return {
+			host,
+			instanceName,
+			pageInfo: null,
+			meta: null,
+			narrow: window.innerWidth < 1280,
+			announcements: {
+				endpoint: 'announcements',
+				limit: 10,
+			},
+		};
+	},
+
+	created() {
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+	},
+
+	methods: {
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true,
+			}, {}, 'closed');
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.rwqkcmrc {
+	position: relative;
+	text-align: center;
+	background-position: center;
+	background-size: cover;
+	// TODO: パララックスにしたい
+
+	> .back {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		background: rgba(0, 0, 0, 0.3);
+
+		&.transparent {
+			-webkit-backdrop-filter: var(--blur, blur(12px));
+			backdrop-filter: var(--blur, blur(12px));
+		}
+	}
+
+	> .contents {
+		position: relative;
+		z-index: 1;
+		height: inherit;
+		overflow: auto;
+
+		> .wrapper {
+			max-width: 380px;
+			padding: 0 16px;
+			box-sizing: border-box;
+			margin: 0 auto;
+
+			> .panel {
+				-webkit-backdrop-filter: var(--blur, blur(8px));
+				backdrop-filter: var(--blur, blur(8px));
+				background: rgba(0, 0, 0, 0.5);
+				border-radius: var(--radius);
+
+				&, * {
+					color: #fff !important;
+				}
+			}
+
+			> h1 {
+				display: block;
+				margin: 0;
+				padding: 32px 0 32px 0;
+				color: #fff;
+
+				&.full {
+					padding: 64px 0 0 0;
+
+					> .link {
+						> ::v-deep(.logo) {
+							max-height: 130px;
+						}
+					}
+				}
+
+				> .link {
+					display: block;
+
+					> ::v-deep(.logo) {
+						vertical-align: bottom;
+						max-height: 100px;
+					}
+				}
+			}
+
+			> .about {
+				display: block;
+				margin: 24px 0;
+				text-align: center;
+				box-sizing: border-box;
+				text-shadow: 0 0 8px black;
+				color: #fff;
+			}
+
+			> .action {
+				> button {
+					display: block;
+					width: 100%;
+					padding: 10px;
+					box-sizing: border-box;
+					text-align: center;
+					border-radius: 999px;
+
+					&._button {
+						background: var(--panel);
+					}
+
+					&:first-child {
+						margin-bottom: 16px;
+					}
+				}
+			}
+
+			> .announcements {
+				margin: 32px 0;
+				text-align: left;
+
+				> header {
+					padding: 12px 16px;
+					border-bottom: solid 1px rgba(255, 255, 255, 0.5);
+				}
+
+				> .list {
+					max-height: 300px;
+					overflow: auto;
+
+					> .item {
+						padding: 12px 16px;
+
+						& + .item {
+							border-top: solid 1px rgba(255, 255, 255, 0.5);
+						}
+
+						> .title {
+							font-weight: bold;
+						}
+
+						> .content {
+							> img {
+								max-width: 100%;
+							}
+						}
+					}
+				}
+			}
+
+			> .powered-by {
+				padding: 28px;
+				font-size: 14px;
+				text-align: center;
+				border-top: 1px solid rgba(255, 255, 255, 0.5);
+				color: #fff;
+
+				> small {
+					display: block;
+					margin-top: 8px;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue
new file mode 100644
index 0000000000..84c96a1dae
--- /dev/null
+++ b/packages/frontend/src/ui/zen.vue
@@ -0,0 +1,34 @@
+<template>
+<div class="mk-app">
+	<RouterView/>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { provide, ComputedRef } from 'vue';
+import XCommon from './_common_/common.vue';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { instanceName } from '@/config';
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+
+document.documentElement.style.overflowY = 'scroll';
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+	min-height: 100dvh;
+	box-sizing: border-box;
+}
+</style>
diff --git a/packages/frontend/src/widgets/activity.calendar.vue b/packages/frontend/src/widgets/activity.calendar.vue
new file mode 100644
index 0000000000..84f6af1c13
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.calendar.vue
@@ -0,0 +1,81 @@
+<template>
+<svg viewBox="0 0 21 7">
+	<rect v-for="record in activity" class="day"
+		width="1" height="1"
+		:x="record.x" :y="record.date.weekday"
+		rx="1" ry="1"
+		fill="transparent">
+		<title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
+	</rect>
+	<rect v-for="record in activity" class="day"
+		:width="record.v" :height="record.v"
+		:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
+		rx="1" ry="1"
+		:fill="record.color"
+		style="pointer-events: none;"/>
+	<rect class="today"
+		width="1" height="1"
+		:x="activity[0].x" :y="activity[0].date.weekday"
+		rx="1" ry="1"
+		fill="none"
+		stroke-width="0.1"
+		stroke="#f73520"/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+	activity: any[]
+}>();
+
+for (const d of props.activity) {
+	d.total = d.notes + d.replies + d.renotes;
+}
+const peak = Math.max(...props.activity.map(d => d.total));
+
+const now = new Date();
+const year = now.getFullYear();
+const month = now.getMonth();
+const day = now.getDate();
+
+let x = 20;
+props.activity.slice().forEach((d, i) => {
+	d.x = x;
+
+	const date = new Date(year, month, day - i);
+	d.date = {
+		year: date.getFullYear(),
+		month: date.getMonth(),
+		day: date.getDate(),
+		weekday: date.getDay(),
+	};
+
+	d.v = peak === 0 ? 0 : d.total / (peak / 2);
+	if (d.v > 1) d.v = 1;
+	const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170;
+	const cs = d.v * 100;
+	const cl = 15 + ((1 - d.v) * 80);
+	d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
+
+	if (d.date.weekday === 0) x--;
+});
+</script>
+
+<style lang="scss" scoped>
+svg {
+	display: block;
+	padding: 16px;
+	width: 100%;
+	box-sizing: border-box;
+
+	> rect {
+		transform-origin: center;
+
+		&.day {
+			&:hover {
+				fill: rgba(#000, 0.05);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/activity.chart.vue b/packages/frontend/src/widgets/activity.chart.vue
new file mode 100644
index 0000000000..b61e419f94
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.chart.vue
@@ -0,0 +1,92 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
+	<polyline
+		:points="pointsNote"
+		fill="none"
+		stroke-width="1"
+		stroke="#41ddde"/>
+	<polyline
+		:points="pointsReply"
+		fill="none"
+		stroke-width="1"
+		stroke="#f7796c"/>
+	<polyline
+		:points="pointsRenote"
+		fill="none"
+		stroke-width="1"
+		stroke="#a1de41"/>
+	<polyline
+		:points="pointsTotal"
+		fill="none"
+		stroke-width="1"
+		stroke="#555"
+		stroke-dasharray="2 2"/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+	activity: any[]
+}>();
+
+let viewBoxX: number = $ref(147);
+let viewBoxY: number = $ref(60);
+let zoom: number = $ref(1);
+let pos: number = $ref(0);
+let pointsNote: any = $ref(null);
+let pointsReply: any = $ref(null);
+let pointsRenote: any = $ref(null);
+let pointsTotal: any = $ref(null);
+
+function dragListen(fn) {
+	window.addEventListener('mousemove', fn);
+	window.addEventListener('mouseleave', dragClear.bind(null, fn));
+	window.addEventListener('mouseup', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+	window.removeEventListener('mousemove', fn);
+	window.removeEventListener('mouseleave', dragClear);
+	window.removeEventListener('mouseup', dragClear);
+}
+
+function onMousedown(ev) {
+	const clickX = ev.clientX;
+	const clickY = ev.clientY;
+	const baseZoom = zoom;
+	const basePos = pos;
+
+	// 動かした時
+	dragListen(me => {
+		let moveLeft = me.clientX - clickX;
+		let moveTop = me.clientY - clickY;
+
+		zoom = Math.max(1, baseZoom + (-moveTop / 20));
+		pos = Math.min(0, basePos + moveLeft);
+		if (pos < -(((props.activity.length - 1) * zoom) - viewBoxX)) pos = -(((props.activity.length - 1) * zoom) - viewBoxX);
+
+		render();
+	});
+}
+
+function render() {
+	const peak = Math.max(...props.activity.map(d => d.total));
+	if (peak !== 0) {
+		const activity = props.activity.slice().reverse();
+		pointsNote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.notes / peak)) * viewBoxY}`).join(' ');
+		pointsReply = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.replies / peak)) * viewBoxY}`).join(' ');
+		pointsRenote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.renotes / peak)) * viewBoxY}`).join(' ');
+		pointsTotal = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.total / peak)) * viewBoxY}`).join(' ');
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+svg {
+	display: block;
+	padding: 16px;
+	width: 100%;
+	box-sizing: border-box;
+	cursor: all-scroll;
+}
+</style>
diff --git a/packages/frontend/src/widgets/activity.vue b/packages/frontend/src/widgets/activity.vue
new file mode 100644
index 0000000000..238a05ca09
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.vue
@@ -0,0 +1,90 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity">
+	<template #header><i class="ti ti-chart-line"></i>{{ i18n.ts._widgets.activity }}</template>
+	<template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
+
+	<div>
+		<MkLoading v-if="fetching"/>
+		<template v-else>
+			<XCalendar v-show="widgetProps.view === 0" :activity="[].concat(activity)"/>
+			<XChart v-show="widgetProps.view === 1" :activity="[].concat(activity)"/>
+		</template>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import XCalendar from './activity.calendar.vue';
+import XChart from './activity.chart.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'activity';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	view: {
+		type: 'number' as const,
+		default: 0,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const activity = ref(null);
+const fetching = ref(true);
+
+const toggleView = () => {
+	if (widgetProps.view === 1) {
+		widgetProps.view = 0;
+	} else {
+		widgetProps.view++;
+	}
+	save();
+};
+
+os.apiGet('charts/user/notes', {
+	userId: $i.id,
+	span: 'day',
+	limit: 7 * 21,
+}).then(res => {
+	activity.value = res.diffs.normal.map((_, i) => ({
+		total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i],
+		notes: res.diffs.normal[i],
+		replies: res.diffs.reply[i],
+		renotes: res.diffs.renote[i],
+	}));
+	fetching.value = false;
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/aichan.vue b/packages/frontend/src/widgets/aichan.vue
new file mode 100644
index 0000000000..828490fd9c
--- /dev/null
+++ b/packages/frontend/src/widgets/aichan.vue
@@ -0,0 +1,74 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-aichan">
+	<iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+
+const name = 'ai';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const live2d = ref<HTMLIFrameElement>();
+
+const touched = () => {
+	//if (this.live2d) this.live2d.changeExpression('gurugurume');
+};
+
+const onMousemove = (ev: MouseEvent) => {
+	const iframeRect = live2d.value.getBoundingClientRect();
+	live2d.value.contentWindow.postMessage({
+		type: 'moveCursor',
+		body: {
+			x: ev.clientX - iframeRect.left,
+			y: ev.clientY - iframeRect.top,
+		},
+	}, '*');
+};
+
+onMounted(() => {
+	window.addEventListener('mousemove', onMousemove, { passive: true });
+});
+
+onUnmounted(() => {
+	window.removeEventListener('mousemove', onMousemove);
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.dedjhjmo {
+	width: 100%;
+	height: 350px;
+	border: none;
+	pointer-events: none;
+}
+</style>
diff --git a/packages/frontend/src/widgets/aiscript.vue b/packages/frontend/src/widgets/aiscript.vue
new file mode 100644
index 0000000000..4009edb8b8
--- /dev/null
+++ b/packages/frontend/src/widgets/aiscript.vue
@@ -0,0 +1,175 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript">
+	<template #header><i class="ti ti-terminal-2"></i>{{ i18n.ts._widgets.aiscript }}</template>
+
+	<div class="uylguesu _monospace">
+		<textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea>
+		<button class="_buttonPrimary" @click="run">RUN</button>
+		<div class="logs">
+			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+		</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'aiscript';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	script: {
+		type: 'string' as const,
+		multiline: true,
+		default: '(1 + 1)',
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const logs = ref<{
+	id: string;
+	text: string;
+	print: boolean;
+}[]>([]);
+
+const run = async () => {
+	logs.value = [];
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'widget',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			logs.value.push({
+				id: Math.random().toString(),
+				text: value.type === 'str' ? value.value : utils.valToString(value),
+				print: true,
+			});
+		},
+		log: (type, params) => {
+			switch (type) {
+				case 'end': logs.value.push({
+					id: Math.random().toString(),
+					text: utils.valToString(params.val, true),
+					print: false,
+				}); break;
+				default: break;
+			}
+		},
+	});
+
+	let ast;
+	try {
+		ast = parse(widgetProps.script);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+	try {
+		await aiscript.exec(ast);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: err,
+		});
+	}
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.uylguesu {
+	text-align: right;
+
+	> textarea {
+		display: block;
+		width: 100%;
+		max-width: 100%;
+		min-width: 100%;
+		padding: 16px;
+		color: var(--fg);
+		background: transparent;
+		border: none;
+		border-bottom: solid 0.5px var(--divider);
+		border-radius: 0;
+		box-sizing: border-box;
+		font: inherit;
+
+		&:focus-visible {
+			outline: none;
+		}
+	}
+
+	> button {
+		display: inline-block;
+		margin: 8px;
+		padding: 0 10px;
+		height: 28px;
+		outline: none;
+		border-radius: 4px;
+
+		&:disabled {
+			opacity: 0.7;
+			cursor: default;
+		}
+	}
+
+	> .logs {
+		border-top: solid 0.5px var(--divider);
+		text-align: left;
+		padding: 16px;
+
+		&:empty {
+			display: none;
+		}
+
+		> .log {
+			&:not(.print) {
+				opacity: 0.7;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/button.vue b/packages/frontend/src/widgets/button.vue
new file mode 100644
index 0000000000..f0148d7f4e
--- /dev/null
+++ b/packages/frontend/src/widgets/button.vue
@@ -0,0 +1,103 @@
+<template>
+<div class="mkw-button">
+	<MkButton :primary="widgetProps.colored" full @click="run">
+		{{ widgetProps.label }}
+	</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import MkButton from '@/components/MkButton.vue';
+
+const name = 'button';
+
+const widgetPropsDef = {
+	label: {
+		type: 'string' as const,
+		default: 'BUTTON',
+	},
+	colored: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	script: {
+		type: 'string' as const,
+		multiline: true,
+		default: 'Mk:dialog("hello" "world")',
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const run = async () => {
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'widget',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			// nop
+		},
+		log: (type, params) => {
+			// nop
+		},
+	});
+
+	let ast;
+	try {
+		ast = parse(widgetProps.script);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+	try {
+		await aiscript.exec(ast);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: err,
+		});
+	}
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-button {
+}
+</style>
diff --git a/packages/frontend/src/widgets/calendar.vue b/packages/frontend/src/widgets/calendar.vue
new file mode 100644
index 0000000000..99bd36e2fc
--- /dev/null
+++ b/packages/frontend/src/widgets/calendar.vue
@@ -0,0 +1,213 @@
+<template>
+<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }">
+	<div class="calendar" :class="{ isHoliday }">
+		<p class="month-and-year">
+			<span class="year">{{ $t('yearX', { year }) }}</span>
+			<span class="month">{{ $t('monthX', { month }) }}</span>
+		</p>
+		<p v-if="month === 1 && day === 1" class="day">🎉{{ $t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p>
+		<p v-else class="day">{{ $t('dayX', { day }) }}</p>
+		<p class="week-day">{{ weekDay }}</p>
+	</div>
+	<div class="info">
+		<div>
+			<p>{{ i18n.ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p>
+			<div class="meter">
+				<div class="val" :style="{ width: `${dayP}%` }"></div>
+			</div>
+		</div>
+		<div>
+			<p>{{ i18n.ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p>
+			<div class="meter">
+				<div class="val" :style="{ width: `${monthP}%` }"></div>
+			</div>
+		</div>
+		<div>
+			<p>{{ i18n.ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p>
+			<div class="meter">
+				<div class="val" :style="{ width: `${yearP}%` }"></div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { i18n } from '@/i18n';
+import { useInterval } from '@/scripts/use-interval';
+
+const name = 'calendar';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const year = ref(0);
+const month = ref(0);
+const day = ref(0);
+const weekDay = ref('');
+const yearP = ref(0);
+const monthP = ref(0);
+const dayP = ref(0);
+const isHoliday = ref(false);
+const tick = () => {
+	const now = new Date();
+	const nd = now.getDate();
+	const nm = now.getMonth();
+	const ny = now.getFullYear();
+
+	year.value = ny;
+	month.value = nm + 1;
+	day.value = nd;
+	weekDay.value = [
+		i18n.ts._weekday.sunday,
+		i18n.ts._weekday.monday,
+		i18n.ts._weekday.tuesday,
+		i18n.ts._weekday.wednesday,
+		i18n.ts._weekday.thursday,
+		i18n.ts._weekday.friday,
+		i18n.ts._weekday.saturday,
+	][now.getDay()];
+
+	const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
+	const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
+	const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
+	const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
+	const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
+	const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
+
+	dayP.value = dayNumer / dayDenom * 100;
+	monthP.value = monthNumer / monthDenom * 100;
+	yearP.value = yearNumer / yearDenom * 100;
+
+	isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
+};
+
+useInterval(tick, 1000, {
+	immediate: true,
+	afterMounted: false,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-calendar {
+	padding: 16px 0;
+
+	&:after {
+		content: "";
+		display: block;
+		clear: both;
+	}
+
+	> .calendar {
+		float: left;
+		width: 60%;
+		text-align: center;
+
+		&.isHoliday {
+			> .day {
+				color: #ef95a0;
+			}
+		}
+
+		> .month-and-year, > .week-day {
+			margin: 0;
+			line-height: 18px;
+			font-size: 0.9em;
+
+			> .year, > .month {
+				margin: 0 4px;
+			}
+		}
+
+		> .day {
+			margin: 10px 0;
+			line-height: 32px;
+			font-size: 1.75em;
+		}
+	}
+
+	> .info {
+		display: block;
+		float: left;
+		width: 40%;
+		padding: 0 16px 0 0;
+		box-sizing: border-box;
+
+		> div {
+			margin-bottom: 8px;
+
+			&:last-child {
+				margin-bottom: 4px;
+			}
+
+			> p {
+				margin: 0 0 2px 0;
+				font-size: 0.75em;
+				line-height: 18px;
+				opacity: 0.8;
+
+				> b {
+					margin-left: 2px;
+				}
+			}
+
+			> .meter {
+				width: 100%;
+				overflow: hidden;
+				background: var(--X11);
+				border-radius: 8px;
+
+				> .val {
+					height: 4px;
+					transition: width .3s cubic-bezier(0.23, 1, 0.32, 1);
+				}
+			}
+
+			&:nth-child(1) {
+				> .meter > .val {
+					background: #f7796c;
+				}
+			}
+
+			&:nth-child(2) {
+				> .meter > .val {
+					background: #a1de41;
+				}
+			}
+
+			&:nth-child(3) {
+				> .meter > .val {
+					background: #41ddde;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/clock.vue b/packages/frontend/src/widgets/clock.vue
new file mode 100644
index 0000000000..dc99b6631e
--- /dev/null
+++ b/packages/frontend/src/widgets/clock.vue
@@ -0,0 +1,203 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock">
+	<div class="vubelbmv" :class="widgetProps.size">
+		<div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div>
+		<MkAnalogClock
+			class="clock"
+			:thickness="widgetProps.thickness"
+			:offset="tzOffset"
+			:graduations="widgetProps.graduations"
+			:fade-graduations="widgetProps.fadeGraduations"
+			:twentyfour="widgetProps.twentyFour"
+			:s-animation="widgetProps.sAnimation"
+		/>
+		<MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/>
+		<div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkAnalogClock from '@/components/MkAnalogClock.vue';
+import MkDigitalClock from '@/components/MkDigitalClock.vue';
+import { timezones } from '@/scripts/timezones';
+import { i18n } from '@/i18n';
+
+const name = 'clock';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	size: {
+		type: 'radio' as const,
+		default: 'medium',
+		options: [{
+			value: 'small', label: i18n.ts.small,
+		}, {
+			value: 'medium', label: i18n.ts.medium,
+		}, {
+			value: 'large', label: i18n.ts.large,
+		}],
+	},
+	thickness: {
+		type: 'radio' as const,
+		default: 0.2,
+		options: [{
+			value: 0.1, label: 'thin',
+		}, {
+			value: 0.2, label: 'medium',
+		}, {
+			value: 0.3, label: 'thick',
+		}],
+	},
+	graduations: {
+		type: 'radio' as const,
+		default: 'numbers',
+		options: [{
+			value: 'none', label: 'None',
+		}, {
+			value: 'dots', label: 'Dots',
+		}, {
+			value: 'numbers', label: 'Numbers',
+		}],
+	},
+	fadeGraduations: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	sAnimation: {
+		type: 'radio' as const,
+		default: 'elastic',
+		options: [{
+			value: 'none', label: 'None',
+		}, {
+			value: 'elastic', label: 'Elastic',
+		}, {
+			value: 'easeOut', label: 'Ease out',
+		}],
+	},
+	twentyFour: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	label: {
+		type: 'radio' as const,
+		default: 'none',
+		options: [{
+			value: 'none', label: 'None',
+		}, {
+			value: 'time', label: 'Time',
+		}, {
+			value: 'tz', label: 'TZ',
+		}, {
+			value: 'timeAndTz', label: 'Time + TZ',
+		}],
+	},
+	timezone: {
+		type: 'enum' as const,
+		default: null,
+		enum: [...timezones.map((tz) => ({
+			label: tz.name,
+			value: tz.name.toLowerCase(),
+		})), {
+			label: '(auto)',
+			value: null,
+		}],
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+	? 0 - new Date().getTimezoneOffset()
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.vubelbmv {
+	position: relative;
+
+	> .label {
+		position: absolute;
+		opacity: 0.7;
+
+		&.a {
+			top: 14px;
+			left: 14px;
+		}
+
+		&.b {
+			top: 14px;
+			right: 14px;
+		}
+
+		&.c {
+			bottom: 14px;
+			left: 14px;
+		}
+
+		&.d {
+			bottom: 14px;
+			right: 14px;
+		}
+	}
+
+	> .clock {
+		margin: auto;
+	}
+
+	&.small {
+		padding: 12px;
+
+		> .clock {
+			height: 100px;
+		}
+	}
+
+	&.medium {
+		padding: 14px;
+
+		> .clock {
+			height: 150px;
+		}
+	}
+
+	&.large {
+		padding: 16px;
+
+		> .clock {
+			height: 200px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/digital-clock.vue b/packages/frontend/src/widgets/digital-clock.vue
new file mode 100644
index 0000000000..d2bfd523f3
--- /dev/null
+++ b/packages/frontend/src/widgets/digital-clock.vue
@@ -0,0 +1,92 @@
+<template>
+<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
+	<div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div>
+	<div class="time">
+		<MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/>
+	</div>
+	<div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { timezones } from '@/scripts/timezones';
+import MkDigitalClock from '@/components/MkDigitalClock.vue';
+
+const name = 'digitalClock';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	fontSize: {
+		type: 'number' as const,
+		default: 1.5,
+		step: 0.1,
+	},
+	showMs: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	showLabel: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	timezone: {
+		type: 'enum' as const,
+		default: null,
+		enum: [...timezones.map((tz) => ({
+			label: tz.name,
+			value: tz.name.toLowerCase(),
+		})), {
+			label: '(auto)',
+			value: null,
+		}],
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+	? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+	? 0 - new Date().getTimezoneOffset()
+	: timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-digitalClock {
+	padding: 16px 0;
+	text-align: center;
+
+	> .label {
+		font-size: 65%;
+		opacity: 0.7;
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/federation.vue b/packages/frontend/src/widgets/federation.vue
new file mode 100644
index 0000000000..3374783b0c
--- /dev/null
+++ b/packages/frontend/src/widgets/federation.vue
@@ -0,0 +1,147 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation">
+	<template #header><i class="ti ti-whirl"></i>{{ i18n.ts._widgets.federation }}</template>
+
+	<div class="wbrkwalb">
+		<MkLoading v-if="fetching"/>
+		<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
+			<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
+				<img :src="getInstanceIcon(instance)" alt=""/>
+				<div class="body">
+					<a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
+					<p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
+				</div>
+				<MkMiniChart class="chart" :src="charts[i].requests.received"/>
+			</div>
+		</transition-group>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const name = 'federation';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+	const fetchedInstances = await os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 5,
+	});
+	const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+	instances.value = fetchedInstances;
+	charts.value = fetchedCharts;
+	fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
+});
+
+function getInstanceIcon(instance): string {
+	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwalb {
+	$bodyTitleHieght: 18px;
+	$bodyInfoHieght: 16px;
+
+	height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+	overflow: hidden;
+
+	> .instances {
+		.chart-move {
+			transition: transform 1s ease;
+		}
+
+		> .instance {
+			display: flex;
+			align-items: center;
+			padding: 14px 16px;
+			border-bottom: solid 0.5px var(--divider);
+
+			> img {
+				display: block;
+				width: ($bodyTitleHieght + $bodyInfoHieght);
+				height: ($bodyTitleHieght + $bodyInfoHieght);
+				object-fit: cover;
+				border-radius: 4px;
+				margin-right: 8px;
+			}
+
+			> .body {
+				flex: 1;
+				overflow: hidden;
+				font-size: 0.9em;
+				color: var(--fg);
+				padding-right: 8px;
+
+				> .a {
+					display: block;
+					width: 100%;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+					line-height: $bodyTitleHieght;
+				}
+
+				> p {
+					margin: 0;
+					font-size: 75%;
+					opacity: 0.7;
+					line-height: $bodyInfoHieght;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+				}
+			}
+
+			> .chart {
+				height: 30px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts
new file mode 100644
index 0000000000..39826f13c8
--- /dev/null
+++ b/packages/frontend/src/widgets/index.ts
@@ -0,0 +1,53 @@
+import { App, defineAsyncComponent } from 'vue';
+
+export default function(app: App) {
+	app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue')));
+	app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue')));
+	app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue')));
+	app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue')));
+	app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue')));
+	app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue')));
+	app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue')));
+	app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue')));
+	app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue')));
+	app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue')));
+	app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue')));
+	app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue')));
+	app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue')));
+	app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue')));
+	app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue')));
+	app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue')));
+	app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue')));
+	app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue')));
+	app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
+	app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
+	app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
+	app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
+	app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
+}
+
+export const widgets = [
+	'memo',
+	'notifications',
+	'timeline',
+	'calendar',
+	'rss',
+	'rssTicker',
+	'trends',
+	'clock',
+	'activity',
+	'photos',
+	'digitalClock',
+	'unixClock',
+	'federation',
+	'instanceCloud',
+	'postForm',
+	'slideshow',
+	'serverMetric',
+	'onlineUsers',
+	'jobQueue',
+	'button',
+	'aiscript',
+	'aichan',
+	'userList',
+];
diff --git a/packages/frontend/src/widgets/instance-cloud.vue b/packages/frontend/src/widgets/instance-cloud.vue
new file mode 100644
index 0000000000..4965616995
--- /dev/null
+++ b/packages/frontend/src/widgets/instance-cloud.vue
@@ -0,0 +1,81 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud">
+	<div class="">
+		<MkTagCloud v-if="activeInstances">
+			<li v-for="instance in activeInstances" :key="instance.id">
+				<a @click.prevent="onInstanceClick(instance)">
+					<img style="width: 32px;" :src="getInstanceIcon(instance)">
+				</a>
+			</li>
+		</MkTagCloud>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkTagCloud from '@/components/MkTagCloud.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const name = 'instanceCloud';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+let cloud = $ref<InstanceType<typeof MkTagCloud> | null>();
+let activeInstances = $shallowRef(null);
+
+function onInstanceClick(i) {
+	os.pageWindow(`/instance-info/${i.host}`);
+}
+
+useInterval(() => {
+	os.api('federation/instances', {
+		sort: '+lastCommunicatedAt',
+		limit: 25,
+	}).then(res => {
+		activeInstances = res;
+		if (cloud) cloud.update();
+	});
+}, 1000 * 60 * 3, {
+	immediate: true,
+	afterMounted: true,
+});
+
+function getInstanceIcon(instance): string {
+	return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/widgets/job-queue.vue b/packages/frontend/src/widgets/job-queue.vue
new file mode 100644
index 0000000000..9f19c51825
--- /dev/null
+++ b/packages/frontend/src/widgets/job-queue.vue
@@ -0,0 +1,197 @@
+<template>
+<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }">
+	<div class="inbox">
+		<div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
+		<div class="values">
+			<div>
+				<div>Process</div>
+				<div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div>
+			</div>
+			<div>
+				<div>Active</div>
+				<div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div>
+			</div>
+			<div>
+				<div>Delayed</div>
+				<div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div>
+			</div>
+			<div>
+				<div>Waiting</div>
+				<div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div>
+			</div>
+		</div>
+	</div>
+	<div class="deliver">
+		<div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
+		<div class="values">
+			<div>
+				<div>Process</div>
+				<div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div>
+			</div>
+			<div>
+				<div>Active</div>
+				<div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div>
+			</div>
+			<div>
+				<div>Delayed</div>
+				<div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div>
+			</div>
+			<div>
+				<div>Waiting</div>
+				<div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { stream } from '@/stream';
+import number from '@/filters/number';
+import * as sound from '@/scripts/sound';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+const name = 'jobQueue';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	sound: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const connection = stream.useChannel('queueStats');
+const current = reactive({
+	inbox: {
+		activeSincePrevTick: 0,
+		active: 0,
+		waiting: 0,
+		delayed: 0,
+	},
+	deliver: {
+		activeSincePrevTick: 0,
+		active: 0,
+		waiting: 0,
+		delayed: 0,
+	},
+});
+const prev = reactive({} as typeof current);
+const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
+
+for (const domain of ['inbox', 'deliver']) {
+	prev[domain] = deepClone(current[domain]);
+}
+
+const onStats = (stats) => {
+	for (const domain of ['inbox', 'deliver']) {
+		prev[domain] = deepClone(current[domain]);
+		current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
+		current[domain].active = stats[domain].active;
+		current[domain].waiting = stats[domain].waiting;
+		current[domain].delayed = stats[domain].delayed;
+
+		if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
+			jammedSound.play();
+		}
+	}
+};
+
+const onStatsLog = (statsLog) => {
+	for (const stats of [...statsLog].reverse()) {
+		onStats(stats);
+	}
+};
+
+connection.on('stats', onStats);
+connection.on('statsLog', onStatsLog);
+
+connection.send('requestLog', {
+	id: Math.random().toString().substr(2, 8),
+	length: 1,
+});
+
+onUnmounted(() => {
+	connection.off('stats', onStats);
+	connection.off('statsLog', onStatsLog);
+	connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes warnBlink {
+	0% { opacity: 1; }
+	50% { opacity: 0; }
+}
+
+.mkw-jobQueue {
+	font-size: 0.9em;
+
+	> div {
+		padding: 16px;
+
+		&:not(:first-child) {
+			border-top: solid 0.5px var(--divider);
+		}
+
+		> .label {
+			display: flex;
+
+			> .icon {
+				color: var(--warn);
+				margin-left: auto;
+				animation: warnBlink 1s infinite;
+			}
+		}
+
+		> .values {
+			display: flex;
+
+			> div {
+				flex: 1;
+
+				> div:first-child {
+					opacity: 0.7;
+				}
+
+				> div:last-child {
+					&.inc {
+						color: var(--warn);
+					}
+
+					&.dec {
+						color: var(--success);
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/memo.vue b/packages/frontend/src/widgets/memo.vue
new file mode 100644
index 0000000000..1cc0e10bba
--- /dev/null
+++ b/packages/frontend/src/widgets/memo.vue
@@ -0,0 +1,111 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-memo">
+	<template #header><i class="ti ti-note"></i>{{ i18n.ts._widgets.memo }}</template>
+
+	<div class="otgbylcu">
+		<textarea v-model="text" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea>
+		<button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const name = 'memo';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const text = ref<string | null>(defaultStore.state.memo);
+const changed = ref(false);
+let timeoutId;
+
+const saveMemo = () => {
+	defaultStore.set('memo', text.value);
+	changed.value = false;
+};
+
+const onChange = () => {
+	changed.value = true;
+	window.clearTimeout(timeoutId);
+	timeoutId = window.setTimeout(saveMemo, 1000);
+};
+
+watch(() => defaultStore.reactiveState.memo, newText => {
+	text.value = newText.value;
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.otgbylcu {
+	padding-bottom: 28px + 16px;
+
+	> textarea {
+		display: block;
+		width: 100%;
+		max-width: 100%;
+		min-width: 100%;
+		padding: 16px;
+		color: var(--fg);
+		background: transparent;
+		border: none;
+		border-bottom: solid 0.5px var(--divider);
+		border-radius: 0;
+		box-sizing: border-box;
+		font: inherit;
+		font-size: 0.9em;
+
+		&:focus-visible {
+			outline: none;
+		}
+	}
+
+	> button {
+		display: block;
+		position: absolute;
+		bottom: 8px;
+		right: 8px;
+		margin: 0;
+		padding: 0 10px;
+		height: 28px;
+		outline: none;
+		border-radius: 4px;
+
+		&:disabled {
+			opacity: 0.7;
+			cursor: default;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/notifications.vue b/packages/frontend/src/widgets/notifications.vue
new file mode 100644
index 0000000000..e697209444
--- /dev/null
+++ b/packages/frontend/src/widgets/notifications.vue
@@ -0,0 +1,70 @@
+<template>
+<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications">
+	<template #header><i class="ti ti-bell"></i>{{ i18n.ts.notifications }}</template>
+	<template #func><button class="_button" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
+
+	<div>
+		<XNotifications :include-types="widgetProps.includingTypes"/>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import XNotifications from '@/components/MkNotifications.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const name = 'notifications';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	height: {
+		type: 'number' as const,
+		default: 300,
+	},
+	includingTypes: {
+		type: 'array' as const,
+		hidden: true,
+		default: null,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const configureNotification = () => {
+	os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
+		includingTypes: widgetProps.includingTypes,
+	}, {
+		done: async (res) => {
+			const { includingTypes } = res;
+			widgetProps.includingTypes = includingTypes;
+			save();
+		},
+	}, 'closed');
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/online-users.vue b/packages/frontend/src/widgets/online-users.vue
new file mode 100644
index 0000000000..e9ab79b111
--- /dev/null
+++ b/packages/frontend/src/widgets/online-users.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
+	<I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text">
+		<template #n><b>{{ onlineUsersCount }}</b></template>
+	</I18n>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'onlineUsers';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const onlineUsersCount = ref(0);
+
+const tick = () => {
+	os.api('get-online-users-count').then(res => {
+		onlineUsersCount.value = res.count;
+	});
+};
+
+useInterval(tick, 1000 * 15, {
+	immediate: true,
+	afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-onlineUsers {
+	text-align: center;
+
+	&.pad {
+		padding: 16px 0;
+	}
+
+	> .text {
+		::v-deep(b) {
+			color: #41b781;
+		}
+
+		::v-deep(span) {
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/photos.vue b/packages/frontend/src/widgets/photos.vue
new file mode 100644
index 0000000000..4ad5324053
--- /dev/null
+++ b/packages/frontend/src/widgets/photos.vue
@@ -0,0 +1,123 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos">
+	<template #header><i class="ti ti-camera"></i>{{ i18n.ts._widgets.photos }}</template>
+
+	<div class="">
+		<MkLoading v-if="fetching"/>
+		<div v-else :class="$style.stream">
+			<div
+				v-for="(image, i) in images" :key="i"
+				:class="$style.img"
+				:style="`background-image: url(${thumbnail(image)})`"
+			></div>
+		</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { stream } from '@/stream';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const name = 'photos';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const connection = stream.useChannel('main');
+const images = ref([]);
+const fetching = ref(true);
+
+const onDriveFileCreated = (file) => {
+	if (/^image\/.+$/.test(file.type)) {
+		images.value.unshift(file);
+		if (images.value.length > 9) images.value.pop();
+	}
+};
+
+const thumbnail = (image: any): string => {
+	return defaultStore.state.disableShowingAnimatedImages
+		? getStaticImageUrl(image.thumbnailUrl)
+		: image.thumbnailUrl;
+};
+
+os.api('drive/stream', {
+	type: 'image/*',
+	limit: 9,
+}).then(res => {
+	images.value = res;
+	fetching.value = false;
+});
+
+connection.on('driveFileCreated', onDriveFileCreated);
+onUnmounted(() => {
+	connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" module>
+.root[data-transparent] {
+	.stream {
+		padding: 0;
+	}
+
+	.img {
+		border: solid 4px transparent;
+		border-radius: 8px;
+	}
+}
+
+.stream {
+	display: flex;
+	justify-content: center;
+	flex-wrap: wrap;
+	padding: 8px;
+
+	.img {
+		flex: 1 1 33%;
+		width: 33%;
+		height: 80px;
+		box-sizing: border-box;
+		background-position: center center;
+		background-size: cover;
+		background-clip: content-box;
+		border: solid 2px transparent;
+		border-radius: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/post-form.vue b/packages/frontend/src/widgets/post-form.vue
new file mode 100644
index 0000000000..f1708775ba
--- /dev/null
+++ b/packages/frontend/src/widgets/post-form.vue
@@ -0,0 +1,35 @@
+<template>
+<XPostForm class="_panel mkw-postForm" :fixed="true" :autofocus="false"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import XPostForm from '@/components/MkPostForm.vue';
+
+const name = 'postForm';
+
+const widgetPropsDef = {
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/rss-ticker.vue b/packages/frontend/src/widgets/rss-ticker.vue
new file mode 100644
index 0000000000..44c21d1836
--- /dev/null
+++ b/packages/frontend/src/widgets/rss-ticker.vue
@@ -0,0 +1,152 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker">
+	<template #header><i class="ti ti-rss"></i>RSS</template>
+	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
+
+	<div class="ekmkgxbk">
+		<MkLoading v-if="fetching"/>
+		<div v-else class="feed">
+			<transition name="change" mode="default">
+				<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse">
+					<span v-for="item in items" class="item">
+						<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
+					</span>
+				</MarqueeText>
+			</transition>
+		</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import MarqueeText from '@/components/MkMarquee.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { useInterval } from '@/scripts/use-interval';
+import { shuffle } from '@/scripts/shuffle';
+
+const name = 'rssTicker';
+
+const widgetPropsDef = {
+	url: {
+		type: 'string' as const,
+		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+	},
+	shuffle: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	refreshIntervalSec: {
+		type: 'number' as const,
+		default: 60,
+	},
+	duration: {
+		type: 'range' as const,
+		default: 70,
+		step: 1,
+		min: 5,
+		max: 200,
+	},
+	reverse: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	showHeader: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const items = ref([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
+		res.json().then(feed => {
+			if (widgetProps.shuffle) {
+				shuffle(feed.items);
+			}
+			items.value = feed.items;
+			fetching.value = false;
+			key++;
+		});
+	});
+};
+
+watch(() => widgetProps.url, tick);
+
+useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
+	immediate: true,
+	afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+	position: absolute;
+	top: 0;
+  transition: all 1s ease;
+}
+.change-enter-from {
+  opacity: 0;
+	transform: translateY(-100%);
+}
+.change-leave-to {
+  opacity: 0;
+	transform: translateY(100%);
+}
+
+.ekmkgxbk {
+	> .feed {
+		--height: 42px;
+		padding: 0;
+		font-size: 0.9em;
+		line-height: var(--height);
+		height: var(--height);
+		contain: strict;
+
+		::v-deep(.item) {
+			display: inline-flex;
+			align-items: center;
+			vertical-align: bottom;
+			color: var(--fg);
+
+			> .divider {
+				display: inline-block;
+				width: 0.5px;
+				height: 16px;
+				margin: 0 1em;
+				background: var(--divider);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/rss.vue b/packages/frontend/src/widgets/rss.vue
new file mode 100644
index 0000000000..c0338c8e47
--- /dev/null
+++ b/packages/frontend/src/widgets/rss.vue
@@ -0,0 +1,96 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-rss">
+	<template #header><i class="ti ti-rss"></i>RSS</template>
+	<template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
+
+	<div class="ekmkgxbj">
+		<MkLoading v-if="fetching"/>
+		<div v-else class="feed">
+			<a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
+		</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { useInterval } from '@/scripts/use-interval';
+
+const name = 'rss';
+
+const widgetPropsDef = {
+	url: {
+		type: 'string' as const,
+		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+	},
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const items = ref([]);
+const fetching = ref(true);
+
+const tick = () => {
+	window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
+		res.json().then(feed => {
+			items.value = feed.items;
+			fetching.value = false;
+		});
+	});
+};
+
+watch(() => widgetProps.url, tick);
+
+useInterval(tick, 60000, {
+	immediate: true,
+	afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.ekmkgxbj {
+	> .feed {
+		padding: 0;
+		font-size: 0.9em;
+
+		> .item {
+			display: block;
+			padding: 8px 16px;
+			color: var(--fg);
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			overflow: hidden;
+
+			&:nth-child(even) {
+				background: rgba(#000, 0.05);
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
new file mode 100644
index 0000000000..80a8e427e1
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
@@ -0,0 +1,167 @@
+<template>
+<div class="lcfyofjk">
+	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+		<defs>
+			<linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
+				<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+				<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+			</linearGradient>
+			<mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+				<polygon
+					:points="cpuPolygonPoints"
+					fill="#fff"
+					fill-opacity="0.5"
+				/>
+				<polyline
+					:points="cpuPolylinePoints"
+					fill="none"
+					stroke="#fff"
+					stroke-width="1"
+				/>
+				<circle
+					:cx="cpuHeadX"
+					:cy="cpuHeadY"
+					r="1.5"
+					fill="#fff"
+				/>
+			</mask>
+		</defs>
+		<rect
+			x="-2" y="-2"
+			:width="viewBoxX + 4" :height="viewBoxY + 4"
+			:style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"
+		/>
+		<text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text>
+	</svg>
+	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+		<defs>
+			<linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
+				<stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+				<stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+			</linearGradient>
+			<mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+				<polygon
+					:points="memPolygonPoints"
+					fill="#fff"
+					fill-opacity="0.5"
+				/>
+				<polyline
+					:points="memPolylinePoints"
+					fill="none"
+					stroke="#fff"
+					stroke-width="1"
+				/>
+				<circle
+					:cx="memHeadX"
+					:cy="memHeadY"
+					r="1.5"
+					fill="#fff"
+				/>
+			</mask>
+		</defs>
+		<rect
+			x="-2" y="-2"
+			:width="viewBoxX + 4" :height="viewBoxY + 4"
+			:style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"
+		/>
+		<text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text>
+	</svg>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import { v4 as uuid } from 'uuid';
+
+const props = defineProps<{
+	connection: any,
+	meta: any
+}>();
+
+let viewBoxX: number = $ref(50);
+let viewBoxY: number = $ref(30);
+let stats: any[] = $ref([]);
+const cpuGradientId = uuid();
+const cpuMaskId = uuid();
+const memGradientId = uuid();
+const memMaskId = uuid();
+let cpuPolylinePoints: string = $ref('');
+let memPolylinePoints: string = $ref('');
+let cpuPolygonPoints: string = $ref('');
+let memPolygonPoints: string = $ref('');
+let cpuHeadX: any = $ref(null);
+let cpuHeadY: any = $ref(null);
+let memHeadX: any = $ref(null);
+let memHeadY: any = $ref(null);
+let cpuP: string = $ref('');
+let memP: string = $ref('');
+
+onMounted(() => {
+	props.connection.on('stats', onStats);
+	props.connection.on('statsLog', onStatsLog);
+	props.connection.send('requestLog', {
+		id: Math.random().toString().substr(2, 8),
+	});
+});
+
+onBeforeUnmount(() => {
+	props.connection.off('stats', onStats);
+	props.connection.off('statsLog', onStatsLog);
+});
+
+function onStats(connStats) {
+	stats.push(connStats);
+	if (stats.length > 50) stats.shift();
+
+	let cpuPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - s.cpu) * viewBoxY]);
+	let memPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.mem.active / props.meta.mem.total)) * viewBoxY]);
+	cpuPolylinePoints = cpuPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+	memPolylinePoints = memPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+	cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`;
+	memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`;
+
+	cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0];
+	cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1];
+	memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0];
+	memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1];
+
+	cpuP = (connStats.cpu * 100).toFixed(0);
+	memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
+}
+
+function onStatsLog(statsLog) {
+	for (const revStats of [...statsLog].reverse()) {
+		onStats(revStats);
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.lcfyofjk {
+	display: flex;
+
+	> svg {
+		display: block;
+		padding: 10px;
+		width: 50%;
+
+		&:first-child {
+			padding-right: 5px;
+		}
+
+		&:last-child {
+			padding-left: 5px;
+		}
+
+		> text {
+			font-size: 4.5px;
+			fill: currentColor;
+
+			> tspan {
+				opacity: 0.5;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/cpu.vue b/packages/frontend/src/widgets/server-metric/cpu.vue
new file mode 100644
index 0000000000..e7b2226d1f
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/cpu.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="vrvdvrys">
+	<XPie class="pie" :value="usage"/>
+	<div>
+		<p><i class="ti ti-cpu"></i>CPU</p>
+		<p>{{ meta.cpu.cores }} Logical cores</p>
+		<p>{{ meta.cpu.model }}</p>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import XPie from './pie.vue';
+
+const props = defineProps<{
+	connection: any,
+	meta: any
+}>();
+
+let usage: number = $ref(0);
+
+function onStats(stats) {
+	usage = stats.cpu;
+}
+
+onMounted(() => {
+	props.connection.on('stats', onStats);
+});
+
+onBeforeUnmount(() => {
+	props.connection.off('stats', onStats);
+});
+</script>
+
+<style lang="scss" scoped>
+.vrvdvrys {
+	display: flex;
+	padding: 16px;
+
+	> .pie {
+		height: 82px;
+		flex-shrink: 0;
+		margin-right: 16px;
+	}
+
+	> div {
+		flex: 1;
+
+		> p {
+			margin: 0;
+			font-size: 0.8em;
+
+			&:first-child {
+				font-weight: bold;
+				margin-bottom: 4px;
+
+				> i {
+					margin-right: 4px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/disk.vue b/packages/frontend/src/widgets/server-metric/disk.vue
new file mode 100644
index 0000000000..3d22d05383
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/disk.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="zbwaqsat">
+	<XPie class="pie" :value="usage"/>
+	<div>
+		<p><i class="ti ti-database"></i>Disk</p>
+		<p>Total: {{ bytes(total, 1) }}</p>
+		<p>Free: {{ bytes(available, 1) }}</p>
+		<p>Used: {{ bytes(used, 1) }}</p>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+	meta: any; // TODO
+}>();
+
+const usage = $computed(() => props.meta.fs.used / props.meta.fs.total);
+const total = $computed(() => props.meta.fs.total);
+const used = $computed(() => props.meta.fs.used);
+const available = $computed(() => props.meta.fs.total - props.meta.fs.used);
+</script>
+
+<style lang="scss" scoped>
+.zbwaqsat {
+	display: flex;
+	padding: 16px;
+
+	> .pie {
+		height: 82px;
+		flex-shrink: 0;
+		margin-right: 16px;
+	}
+
+	> div {
+		flex: 1;
+
+		> p {
+			margin: 0;
+			font-size: 0.8em;
+
+			&:first-child {
+				font-weight: bold;
+				margin-bottom: 4px;
+
+				> i {
+					margin-right: 4px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue
new file mode 100644
index 0000000000..bc3fca6fc1
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/index.vue
@@ -0,0 +1,87 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
+	<template #header><i class="ti ti-server"></i>{{ i18n.ts._widgets.serverMetric }}</template>
+	<template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
+
+	<div v-if="meta" class="mkw-serverMetric">
+		<XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/>
+		<XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/>
+		<XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/>
+		<XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/>
+		<XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget';
+import XCpuMemory from './cpu-mem.vue';
+import XNet from './net.vue';
+import XCpu from './cpu.vue';
+import XMemory from './mem.vue';
+import XDisk from './disk.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const name = 'serverMetric';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	view: {
+		type: 'number' as const,
+		default: 0,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const meta = ref(null);
+
+os.api('server-info', {}).then(res => {
+	meta.value = res;
+});
+
+const toggleView = () => {
+	if (widgetProps.view === 4) {
+		widgetProps.view = 0;
+	} else {
+		widgetProps.view++;
+	}
+	save();
+};
+
+const connection = stream.useChannel('serverStats');
+onUnmounted(() => {
+	connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/server-metric/mem.vue b/packages/frontend/src/widgets/server-metric/mem.vue
new file mode 100644
index 0000000000..6018eb4265
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/mem.vue
@@ -0,0 +1,73 @@
+<template>
+<div class="zlxnikvl">
+	<XPie class="pie" :value="usage"/>
+	<div>
+		<p><i class="fas fa-memory"></i>RAM</p>
+		<p>Total: {{ bytes(total, 1) }}</p>
+		<p>Used: {{ bytes(used, 1) }}</p>
+		<p>Free: {{ bytes(free, 1) }}</p>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+	connection: any,
+	meta: any
+}>();
+
+let usage: number = $ref(0);
+let total: number = $ref(0);
+let used: number = $ref(0);
+let free: number = $ref(0);
+
+function onStats(stats) {
+	usage = stats.mem.active / props.meta.mem.total;
+	total = props.meta.mem.total;
+	used = stats.mem.active;
+	free = total - used;
+}
+
+onMounted(() => {
+	props.connection.on('stats', onStats);
+});
+
+onBeforeUnmount(() => {
+	props.connection.off('stats', onStats);
+});
+</script>
+
+<style lang="scss" scoped>
+.zlxnikvl {
+	display: flex;
+	padding: 16px;
+
+	> .pie {
+		height: 82px;
+		flex-shrink: 0;
+		margin-right: 16px;
+	}
+
+	> div {
+		flex: 1;
+
+		> p {
+			margin: 0;
+			font-size: 0.8em;
+
+			&:first-child {
+				font-weight: bold;
+				margin-bottom: 4px;
+
+				> i {
+					margin-right: 4px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue
new file mode 100644
index 0000000000..ab8b0fe471
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/net.vue
@@ -0,0 +1,140 @@
+<template>
+<div class="oxxrhrto">
+	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+		<polygon
+			:points="inPolygonPoints"
+			fill="#94a029"
+			fill-opacity="0.5"
+		/>
+		<polyline
+			:points="inPolylinePoints"
+			fill="none"
+			stroke="#94a029"
+			stroke-width="1"
+		/>
+		<circle
+			:cx="inHeadX"
+			:cy="inHeadY"
+			r="1.5"
+			fill="#94a029"
+		/>
+		<text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text>
+	</svg>
+	<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+		<polygon
+			:points="outPolygonPoints"
+			fill="#ff9156"
+			fill-opacity="0.5"
+		/>
+		<polyline
+			:points="outPolylinePoints"
+			fill="none"
+			stroke="#ff9156"
+			stroke-width="1"
+		/>
+		<circle
+			:cx="outHeadX"
+			:cy="outHeadY"
+			r="1.5"
+			fill="#ff9156"
+		/>
+		<text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text>
+	</svg>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+	connection: any,
+	meta: any
+}>();
+
+let viewBoxX: number = $ref(50);
+let viewBoxY: number = $ref(30);
+let stats: any[] = $ref([]);
+let inPolylinePoints: string = $ref('');
+let outPolylinePoints: string = $ref('');
+let inPolygonPoints: string = $ref('');
+let outPolygonPoints: string = $ref('');
+let inHeadX: any = $ref(null);
+let inHeadY: any = $ref(null);
+let outHeadX: any = $ref(null);
+let outHeadY: any = $ref(null);
+let inRecent: number = $ref(0);
+let outRecent: number = $ref(0);
+
+onMounted(() => {
+	props.connection.on('stats', onStats);
+	props.connection.on('statsLog', onStatsLog);
+	props.connection.send('requestLog', {
+		id: Math.random().toString().substr(2, 8),
+	});
+});
+
+onBeforeUnmount(() => {
+	props.connection.off('stats', onStats);
+	props.connection.off('statsLog', onStatsLog);
+});
+
+function onStats(connStats) {
+	stats.push(connStats);
+	if (stats.length > 50) stats.shift();
+
+	const inPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.rx)));
+	const outPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.tx)));
+
+	let inPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * viewBoxY]);
+	let outPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * viewBoxY]);
+	inPolylinePoints = inPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+	outPolylinePoints = outPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+	inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`;
+	outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`;
+
+	inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0];
+	inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1];
+	outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0];
+	outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1];
+
+	inRecent = connStats.net.rx;
+	outRecent = connStats.net.tx;
+}
+
+function onStatsLog(statsLog) {
+	for (const revStats of [...statsLog].reverse()) {
+		onStats(revStats);
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.oxxrhrto {
+	display: flex;
+
+	> svg {
+		display: block;
+		padding: 10px;
+		width: 50%;
+
+		&:first-child {
+			padding-right: 5px;
+		}
+
+		&:last-child {
+			padding-left: 5px;
+		}
+
+		> text {
+			font-size: 4.5px;
+			fill: currentColor;
+
+			> tspan {
+				opacity: 0.5;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue
new file mode 100644
index 0000000000..868dbc0484
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/pie.vue
@@ -0,0 +1,52 @@
+<template>
+<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
+	<circle
+		:r="r"
+		cx="50%" cy="50%"
+		fill="none"
+		stroke-width="0.1"
+		stroke="rgba(0, 0, 0, 0.05)"
+	/>
+	<circle
+		:r="r"
+		cx="50%" cy="50%"
+		:stroke-dasharray="Math.PI * (r * 2)"
+		:stroke-dashoffset="strokeDashoffset"
+		fill="none"
+		stroke-width="0.1"
+		:stroke="color"
+	/>
+	<text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
+</svg>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+	value: number;
+}>();
+
+const r = 0.45;
+
+const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`);
+const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2)));
+</script>
+
+<style lang="scss" scoped>
+.hsalcinq {
+	display: block;
+	height: 100%;
+
+	> circle {
+		transform-origin: center;
+		transform: rotate(-90deg);
+		transition: stroke-dashoffset 0.5s ease;
+	}
+
+	> text {
+		font-size: 0.15px;
+		fill: currentColor;
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/slideshow.vue b/packages/frontend/src/widgets/slideshow.vue
new file mode 100644
index 0000000000..e317b8ab94
--- /dev/null
+++ b/packages/frontend/src/widgets/slideshow.vue
@@ -0,0 +1,159 @@
+<template>
+<div class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }">
+	<div @click="choose">
+		<p v-if="widgetProps.folderId == null">
+			{{ i18n.ts.folder }}
+		</p>
+		<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
+		<div ref="slideA" class="slide a"></div>
+		<div ref="slideB" class="slide b"></div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'slideshow';
+
+const widgetPropsDef = {
+	height: {
+		type: 'number' as const,
+		default: 300,
+	},
+	folderId: {
+		type: 'string' as const,
+		default: null,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const images = ref([]);
+const fetching = ref(true);
+const slideA = ref<HTMLElement>();
+const slideB = ref<HTMLElement>();
+
+const change = () => {
+	if (images.value.length === 0) return;
+
+	const index = Math.floor(Math.random() * images.value.length);
+	const img = `url(${ images.value[index].url })`;
+
+	slideB.value.style.backgroundImage = img;
+
+	slideB.value.classList.add('anime');
+	window.setTimeout(() => {
+		// 既にこのウィジェットがunmountされていたら要素がない
+		if (slideA.value == null) return;
+
+		slideA.value.style.backgroundImage = img;
+
+		slideB.value.classList.remove('anime');
+	}, 1000);
+};
+
+const fetch = () => {
+	fetching.value = true;
+
+	os.api('drive/files', {
+		folderId: widgetProps.folderId,
+		type: 'image/*',
+		limit: 100,
+	}).then(res => {
+		images.value = res;
+		fetching.value = false;
+		slideA.value.style.backgroundImage = '';
+		slideB.value.style.backgroundImage = '';
+		change();
+	});
+};
+
+const choose = () => {
+	os.selectDriveFolder(false).then(folder => {
+		if (folder == null) {
+			return;
+		}
+		widgetProps.folderId = folder.id;
+		save();
+		fetch();
+	});
+};
+
+useInterval(change, 10000, {
+	immediate: false,
+	afterMounted: true,
+});
+
+onMounted(() => {
+	if (widgetProps.folderId != null) {
+		fetch();
+	}
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.kvausudm {
+	position: relative;
+
+	> div {
+		width: 100%;
+		height: 100%;
+		cursor: pointer;
+
+		> p {
+			display: block;
+			margin: 1em;
+			text-align: center;
+			color: #888;
+		}
+
+		> * {
+			pointer-events: none;
+		}
+
+		> .slide {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background-size: cover;
+			background-position: center;
+
+			&.b {
+				opacity: 0;
+			}
+
+			&.anime {
+				transition: opacity 1s;
+				opacity: 1;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/timeline.vue b/packages/frontend/src/widgets/timeline.vue
new file mode 100644
index 0000000000..e48444d33f
--- /dev/null
+++ b/packages/frontend/src/widgets/timeline.vue
@@ -0,0 +1,129 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" class="mkw-timeline">
+	<template #header>
+		<button class="_button" @click="choose">
+			<i v-if="widgetProps.src === 'home'" class="ti ti-home"></i>
+			<i v-else-if="widgetProps.src === 'local'" class="ti ti-messages"></i>
+			<i v-else-if="widgetProps.src === 'social'" class="ti ti-share"></i>
+			<i v-else-if="widgetProps.src === 'global'" class="ti ti-world"></i>
+			<i v-else-if="widgetProps.src === 'list'" class="ti ti-list"></i>
+			<i v-else-if="widgetProps.src === 'antenna'" class="ti ti-antenna"></i>
+			<span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span>
+			<i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i>
+		</button>
+	</template>
+
+	<div>
+		<XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'timeline';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	height: {
+		type: 'number' as const,
+		default: 300,
+	},
+	src: {
+		type: 'string' as const,
+		default: 'home',
+		hidden: true,
+	},
+	antenna: {
+		type: 'object' as const,
+		default: null,
+		hidden: true,
+	},
+	list: {
+		type: 'object' as const,
+		default: null,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const menuOpened = ref(false);
+
+const setSrc = (src) => {
+	widgetProps.src = src;
+	save();
+};
+
+const choose = async (ev) => {
+	menuOpened.value = true;
+	const [antennas, lists] = await Promise.all([
+		os.api('antennas/list'),
+		os.api('users/lists/list'),
+	]);
+	const antennaItems = antennas.map(antenna => ({
+		text: antenna.name,
+		icon: 'ti ti-antenna',
+		action: () => {
+			widgetProps.antenna = antenna;
+			setSrc('antenna');
+		},
+	}));
+	const listItems = lists.map(list => ({
+		text: list.name,
+		icon: 'ti ti-list',
+		action: () => {
+			widgetProps.list = list;
+			setSrc('list');
+		},
+	}));
+	os.popupMenu([{
+		text: i18n.ts._timelines.home,
+		icon: 'ti ti-home',
+		action: () => { setSrc('home'); },
+	}, {
+		text: i18n.ts._timelines.local,
+		icon: 'ti ti-messages',
+		action: () => { setSrc('local'); },
+	}, {
+		text: i18n.ts._timelines.social,
+		icon: 'ti ti-share',
+		action: () => { setSrc('social'); },
+	}, {
+		text: i18n.ts._timelines.global,
+		icon: 'ti ti-world',
+		action: () => { setSrc('global'); },
+	}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
+		menuOpened.value = false;
+	});
+};
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/trends.vue b/packages/frontend/src/widgets/trends.vue
new file mode 100644
index 0000000000..02eec0431e
--- /dev/null
+++ b/packages/frontend/src/widgets/trends.vue
@@ -0,0 +1,120 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-trends">
+	<template #header><i class="ti ti-hash"></i>{{ i18n.ts._widgets.trends }}</template>
+
+	<div class="wbrkwala">
+		<MkLoading v-if="fetching"/>
+		<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags">
+			<div v-for="stat in stats" :key="stat.tag">
+				<div class="tag">
+					<MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
+					<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
+				</div>
+				<MkMiniChart class="chart" :src="stat.chart"/>
+			</div>
+		</transition-group>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'hashtags';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const stats = ref([]);
+const fetching = ref(true);
+
+const fetch = () => {
+	os.api('hashtags/trend').then(res => {
+		stats.value = res;
+		fetching.value = false;
+	});
+};
+
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwala {
+	height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+	overflow: hidden;
+
+	> .tags {
+		.chart-move {
+			transition: transform 1s ease;
+		}
+
+		> div {
+			display: flex;
+			align-items: center;
+			padding: 14px 16px;
+			border-bottom: solid 0.5px var(--divider);
+
+			> .tag {
+				flex: 1;
+				overflow: hidden;
+				font-size: 0.9em;
+				color: var(--fg);
+
+				> .a {
+					display: block;
+					width: 100%;
+					white-space: nowrap;
+					overflow: hidden;
+					text-overflow: ellipsis;
+					line-height: 18px;
+				}
+
+				> p {
+					margin: 0;
+					font-size: 75%;
+					opacity: 0.7;
+					line-height: 16px;
+				}
+			}
+
+			> .chart {
+				height: 30px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/unix-clock.vue b/packages/frontend/src/widgets/unix-clock.vue
new file mode 100644
index 0000000000..cf85ac782c
--- /dev/null
+++ b/packages/frontend/src/widgets/unix-clock.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="mkw-unixClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
+	<div v-if="widgetProps.showLabel" class="label">UNIX Epoch</div>
+	<div class="time">
+		<span v-text="ss"></span>
+		<span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span>
+		<span v-if="widgetProps.showMs" v-text="ms"></span>
+	</div>
+	<div v-if="widgetProps.showLabel" class="label">UTC</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+
+const name = 'unixClock';
+
+const widgetPropsDef = {
+	transparent: {
+		type: 'boolean' as const,
+		default: false,
+	},
+	fontSize: {
+		type: 'number' as const,
+		default: 1.5,
+		step: 0.1,
+	},
+	showMs: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	showLabel: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+let intervalId;
+const ss = ref('');
+const ms = ref('');
+const showColon = ref(false);
+let prevSec: string | null = null;
+
+watch(showColon, (v) => {
+	if (v) {
+		window.setTimeout(() => {
+			showColon.value = false;
+		}, 30);
+	}
+});
+
+const tick = () => {
+	const now = new Date();
+	ss.value = Math.floor(now.getTime() / 1000).toString();
+	ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0');
+	if (ss.value !== prevSec) showColon.value = true;
+	prevSec = ss.value;
+};
+
+tick();
+
+watch(() => widgetProps.showMs, () => {
+	if (intervalId) window.clearInterval(intervalId);
+	intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000);
+}, { immediate: true });
+
+onUnmounted(() => {
+	window.clearInterval(intervalId);
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-unixClock {
+	padding: 16px 0;
+	text-align: center;
+
+	> .label {
+		font-size: 65%;
+		opacity: 0.7;
+	}
+
+	> .time {
+		> .colon {
+			opacity: 0;
+			transition: opacity 1s ease;
+
+			&.showColon {
+				opacity: 1;
+				transition: opacity 0s;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/user-list.vue b/packages/frontend/src/widgets/user-list.vue
new file mode 100644
index 0000000000..9ffbf0d8e3
--- /dev/null
+++ b/packages/frontend/src/widgets/user-list.vue
@@ -0,0 +1,136 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList">
+	<template #header><i class="ti ti-users"></i>{{ list ? list.name : i18n.ts._widgets.userList }}</template>
+	<template #func><button class="_button" @click="configure()"><i class="ti ti-settings"></i></button></template>
+
+	<div :class="$style.root">
+		<div v-if="widgetProps.listId == null" class="init">
+			<MkButton primary @click="chooseList">{{ i18n.ts._widgets._userList.chooseList }}</MkButton>
+		</div>
+		<MkLoading v-else-if="fetching"/>
+		<div v-else class="users">
+			<MkA v-for="user in users" :key="user.id" class="user">
+				<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+			</MkA>
+		</div>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+
+const name = 'userList';
+
+const widgetPropsDef = {
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+	listId: {
+		type: 'string' as const,
+		default: null,
+		hidden: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+let list = $ref();
+let users = $ref([]);
+let fetching = $ref(true);
+
+async function chooseList() {
+	const lists = await os.api('users/lists/list');
+	const { canceled, result: list } = await os.select({
+		title: i18n.ts.selectList,
+		items: lists.map(x => ({
+			value: x, text: x.name,
+		})),
+		default: widgetProps.listId,
+	});
+	if (canceled) return;
+
+	widgetProps.listId = list.id;
+	save();
+	fetch();
+}
+
+const fetch = () => {
+	if (widgetProps.listId == null) {
+		fetching = false;
+		return;
+	}
+
+	os.api('users/lists/show', {
+		listId: widgetProps.listId,
+	}).then(_list => {
+		list = _list;
+		os.api('users/show', {
+			userIds: list.userIds,
+		}).then(_users => {
+			users = _users;
+			fetching = false;
+		});
+	});
+};
+
+useInterval(fetch, 1000 * 60, {
+	immediate: true,
+	afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	&:global {
+		> .init {
+			padding: 16px;
+		}
+
+		> .users {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
+			grid-gap: 12px;
+			place-content: center;
+			padding: 16px;
+
+			> .user {
+				width: 100%;
+				height: 100%;
+				aspect-ratio: 1;
+
+				> .avatar {
+					width: 100%;
+					height: 100%;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts
new file mode 100644
index 0000000000..8bd56a5966
--- /dev/null
+++ b/packages/frontend/src/widgets/widget.ts
@@ -0,0 +1,73 @@
+import { reactive, watch } from 'vue';
+import { throttle } from 'throttle-debounce';
+import { Form, GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+export type Widget<P extends Record<string, unknown>> = {
+	id: string;
+	data: Partial<P>;
+};
+
+export type WidgetComponentProps<P extends Record<string, unknown>> = {
+	widget?: Widget<P>;
+};
+
+export type WidgetComponentEmits<P extends Record<string, unknown>> = {
+	(ev: 'updateProps', props: P);
+};
+
+export type WidgetComponentExpose = {
+	name: string;
+	id: string | null;
+	configure: () => void;
+};
+
+export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>(
+	name: string,
+	propsDef: F,
+	props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
+	emit: WidgetComponentEmits<GetFormResultType<F>>,
+): {
+	widgetProps: GetFormResultType<F>;
+	save: () => void;
+	configure: () => void;
+} => {
+	const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {});
+
+	const mergeProps = () => {
+		for (const prop of Object.keys(propsDef)) {
+			if (typeof widgetProps[prop] === 'undefined') {
+				widgetProps[prop] = propsDef[prop].default;
+			}
+		}
+	};
+	watch(widgetProps, () => {
+		mergeProps();
+	}, { deep: true, immediate: true });
+
+	const save = throttle(3000, () => {
+		emit('updateProps', widgetProps);
+	});
+
+	const configure = async () => {
+		const form = deepClone(propsDef);
+		for (const item of Object.keys(form)) {
+			form[item].default = widgetProps[item];
+		}
+		const { canceled, result } = await os.form(name, form);
+		if (canceled) return;
+
+		for (const key of Object.keys(result)) {
+			widgetProps[key] = result[key];
+		}
+
+		save();
+	};
+
+	return {
+		widgetProps,
+		save,
+		configure,
+	};
+};
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
new file mode 100644
index 0000000000..86109f600a
--- /dev/null
+++ b/packages/frontend/tsconfig.json
@@ -0,0 +1,47 @@
+{
+	"compilerOptions": {
+		"allowJs": true,
+		"noEmitOnError": false,
+		"noImplicitAny": false,
+		"noImplicitReturns": true,
+		"noUnusedParameters": false,
+		"noUnusedLocals": true,
+		"noFallthroughCasesInSwitch": true,
+		"declaration": false,
+		"sourceMap": false,
+		"target": "es2017",
+		"module": "esnext",
+		"moduleResolution": "node",
+		"removeComments": false,
+		"noLib": false,
+		"strict": true,
+		"strictNullChecks": true,
+		"experimentalDecorators": true,
+		"resolveJsonModule": true,
+		"allowSyntheticDefaultImports": true,
+		"isolatedModules": true,
+		"useDefineForClassFields": true,
+		"baseUrl": ".",
+		"paths": {
+			"@/*": ["./src/*"],
+		},
+		"typeRoots": [
+			"node_modules/@types",
+			"@types",
+		],
+		"types": [
+			"vite/client",
+		],
+		"lib": [
+			"esnext",
+			"dom"
+		],
+		"jsx": "preserve"
+	},
+	"compileOnSave": false,
+	"include": [
+		".eslintrc.js",
+		"./**/*.ts",
+		"./**/*.vue"
+	]
+}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
new file mode 100644
index 0000000000..1acf5301b7
--- /dev/null
+++ b/packages/frontend/vite.config.ts
@@ -0,0 +1,70 @@
+import pluginVue from '@vitejs/plugin-vue';
+import { defineConfig } from 'vite';
+
+import locales from '../../locales';
+import meta from '../../package.json';
+import pluginJson5 from './vite.json5';
+
+const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
+
+export default defineConfig(({ command, mode }) => {
+
+	return {
+		base: '/vite/',
+
+		plugins: [
+			pluginVue({
+				reactivityTransform: true,
+			}),
+			pluginJson5(),
+		],
+
+		resolve: {
+			extensions,
+			alias: {
+				'@/': __dirname + '/src/',
+				'/client-assets/': __dirname + '/assets/',
+				'/static-assets/': __dirname + '/../backend/assets/',
+			},
+		},
+
+		define: {
+			_VERSION_: JSON.stringify(meta.version),
+			_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+			_ENV_: JSON.stringify(process.env.NODE_ENV),
+			_DEV_: process.env.NODE_ENV !== 'production',
+			_PERF_PREFIX_: JSON.stringify('Misskey:'),
+			_DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'),
+			_DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'),
+			_DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'),
+			__VUE_OPTIONS_API__: true,
+			__VUE_PROD_DEVTOOLS__: false,
+		},
+
+		build: {
+			target: [
+				'chrome100',
+				'firefox100',
+				'safari15',
+				'es2017', // TODO: そのうち消す
+			],
+			manifest: 'manifest.json',
+			rollupOptions: {
+				input: {
+					app: './src/init.ts',
+				},
+				output: {
+					manualChunks: {
+						vue: ['vue'],
+					},
+				},
+			},
+			cssCodeSplit: true,
+			outDir: __dirname + '/../../built/_vite_',
+			assetsDir: '.',
+			emptyOutDir: false,
+			sourcemap: process.env.NODE_ENV === 'development',
+			reportCompressedSize: false,
+		},
+	};
+});
diff --git a/packages/frontend/vite.json5.ts b/packages/frontend/vite.json5.ts
new file mode 100644
index 0000000000..0a37fbff44
--- /dev/null
+++ b/packages/frontend/vite.json5.ts
@@ -0,0 +1,38 @@
+// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json
+
+import JSON5 from 'json5';
+import { Plugin } from 'rollup';
+import { createFilter, dataToEsm } from '@rollup/pluginutils';
+import { RollupJsonOptions } from '@rollup/plugin-json';
+
+export default function json5(options: RollupJsonOptions = {}): Plugin {
+	const filter = createFilter(options.include, options.exclude);
+	const indent = 'indent' in options ? options.indent : '\t';
+
+	return {
+		name: 'json5',
+
+		// eslint-disable-next-line no-shadow
+		transform(json, id) {
+			if (id.slice(-6) !== '.json5' || !filter(id)) return null;
+
+			try {
+				const parsed = JSON5.parse(json);
+				return {
+					code: dataToEsm(parsed, {
+						preferConst: options.preferConst,
+						compact: options.compact,
+						namedExports: options.namedExports,
+						indent,
+					}),
+					map: { mappings: '' },
+				};
+			} catch (err) {
+				const message = 'Could not parse JSON file';
+				const position = parseInt(/[\d]/.exec(err.message)[0], 10);
+				this.warn({ message, id, position });
+				return null;
+			}
+		},
+	};
+}
diff --git a/scripts/clean-all.js b/scripts/clean-all.js
index 49b9957657..c65a1c3a32 100644
--- a/scripts/clean-all.js
+++ b/scripts/clean-all.js
@@ -4,8 +4,8 @@ const fs = require('fs');
 	fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true });
 
-	fs.rmSync(__dirname + '/../packages/client/built', { recursive: true, force: true });
-	fs.rmSync(__dirname + '/../packages/client/node_modules', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true });
 
 	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true });
diff --git a/scripts/clean.js b/scripts/clean.js
index 70b9d882b5..d32586914b 100644
--- a/scripts/clean.js
+++ b/scripts/clean.js
@@ -2,7 +2,7 @@ const fs = require('fs');
 
 (async () => {
 	fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
-	fs.rmSync(__dirname + '/../packages/client/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../built', { recursive: true, force: true });
 })();
diff --git a/scripts/dev.js b/scripts/dev.js
index 24e8914ee9..eee27450c3 100644
--- a/scripts/dev.js
+++ b/scripts/dev.js
@@ -26,7 +26,7 @@ const fs = require('fs');
 		stderr: process.stderr,
 	});
 
-	execa('yarn', ['workspace', 'client', 'watch'], {
+	execa('yarn', ['workspace', 'frontend', 'watch'], {
 		cwd: __dirname + '/../',
 		stdout: process.stdout,
 		stderr: process.stderr,
diff --git a/yarn.lock b/yarn.lock
index 3fdc9145fe..e3606489d6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5139,94 +5139,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"client@workspace:packages/client":
-  version: 0.0.0-use.local
-  resolution: "client@workspace:packages/client"
-  dependencies:
-    "@discordapp/twemoji": 14.0.2
-    "@rollup/plugin-alias": 4.0.2
-    "@rollup/plugin-json": 6.0.0
-    "@rollup/pluginutils": 5.0.2
-    "@syuilo/aiscript": 0.11.1
-    "@tabler/icons": ^1.118.0
-    "@types/escape-regexp": 0.0.1
-    "@types/glob": 8.0.0
-    "@types/gulp": 4.0.10
-    "@types/gulp-rename": 2.0.1
-    "@types/katex": 0.14.0
-    "@types/matter-js": 0.18.2
-    "@types/punycode": 2.1.0
-    "@types/seedrandom": 3.0.3
-    "@types/throttle-debounce": 5.0.0
-    "@types/tinycolor2": 1.4.3
-    "@types/uuid": 9.0.0
-    "@types/websocket": 1.0.5
-    "@types/ws": 8.5.3
-    "@typescript-eslint/eslint-plugin": 5.47.0
-    "@typescript-eslint/parser": 5.47.0
-    "@vitejs/plugin-vue": 4.0.0
-    "@vue/compiler-sfc": 3.2.45
-    "@vue/runtime-core": 3.2.45
-    autobind-decorator: 2.4.0
-    autosize: 5.0.2
-    blurhash: 2.0.4
-    broadcast-channel: 4.18.1
-    browser-image-resizer: "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3"
-    chart.js: 4.1.1
-    chartjs-adapter-date-fns: 3.0.0
-    chartjs-chart-matrix: ^1.3.0
-    chartjs-plugin-gradient: 0.6.1
-    chartjs-plugin-zoom: 2.0.0
-    compare-versions: 5.0.1
-    cropperjs: 2.0.0-beta
-    cross-env: 7.0.3
-    cypress: 12.2.0
-    date-fns: 2.29.3
-    escape-regexp: 0.0.1
-    eslint: 8.30.0
-    eslint-plugin-import: 2.26.0
-    eslint-plugin-vue: 9.8.0
-    eventemitter3: 5.0.0
-    idb-keyval: 6.2.0
-    insert-text-at-cursor: 0.3.0
-    is-file-animated: 1.0.2
-    json5: 2.2.2
-    katex: 0.15.6
-    matter-js: 0.18.0
-    mfm-js: 0.23.0
-    misskey-js: 0.0.14
-    photoswipe: 5.3.4
-    prismjs: 1.29.0
-    punycode: 2.1.1
-    querystring: 0.2.1
-    rndstr: 1.0.0
-    rollup: 3.8.0
-    s-age: 1.1.2
-    sass: 1.57.1
-    seedrandom: 3.0.5
-    start-server-and-test: 1.15.2
-    strict-event-emitter-types: 2.0.0
-    stringz: 2.1.0
-    syuilo-password-strength: 0.0.1
-    textarea-caret: 3.1.0
-    three: 0.148.0
-    throttle-debounce: 5.0.0
-    tinycolor2: 1.4.2
-    tsc-alias: 1.8.2
-    tsconfig-paths: 4.1.1
-    twemoji-parser: 14.0.0
-    typescript: 4.9.4
-    uuid: 9.0.0
-    vanilla-tilt: 1.8.0
-    vite: 4.0.3
-    vue: 3.2.45
-    vue-eslint-parser: ^9.1.0
-    vue-prism-editor: 2.0.0-alpha.2
-    vue-tsc: ^1.0.16
-    vuedraggable: next
-  languageName: unknown
-  linkType: soft
-
 "cliui@npm:^3.2.0":
   version: 3.2.0
   resolution: "cliui@npm:3.2.0"
@@ -8135,6 +8047,94 @@ __metadata:
   languageName: node
   linkType: hard
 
+"frontend@workspace:packages/frontend":
+  version: 0.0.0-use.local
+  resolution: "frontend@workspace:packages/frontend"
+  dependencies:
+    "@discordapp/twemoji": 14.0.2
+    "@rollup/plugin-alias": 4.0.2
+    "@rollup/plugin-json": 6.0.0
+    "@rollup/pluginutils": 5.0.2
+    "@syuilo/aiscript": 0.11.1
+    "@tabler/icons": ^1.118.0
+    "@types/escape-regexp": 0.0.1
+    "@types/glob": 8.0.0
+    "@types/gulp": 4.0.10
+    "@types/gulp-rename": 2.0.1
+    "@types/katex": 0.14.0
+    "@types/matter-js": 0.18.2
+    "@types/punycode": 2.1.0
+    "@types/seedrandom": 3.0.3
+    "@types/throttle-debounce": 5.0.0
+    "@types/tinycolor2": 1.4.3
+    "@types/uuid": 9.0.0
+    "@types/websocket": 1.0.5
+    "@types/ws": 8.5.3
+    "@typescript-eslint/eslint-plugin": 5.47.0
+    "@typescript-eslint/parser": 5.47.0
+    "@vitejs/plugin-vue": 4.0.0
+    "@vue/compiler-sfc": 3.2.45
+    "@vue/runtime-core": 3.2.45
+    autobind-decorator: 2.4.0
+    autosize: 5.0.2
+    blurhash: 2.0.4
+    broadcast-channel: 4.18.1
+    browser-image-resizer: "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3"
+    chart.js: 4.1.1
+    chartjs-adapter-date-fns: 3.0.0
+    chartjs-chart-matrix: ^1.3.0
+    chartjs-plugin-gradient: 0.6.1
+    chartjs-plugin-zoom: 2.0.0
+    compare-versions: 5.0.1
+    cropperjs: 2.0.0-beta
+    cross-env: 7.0.3
+    cypress: 12.2.0
+    date-fns: 2.29.3
+    escape-regexp: 0.0.1
+    eslint: 8.30.0
+    eslint-plugin-import: 2.26.0
+    eslint-plugin-vue: 9.8.0
+    eventemitter3: 5.0.0
+    idb-keyval: 6.2.0
+    insert-text-at-cursor: 0.3.0
+    is-file-animated: 1.0.2
+    json5: 2.2.2
+    katex: 0.15.6
+    matter-js: 0.18.0
+    mfm-js: 0.23.0
+    misskey-js: 0.0.14
+    photoswipe: 5.3.4
+    prismjs: 1.29.0
+    punycode: 2.1.1
+    querystring: 0.2.1
+    rndstr: 1.0.0
+    rollup: 3.8.0
+    s-age: 1.1.2
+    sass: 1.57.1
+    seedrandom: 3.0.5
+    start-server-and-test: 1.15.2
+    strict-event-emitter-types: 2.0.0
+    stringz: 2.1.0
+    syuilo-password-strength: 0.0.1
+    textarea-caret: 3.1.0
+    three: 0.148.0
+    throttle-debounce: 5.0.0
+    tinycolor2: 1.4.2
+    tsc-alias: 1.8.2
+    tsconfig-paths: 4.1.1
+    twemoji-parser: 14.0.0
+    typescript: 4.9.4
+    uuid: 9.0.0
+    vanilla-tilt: 1.8.0
+    vite: 4.0.3
+    vue: 3.2.45
+    vue-eslint-parser: ^9.1.0
+    vue-prism-editor: 2.0.0-alpha.2
+    vue-tsc: ^1.0.16
+    vuedraggable: next
+  languageName: unknown
+  linkType: soft
+
 "fs-constants@npm:^1.0.0":
   version: 1.0.0
   resolution: "fs-constants@npm:1.0.0"
-- 
cgit v1.2.3-freya

