summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages
parentwip: retention for dashboard (diff)
downloadmisskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages')
-rw-r--r--packages/frontend/src/pages/_empty_.vue7
-rw-r--r--packages/frontend/src/pages/_error_.vue89
-rw-r--r--packages/frontend/src/pages/_loading_.vue6
-rw-r--r--packages/frontend/src/pages/about-misskey.vue264
-rw-r--r--packages/frontend/src/pages/about.emojis.vue134
-rw-r--r--packages/frontend/src/pages/about.federation.vue106
-rw-r--r--packages/frontend/src/pages/about.vue166
-rw-r--r--packages/frontend/src/pages/admin-file.vue160
-rw-r--r--packages/frontend/src/pages/admin/_header_.vue292
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue97
-rw-r--r--packages/frontend/src/pages/admin/ads.vue132
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue112
-rw-r--r--packages/frontend/src/pages/admin/bot-protection.vue109
-rw-r--r--packages/frontend/src/pages/admin/database.vue35
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue126
-rw-r--r--packages/frontend/src/pages/admin/emoji-edit-dialog.vue106
-rw-r--r--packages/frontend/src/pages/admin/emojis.vue398
-rw-r--r--packages/frontend/src/pages/admin/files.vue120
-rw-r--r--packages/frontend/src/pages/admin/index.vue316
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue51
-rw-r--r--packages/frontend/src/pages/admin/integrations.discord.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.github.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.twitter.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.vue57
-rw-r--r--packages/frontend/src/pages/admin/metrics.vue472
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue148
-rw-r--r--packages/frontend/src/pages/admin/other-settings.vue44
-rw-r--r--packages/frontend/src/pages/admin/overview.active-users.vue217
-rw-r--r--packages/frontend/src/pages/admin/overview.ap-requests.vue346
-rw-r--r--packages/frontend/src/pages/admin/overview.federation.vue185
-rw-r--r--packages/frontend/src/pages/admin/overview.heatmap.vue15
-rw-r--r--packages/frontend/src/pages/admin/overview.instances.vue50
-rw-r--r--packages/frontend/src/pages/admin/overview.moderators.vue55
-rw-r--r--packages/frontend/src/pages/admin/overview.pie.vue110
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.chart.vue186
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.vue127
-rw-r--r--packages/frontend/src/pages/admin/overview.retention.vue49
-rw-r--r--packages/frontend/src/pages/admin/overview.stats.vue155
-rw-r--r--packages/frontend/src/pages/admin/overview.users.vue57
-rw-r--r--packages/frontend/src/pages/admin/overview.vue190
-rw-r--r--packages/frontend/src/pages/admin/proxy-account.vue62
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.chart.vue186
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.vue149
-rw-r--r--packages/frontend/src/pages/admin/queue.vue56
-rw-r--r--packages/frontend/src/pages/admin/relays.vue103
-rw-r--r--packages/frontend/src/pages/admin/security.vue179
-rw-r--r--packages/frontend/src/pages/admin/settings.vue262
-rw-r--r--packages/frontend/src/pages/admin/users.vue170
-rw-r--r--packages/frontend/src/pages/announcements.vue69
-rw-r--r--packages/frontend/src/pages/antenna-timeline.vue128
-rw-r--r--packages/frontend/src/pages/api-console.vue89
-rw-r--r--packages/frontend/src/pages/auth.form.vue60
-rw-r--r--packages/frontend/src/pages/auth.vue91
-rw-r--r--packages/frontend/src/pages/channel-editor.vue122
-rw-r--r--packages/frontend/src/pages/channel.vue184
-rw-r--r--packages/frontend/src/pages/channels.vue79
-rw-r--r--packages/frontend/src/pages/clip.vue129
-rw-r--r--packages/frontend/src/pages/drive.vue25
-rw-r--r--packages/frontend/src/pages/emojis.emoji.vue72
-rw-r--r--packages/frontend/src/pages/explore.featured.vue30
-rw-r--r--packages/frontend/src/pages/explore.users.vue148
-rw-r--r--packages/frontend/src/pages/explore.vue87
-rw-r--r--packages/frontend/src/pages/favorites.vue49
-rw-r--r--packages/frontend/src/pages/follow-requests.vue153
-rw-r--r--packages/frontend/src/pages/follow.vue62
-rw-r--r--packages/frontend/src/pages/gallery/edit.vue149
-rw-r--r--packages/frontend/src/pages/gallery/index.vue139
-rw-r--r--packages/frontend/src/pages/gallery/post.vue265
-rw-r--r--packages/frontend/src/pages/instance-info.vue258
-rw-r--r--packages/frontend/src/pages/messaging/index.vue327
-rw-r--r--packages/frontend/src/pages/messaging/messaging-room.form.vue364
-rw-r--r--packages/frontend/src/pages/messaging/messaging-room.message.vue367
-rw-r--r--packages/frontend/src/pages/messaging/messaging-room.vue411
-rw-r--r--packages/frontend/src/pages/mfm-cheat-sheet.vue387
-rw-r--r--packages/frontend/src/pages/miauth.vue90
-rw-r--r--packages/frontend/src/pages/my-antennas/create.vue46
-rw-r--r--packages/frontend/src/pages/my-antennas/edit.vue43
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue155
-rw-r--r--packages/frontend/src/pages/my-antennas/index.vue64
-rw-r--r--packages/frontend/src/pages/my-clips/index.vue100
-rw-r--r--packages/frontend/src/pages/my-lists/index.vue82
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue162
-rw-r--r--packages/frontend/src/pages/not-found.vue22
-rw-r--r--packages/frontend/src/pages/note.vue206
-rw-r--r--packages/frontend/src/pages/notifications.vue95
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue63
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue57
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue97
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue54
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.blocks.vue65
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.container.vue155
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.vue394
-rw-r--r--packages/frontend/src/pages/page.vue277
-rw-r--r--packages/frontend/src/pages/pages.vue99
-rw-r--r--packages/frontend/src/pages/preview.vue27
-rw-r--r--packages/frontend/src/pages/registry.keys.vue96
-rw-r--r--packages/frontend/src/pages/registry.value.vue123
-rw-r--r--packages/frontend/src/pages/registry.vue74
-rw-r--r--packages/frontend/src/pages/reset-password.vue59
-rw-r--r--packages/frontend/src/pages/scratchpad.vue137
-rw-r--r--packages/frontend/src/pages/search.vue38
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue216
-rw-r--r--packages/frontend/src/pages/settings/account-info.vue158
-rw-r--r--packages/frontend/src/pages/settings/accounts.vue143
-rw-r--r--packages/frontend/src/pages/settings/api.vue46
-rw-r--r--packages/frontend/src/pages/settings/apps.vue96
-rw-r--r--packages/frontend/src/pages/settings/custom-css.vue46
-rw-r--r--packages/frontend/src/pages/settings/deck.vue39
-rw-r--r--packages/frontend/src/pages/settings/delete-account.vue52
-rw-r--r--packages/frontend/src/pages/settings/drive.vue145
-rw-r--r--packages/frontend/src/pages/settings/email.vue111
-rw-r--r--packages/frontend/src/pages/settings/general.vue196
-rw-r--r--packages/frontend/src/pages/settings/import-export.vue165
-rw-r--r--packages/frontend/src/pages/settings/index.vue291
-rw-r--r--packages/frontend/src/pages/settings/instance-mute.vue53
-rw-r--r--packages/frontend/src/pages/settings/integration.vue99
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue61
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue87
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue90
-rw-r--r--packages/frontend/src/pages/settings/other.vue47
-rw-r--r--packages/frontend/src/pages/settings/plugin.install.vue124
-rw-r--r--packages/frontend/src/pages/settings/plugin.vue98
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue444
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue100
-rw-r--r--packages/frontend/src/pages/settings/profile.vue220
-rw-r--r--packages/frontend/src/pages/settings/reaction.vue154
-rw-r--r--packages/frontend/src/pages/settings/security.vue160
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue45
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue82
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue140
-rw-r--r--packages/frontend/src/pages/settings/statusbar.vue54
-rw-r--r--packages/frontend/src/pages/settings/theme.install.vue80
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue78
-rw-r--r--packages/frontend/src/pages/settings/theme.vue409
-rw-r--r--packages/frontend/src/pages/settings/webhook.edit.vue95
-rw-r--r--packages/frontend/src/pages/settings/webhook.new.vue82
-rw-r--r--packages/frontend/src/pages/settings/webhook.vue53
-rw-r--r--packages/frontend/src/pages/settings/word-mute.vue128
-rw-r--r--packages/frontend/src/pages/share.vue169
-rw-r--r--packages/frontend/src/pages/signup-complete.vue41
-rw-r--r--packages/frontend/src/pages/tag.vue35
-rw-r--r--packages/frontend/src/pages/theme-editor.vue283
-rw-r--r--packages/frontend/src/pages/timeline.tutorial.vue142
-rw-r--r--packages/frontend/src/pages/timeline.vue183
-rw-r--r--packages/frontend/src/pages/user-info.vue485
-rw-r--r--packages/frontend/src/pages/user-list-timeline.vue121
-rw-r--r--packages/frontend/src/pages/user/clips.vue47
-rw-r--r--packages/frontend/src/pages/user/follow-list.vue47
-rw-r--r--packages/frontend/src/pages/user/followers.vue61
-rw-r--r--packages/frontend/src/pages/user/following.vue61
-rw-r--r--packages/frontend/src/pages/user/gallery.vue38
-rw-r--r--packages/frontend/src/pages/user/home.vue530
-rw-r--r--packages/frontend/src/pages/user/index.activity.vue52
-rw-r--r--packages/frontend/src/pages/user/index.photos.vue102
-rw-r--r--packages/frontend/src/pages/user/index.timeline.vue45
-rw-r--r--packages/frontend/src/pages/user/index.vue113
-rw-r--r--packages/frontend/src/pages/user/pages.vue30
-rw-r--r--packages/frontend/src/pages/user/reactions.vue61
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue309
-rw-r--r--packages/frontend/src/pages/welcome.entrance.b.vue237
-rw-r--r--packages/frontend/src/pages/welcome.entrance.c.vue306
-rw-r--r--packages/frontend/src/pages/welcome.setup.vue89
-rw-r--r--packages/frontend/src/pages/welcome.timeline.vue99
-rw-r--r--packages/frontend/src/pages/welcome.vue30
164 files changed, 22163 insertions, 0 deletions
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>